Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,12 @@ const explainRepoDecisionOutputSchema = {

const registryChangesOutputSchema = {
generatedAt: z.string().optional(),
previous: z.unknown().optional(),
current: z.unknown().optional(),
added: z.unknown().optional(),
removed: z.unknown().optional(),
changed: z.unknown().optional(),
warnings: z.unknown().optional(),
currentSnapshotId: z.string().optional(),
previousSnapshotId: z.string().optional(),
addedRepos: z.unknown().optional(),
removedRepos: z.unknown().optional(),
changedRepos: z.unknown().optional(),
summary: z.string().optional(),
};

const upstreamDriftOutputSchema = {
Expand Down
52 changes: 49 additions & 3 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { GittensoryMcp } from "../../src/mcp/server";
import { normalizeRegistryPayload } from "../../src/registry/normalize";
import { persistRegistrySnapshot } from "../../src/registry/sync";
import { createTestEnv } from "../helpers/d1";

// Tools that ship an MCP-native output schema so modern clients can validate/render responses.
Expand All @@ -19,8 +21,8 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
"gittensory_local_status",
];

async function connectTestClient() {
const mcpServer = new GittensoryMcp(createTestEnv()).createServer();
async function connectTestClient(env: Env = createTestEnv()) {
const mcpServer = new GittensoryMcp(env).createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.connect(serverTransport);
const client = new Client({ name: "gittensory-output-schema-test", version: "0.1.0" }, { capabilities: {} });
Expand Down Expand Up @@ -60,6 +62,11 @@ describe("MCP output schema discovery", () => {
const localStatus = byName.get("gittensory_local_status");
const localStatusProps = Object.keys((localStatus?.outputSchema?.properties ?? {}) as Record<string, unknown>);
expect(localStatusProps).toEqual(expect.arrayContaining(["apiAvailable", "supportedEndpoint"]));

const registryChanges = byName.get("gittensory_get_registry_changes");
const registryChangesProps = Object.keys((registryChanges?.outputSchema?.properties ?? {}) as Record<string, unknown>);
expect(registryChangesProps).toEqual(expect.arrayContaining(["currentSnapshotId", "previousSnapshotId", "addedRepos", "removedRepos", "changedRepos", "summary"]));
expect(registryChangesProps).not.toEqual(expect.arrayContaining(["previous", "current", "added", "removed", "changed", "warnings"]));
});

it("preserves the full tool inventory while adding output schemas", async () => {
Expand Down Expand Up @@ -96,10 +103,22 @@ describe("MCP tool calls return schema-valid structured content", () => {
});

it("gittensory_get_registry_changes returns validated structured content", async () => {
const { client } = await connectTestClient();
const env = createTestEnv();
await seedRegistryChangeSnapshots(env);
const { client } = await connectTestClient(env);
const result = await client.callTool({ name: "gittensory_get_registry_changes", arguments: {} });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toBeDefined();
expect(result.structuredContent).toMatchObject({
addedRepos: ["owner/added"],
removedRepos: ["owner/removed"],
currentSnapshotId: expect.any(String),
previousSnapshotId: expect.any(String),
summary: "1 added, 1 removed, 1 changed repo(s) between the latest registry snapshots.",
});
expect((result.structuredContent as Record<string, unknown>).changedRepos).toEqual([
{ repoFullName: "owner/changed", changes: ["emission_share 0.01 -> 0.02"] },
]);
});

it("gittensory_get_repo_context returns validated structured content", async () => {
Expand Down Expand Up @@ -139,3 +158,30 @@ describe("MCP output schemas do not declare private financial fields", () => {
}
});
});

async function seedRegistryChangeSnapshots(env: Env) {
await persistRegistrySnapshot(
env,
normalizeRegistryPayload(
{
"owner/removed": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false },
"owner/changed": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false },
"owner/stable": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false },
},
{ kind: "raw-github", url: "fixture://old-registry" },
"2026-05-24T00:00:00.000Z",
),
);
await persistRegistrySnapshot(
env,
normalizeRegistryPayload(
{
"owner/added": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false },
"owner/changed": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false },
"owner/stable": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false },
},
{ kind: "raw-github", url: "fixture://current-registry" },
"2026-05-25T00:00:00.000Z",
),
);
}