diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7829e7034c..eeaa1c39e8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -252,6 +252,104 @@ const variantsShape = { variants: z.array(z.object(scorePreviewShape)).min(1).max(10), }; +// ── MCP tool output schemas ──────────────────────────────────────────────── +// Structured-output metadata for machine-readable tools so modern MCP clients +// can discover and validate Gittensory responses. Schemas declare documented +// top-level fields; complex/nullable/variant fields use a permissive type so +// validation never rejects a real response (the SDK strips unknown keys). All +// fields are optional because several tools return either a result payload or a +// `{ status: "not_found" | ... }` / refresh envelope. +const repoContextOutputSchema = { + repoFullName: z.string().optional(), + repo: z.unknown().optional(), + lane: z.unknown().optional(), + queueHealth: z.unknown().optional(), + collisions: z.unknown().optional(), + configQuality: z.unknown().optional(), + dataQuality: z.unknown().optional(), +}; + +const freshnessResponseOutputSchema = { + status: z.string().optional(), + repoFullName: z.string().optional(), + source: z.string().optional(), + freshness: z.string().optional(), + generatedAt: z.string().optional(), + report: z.unknown().optional(), +}; + +const contributorProfileOutputSchema = { + login: z.string().optional(), + github: z.unknown().optional(), + source: z.unknown().optional(), + repoStats: z.unknown().optional(), + trustSignals: z.unknown().optional(), +}; + +const decisionPackOutputSchema = { + status: z.string().optional(), + login: z.string().optional(), + source: z.string().optional(), + freshness: z.string().optional(), + generatedAt: z.string().optional(), + rebuildEnqueued: z.boolean().optional(), + summary: z.string().optional(), + repoDecisions: z.unknown().optional(), + topActions: z.unknown().optional(), +}; + +const openPrMonitorOutputSchema = { + login: z.string().optional(), + generatedAt: z.string().optional(), + openPrCount: z.number().optional(), + registeredRepoCount: z.number().optional(), + cleanupFirst: z.boolean().optional(), + summary: z.string().optional(), + guidance: z.unknown().optional(), + pendingScenarios: z.unknown().optional(), + pullRequests: z.unknown().optional(), +}; + +const explainRepoDecisionOutputSchema = { + status: z.string().optional(), + login: z.string().optional(), + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + source: z.string().optional(), + freshness: z.string().optional(), + rebuildEnqueued: z.boolean().optional(), + decision: z.unknown().optional(), + dataQuality: z.unknown().optional(), +}; + +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(), +}; + +const upstreamDriftOutputSchema = { + generatedAt: z.string().optional(), + status: z.string().optional(), + latestCommitSha: z.string().nullable().optional(), + latestRulesetId: z.string().nullable().optional(), + highestSeverity: z.string().nullable().optional(), + affectedAreas: z.unknown().optional(), + openReportCount: z.number().optional(), + reports: z.unknown().optional(), +}; + +const localStatusOutputSchema = { + apiAvailable: z.boolean().optional(), + sourceUploadDefault: z.boolean().optional(), + supportedEndpoint: z.string().optional(), + supportedTools: z.unknown().optional(), +}; + export async function handleMcpRequest(c: AppContext): Promise { if (c.req.method === "OPTIONS") return new Response(null, { status: 204 }); const identity = await authenticateMcpRequest(c); @@ -324,6 +422,7 @@ export class GittensoryMcp { { description: "Return Gittensory repo context: registration, lane, queue health, collisions, and config quality.", inputSchema: ownerRepoShape, + outputSchema: repoContextOutputSchema, }, async (input) => this.toolResult(await this.getRepoContext(input)), ); @@ -333,6 +432,7 @@ export class GittensoryMcp { { description: "Return the cached or freshly-computed maintainer burden forecast for a repo, including projected review load, queue growth risk, stale PR signals, and a freshness marker.", inputSchema: ownerRepoShape, + outputSchema: freshnessResponseOutputSchema, }, async (input) => this.toolResult(await this.getBurdenForecast(input)), ); @@ -342,6 +442,7 @@ export class GittensoryMcp { { description: "Return cached or freshly-computed per-repo accepted/rejected PR outcome patterns: what maintainers actually merge or close, separated from maintainer-lane activity, with a freshness marker and explicit evidence-completeness.", inputSchema: ownerRepoShape, + outputSchema: freshnessResponseOutputSchema, }, async (input) => this.toolResult(await this.getRepoOutcomePatterns(input)), ); @@ -351,6 +452,7 @@ export class GittensoryMcp { { description: "Return an evidence-backed Gittensory contributor profile for a GitHub login.", inputSchema: loginShape, + outputSchema: contributorProfileOutputSchema, }, async (input) => this.toolResult(await this.getContributorProfile(input.login)), ); @@ -360,6 +462,7 @@ export class GittensoryMcp { { description: "Return the canonical private contributor decision pack for a GitHub login.", inputSchema: loginShape, + outputSchema: decisionPackOutputSchema, }, async (input) => this.toolResult(await this.getDecisionPack(input.login)), ); @@ -370,6 +473,7 @@ export class GittensoryMcp { description: "Inspect a contributor's open PRs on registered repos, classify queue state, and return public-safe next-step packets from cached metadata.", inputSchema: loginShape, + outputSchema: openPrMonitorOutputSchema, }, async (input) => this.toolResult(await this.monitorOpenPullRequests(input.login)), ); @@ -379,6 +483,7 @@ export class GittensoryMcp { { description: "Return the contributor/repo decision from the canonical decision pack.", inputSchema: loginRepoShape, + outputSchema: explainRepoDecisionOutputSchema, }, async (input) => this.toolResult(await this.explainRepoDecision(input)), ); @@ -406,6 +511,7 @@ export class GittensoryMcp { { description: "Return the diff between the latest cached Gittensor registry snapshots.", inputSchema: {}, + outputSchema: registryChangesOutputSchema, }, async () => this.toolResult(await this.getRegistryChanges()), ); @@ -415,6 +521,7 @@ export class GittensoryMcp { { description: "Return private upstream Gittensor ruleset drift status, including stale/drift warnings for MCP planning.", inputSchema: {}, + outputSchema: upstreamDriftOutputSchema, }, async () => this.toolResult(await this.getUpstreamDrift()), ); @@ -424,6 +531,7 @@ export class GittensoryMcp { { description: "Return the cached or freshly-computed issue-quality report for a repo, ranking which open issues are actionable, need proof, are stale/duplicate-prone, or already solved.", inputSchema: ownerRepoShape, + outputSchema: freshnessResponseOutputSchema, }, async (input) => this.toolResult(await this.getIssueQuality(input)), ); @@ -469,6 +577,7 @@ export class GittensoryMcp { { description: "Return Gittensory local-MCP contract status and privacy defaults.", inputSchema: {}, + outputSchema: localStatusOutputSchema, }, async () => this.toolResult({ diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts new file mode 100644 index 0000000000..d1473ef996 --- /dev/null +++ b/test/unit/mcp-output-schemas.test.ts @@ -0,0 +1,141 @@ +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 { createTestEnv } from "../helpers/d1"; + +// Tools that ship an MCP-native output schema so modern clients can validate/render responses. +const TOOLS_WITH_OUTPUT_SCHEMA = [ + "gittensory_get_repo_context", + "gittensory_get_burden_forecast", + "gittensory_get_repo_outcome_patterns", + "gittensory_get_contributor_profile", + "gittensory_get_decision_pack", + "gittensory_monitor_open_prs", + "gittensory_explain_repo_decision", + "gittensory_get_issue_quality", + "gittensory_get_registry_changes", + "gittensory_get_upstream_drift", + "gittensory_local_status", +]; + +async function connectTestClient() { + const mcpServer = new GittensoryMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverTransport); + const client = new Client({ name: "gittensory-output-schema-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return { client, mcpServer }; +} + +// ── Output schema discovery ──────────────────────────────────────────────────── + +describe("MCP output schema discovery", () => { + it("exposes an outputSchema for every covered tool in tools/list", async () => { + const { client } = await connectTestClient(); + const { tools } = await client.listTools(); + const byName = new Map(tools.map((t) => [t.name, t])); + + for (const name of TOOLS_WITH_OUTPUT_SCHEMA) { + const tool = byName.get(name); + expect(tool, `expected tool "${name}" to be registered`).toBeDefined(); + expect(tool?.outputSchema, `expected tool "${name}" to expose an outputSchema`).toBeDefined(); + expect(tool?.outputSchema?.type).toBe("object"); + } + }); + + it("output schemas declare documented top-level properties", async () => { + const { client } = await connectTestClient(); + const { tools } = await client.listTools(); + const byName = new Map(tools.map((t) => [t.name, t])); + + const repoContext = byName.get("gittensory_get_repo_context"); + const repoContextProps = Object.keys((repoContext?.outputSchema?.properties ?? {}) as Record); + expect(repoContextProps).toEqual(expect.arrayContaining(["repoFullName", "lane", "queueHealth", "configQuality"])); + + const upstream = byName.get("gittensory_get_upstream_drift"); + const upstreamProps = Object.keys((upstream?.outputSchema?.properties ?? {}) as Record); + expect(upstreamProps).toEqual(expect.arrayContaining(["status", "highestSeverity"])); + + const localStatus = byName.get("gittensory_local_status"); + const localStatusProps = Object.keys((localStatus?.outputSchema?.properties ?? {}) as Record); + expect(localStatusProps).toEqual(expect.arrayContaining(["apiAvailable", "supportedEndpoint"])); + }); + + it("preserves the full tool inventory while adding output schemas", async () => { + const { client } = await connectTestClient(); + const { tools } = await client.listTools(); + const names = new Set(tools.map((t) => t.name)); + + // A representative slice of tools without output schemas remains intact. + expect(names.has("gittensory_preflight_pr")).toBe(true); + expect(names.has("gittensory_agent_plan_next_work")).toBe(true); + expect(names.has("gittensory_compare_pr_variants")).toBe(true); + }); +}); + +// ── Structured content validates against the declared schema ───────────────────── + +describe("MCP tool calls return schema-valid structured content", () => { + it("gittensory_local_status returns validated structured content", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_local_status", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toBeDefined(); + const data = result.structuredContent as Record; + expect(data.apiAvailable).toBe(true); + expect(data.supportedEndpoint).toBe("/v1/local/branch-analysis"); + }); + + it("gittensory_get_upstream_drift returns validated structured content", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_get_upstream_drift", arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(["current", "drift_detected", "stale", "unavailable"]).toContain(data.status); + }); + + it("gittensory_get_registry_changes returns validated structured content", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_get_registry_changes", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toBeDefined(); + }); + + it("gittensory_get_repo_context returns validated structured content", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_get_repo_context", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("octo/demo"); + }); +}); + +// ── Public/private safety ───────────────────────────────────────────────────── + +describe("MCP output schemas do not declare private financial fields", () => { + it("no output schema exposes wallet/hotkey/coldkey/financial property names", async () => { + const { client } = await connectTestClient(); + const { tools } = await client.listTools(); + + for (const tool of tools) { + if (!tool.outputSchema) continue; + const serialized = JSON.stringify(tool.outputSchema); + expect(serialized, `tool "${tool.name}" output schema must not declare private fields`).not.toMatch( + /hotkey|coldkey|wallet|mnemonic|alphaPerDay|taoPerDay|usdPerDay|rawTrust|privateReviewability/i, + ); + } + }); + + it("structured content from public-safe tools never includes redacted financial keys", async () => { + const { client } = await connectTestClient(); + + for (const name of ["gittensory_local_status", "gittensory_get_upstream_drift", "gittensory_get_registry_changes"]) { + const result = await client.callTool({ name, arguments: {} }); + const serialized = JSON.stringify(result.structuredContent ?? {}); + expect(serialized, `tool "${name}" structured content must not leak financial fields`).not.toMatch( + /hotkey|coldkey|wallet|mnemonic|alphaPerDay|taoPerDay|usdPerDay/i, + ); + } + }); +});