From 887ca4ba4b998eca51365aafb5e6ef7aed8d1041 Mon Sep 17 00:00:00 2001 From: web-dev0521 Date: Wed, 3 Jun 2026 02:54:29 -0600 Subject: [PATCH] feat(mcp): add structured output schemas for existing tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MCP-native output schemas to 11 machine-readable tools so modern clients can discover and validate Gittensory responses. Existing CLI JSON behavior and tool result shapes are unchanged — outputSchema is metadata plus server-side validation only. Tools covered (outputSchema added): - gittensory_get_repo_context (repoContextOutputSchema) - gittensory_get_burden_forecast (freshnessResponseOutputSchema) - gittensory_get_repo_outcome_patterns (freshnessResponseOutputSchema) - gittensory_get_issue_quality (freshnessResponseOutputSchema) - gittensory_get_contributor_profile (contributorProfileOutputSchema) - gittensory_get_decision_pack (decisionPackOutputSchema) - gittensory_monitor_open_prs (openPrMonitorOutputSchema) - gittensory_explain_repo_decision (explainRepoDecisionOutputSchema) - gittensory_get_registry_changes (registryChangesOutputSchema) - gittensory_get_upstream_drift (upstreamDriftOutputSchema) - gittensory_local_status (localStatusOutputSchema) Schema design: - Declares documented top-level fields; complex/nullable/variant fields use a permissive type so validation never rejects a real response (the SDK strips unknown keys and accepts them). - All fields optional because several tools return either a result payload or a not_found / refresh envelope. - No schema declares wallet/hotkey/coldkey/financial property names, so public/private boundaries are preserved across surfaces. test/unit/mcp-output-schemas.test.ts (new, 9 tests): - Discovery: every covered tool exposes an object outputSchema; documented top-level properties present; full tool inventory preserved. - Validation: local_status, upstream_drift, registry_changes, and repo_context calls return schema-valid structuredContent (the SDK validates server-side before returning). - Safety: no output schema declares private financial field names; public tool structured content never leaks redacted financial keys. The full suite (including the api.test.ts integration tests that call these tools with seeded data) passes, confirming the schemas validate against real responses. --- src/mcp/server.ts | 109 +++++++++++++++++++++ test/unit/mcp-output-schemas.test.ts | 141 +++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 test/unit/mcp-output-schemas.test.ts 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, + ); + } + }); +});