diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3cd1c441a7..dfb6212d7e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -540,6 +540,120 @@ const lintPrTextOutputSchema = { summary: z.string().optional(), generatedAt: z.string().optional(), }; +// #550: output schemas for the remaining tools (preflight/score/local-branch/agent), so MCP clients can +// machine-validate their results. Same lenient style as the schemas above — documented top-level keys, +// all optional, complex values as z.unknown(). No behavior change; these mirror the existing payloads. +const preflightResultOutputSchema = { + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + status: z.string().optional(), + lane: z.unknown().optional(), + reviewBurden: z.unknown().optional(), + linkedIssues: z.unknown().optional(), + findings: z.array(z.unknown()).optional(), + collisions: z.unknown().optional(), +}; +const bountyAdvisoryOutputSchema = { + id: z.string().optional(), + repoFullName: z.string().optional(), + issueNumber: z.number().optional(), + status: z.string().optional(), + lifecycle: z.unknown().optional(), + isActiveOpportunity: z.boolean().optional(), + fundingStatus: z.unknown().optional(), + consensusRisk: z.unknown().optional(), + source: z.unknown().optional(), + linkedPrs: z.unknown().optional(), + findings: z.array(z.unknown()).optional(), +}; +const preflightLocalDiffOutputSchema = { + ...preflightResultOutputSchema, + localDiff: z.unknown().optional(), +}; +const scorePreviewRecordOutputSchema = { + id: z.string().optional(), + scoringModelSnapshotId: z.string().optional(), + repoFullName: z.string().optional(), + targetType: z.string().optional(), + targetKey: z.string().optional(), + contributorLogin: z.string().optional(), + input: z.unknown().optional(), + result: z.unknown().optional(), + generatedAt: z.string().optional(), +}; +const explainReviewRiskOutputSchema = { + preflight: z.unknown().optional(), + roleContext: z.unknown().optional(), + recommendation: z.string().optional(), +}; +const variantsOutputSchema = { + variants: z.array(z.unknown()).optional(), +}; +const preflightCurrentBranchOutputSchema = { + login: z.string().optional(), + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + preflight: z.unknown().optional(), + dataQuality: z.unknown().optional(), +}; +const previewCurrentBranchScoreOutputSchema = { + login: z.string().optional(), + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + scorePreview: z.unknown().optional(), + scenarioScorePreview: z.unknown().optional(), + dataQuality: z.unknown().optional(), +}; +const rankLocalNextActionsOutputSchema = { + login: z.string().optional(), + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + nextActions: z.array(z.unknown()).optional(), + recommendedRerunCondition: z.unknown().optional(), + dataQuality: z.unknown().optional(), +}; +const explainLocalBlockersOutputSchema = { + login: z.string().optional(), + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + scoreBlockers: z.unknown().optional(), + scenarioScorePreview: z.unknown().optional(), + branchQualityBlockers: z.unknown().optional(), + accountStateBlockers: z.unknown().optional(), + recommendedRerunCondition: z.unknown().optional(), + dataQuality: z.unknown().optional(), +}; +const prepareLocalPrPacketOutputSchema = { + login: z.string().optional(), + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + prPacket: z.unknown().optional(), + dataQuality: z.unknown().optional(), +}; +const draftPrBodyOutputSchema = { + repoFullName: z.string().optional(), + title: z.string().optional(), + sections: z.unknown().optional(), + markdown: z.string().optional(), + caveats: z.array(z.unknown()).optional(), + excludedPrivateFields: z.array(z.unknown()).optional(), + sourceUploadDisabled: z.boolean().optional(), +}; +const agentRunBundleOutputSchema = { + run: z.unknown().optional(), + actions: z.array(z.unknown()).optional(), + contextSnapshots: z.array(z.unknown()).optional(), + summary: z.unknown().optional(), +}; +const agentPlanNextWorkOutputSchema = { + ...agentRunBundleOutputSchema, + planningElicitation: z.unknown().optional(), + planningChoices: z.unknown().optional(), +}; +const agentExplainNextActionOutputSchema = { + ...agentRunBundleOutputSchema, + topAction: z.unknown().optional(), +}; export async function handleMcpRequest(c: AppContext): Promise { if (c.req.method === "OPTIONS") return new Response(null, { status: 204 }); @@ -765,6 +879,7 @@ export class GittensoryMcp { { description: "Preflight a planned PR for lane correctness, duplicate risk, linked issues, and review burden.", inputSchema: preflightShape, + outputSchema: preflightResultOutputSchema, }, async (input) => this.toolResult(await this.preflightPr(input)), ); @@ -774,6 +889,7 @@ export class GittensoryMcp { { description: "Return lifecycle, funding, and consensus-risk context for a cached Gittensor bounty.", inputSchema: bountyShape, + outputSchema: bountyAdvisoryOutputSchema, }, async (input) => this.toolResult(await this.getBountyAdvisory(input.id)), ); @@ -846,6 +962,7 @@ export class GittensoryMcp { { description: "Preflight local git-diff metadata without uploading code content.", inputSchema: localDiffPreflightShape, + outputSchema: preflightLocalDiffOutputSchema, }, async (input) => this.toolResult(await this.preflightLocalDiff(input)), ); @@ -855,6 +972,7 @@ export class GittensoryMcp { { description: "Return a private scoring preview from local diff metrics or supplied metadata. Source contents are not required.", inputSchema: scorePreviewShape, + outputSchema: scorePreviewRecordOutputSchema, }, async (input) => this.toolResult(await this.previewScore(input)), ); @@ -864,6 +982,7 @@ export class GittensoryMcp { { description: "Explain review risk for a planned PR using preflight, lane, duplicate, and role context.", inputSchema: preflightShape, + outputSchema: explainReviewRiskOutputSchema, }, async (input) => this.toolResult(await this.explainReviewRisk(input)), ); @@ -873,6 +992,7 @@ export class GittensoryMcp { { description: "Compare private scoring previews for multiple PR variants.", inputSchema: variantsShape, + outputSchema: variantsOutputSchema, }, async (input) => this.toolResult(await this.comparePrVariants(input.variants)), ); @@ -911,6 +1031,7 @@ export class GittensoryMcp { { description: "Analyze current-branch metadata supplied by a local MCP wrapper and return PR readiness.", inputSchema: localBranchAnalysisShape, + outputSchema: preflightCurrentBranchOutputSchema, }, async (input) => this.toolResult(await this.localBranchSlice(input, "preflight")), ); @@ -920,6 +1041,7 @@ export class GittensoryMcp { { description: "Analyze current-branch metadata and return private scoreability context.", inputSchema: localBranchAnalysisShape, + outputSchema: previewCurrentBranchScoreOutputSchema, }, async (input) => this.toolResult(await this.localBranchSlice(input, "scorePreview")), ); @@ -929,6 +1051,7 @@ export class GittensoryMcp { { description: "Analyze current-branch metadata and rank local next actions by private reward/risk signals.", inputSchema: localBranchAnalysisShape, + outputSchema: rankLocalNextActionsOutputSchema, }, async (input) => this.toolResult(await this.localBranchSlice(input, "nextActions")), ); @@ -938,6 +1061,7 @@ export class GittensoryMcp { { description: "Analyze current-branch metadata and explain private scoreability and review blockers.", inputSchema: localBranchAnalysisShape, + outputSchema: explainLocalBlockersOutputSchema, }, async (input) => this.toolResult(await this.localBranchSlice(input, "scoreBlockers")), ); @@ -947,6 +1071,7 @@ export class GittensoryMcp { { description: "Analyze current-branch metadata and return a public-safe PR packet for coding agents.", inputSchema: localBranchAnalysisShape, + outputSchema: prepareLocalPrPacketOutputSchema, }, async (input) => this.toolResult(await this.localBranchSlice(input, "prPacket")), ); @@ -956,6 +1081,7 @@ export class GittensoryMcp { { description: "Draft a public-safe, copy/paste PR body from local branch metadata (changed files, tests run, linked issue, duplicate/WIP caution, branch freshness, next steps). Private scoreability/reward/trust context is excluded; source contents are not uploaded.", inputSchema: localBranchAnalysisShape, + outputSchema: draftPrBodyOutputSchema, }, async (input) => this.toolResult(await this.draftPrBody(input)), ); @@ -965,6 +1091,7 @@ export class GittensoryMcp { { description: "Compare private local-branch analysis variants without source uploads.", inputSchema: localBranchVariantsShape, + outputSchema: variantsOutputSchema, }, async (input) => this.toolResult(await this.compareLocalVariants(input.variants)), ); @@ -974,6 +1101,7 @@ export class GittensoryMcp { { description: "Run the deterministic Gittensory base-agent planner and rank the next Gittensor OSS contribution actions.", inputSchema: agentPlanShape, + outputSchema: agentPlanNextWorkOutputSchema, }, async (input, extra) => this.toolResult(await this.agentPlanNextWork(input, extra, server)), ); @@ -983,6 +1111,7 @@ export class GittensoryMcp { { description: "Create a queued copilot-only Gittensory agent run. The agent plans and explains; it does not edit code or open PRs.", inputSchema: agentRunShape, + outputSchema: agentRunBundleOutputSchema, }, async (input) => this.toolResult(await this.agentStartRun(input)), ); @@ -992,6 +1121,7 @@ export class GittensoryMcp { { description: "Fetch a persisted Gittensory agent run with ranked actions and context snapshots.", inputSchema: agentRunIdShape, + outputSchema: agentRunBundleOutputSchema, }, async (input) => this.toolResult(await this.agentGetRun(input.runId)), ); @@ -1001,6 +1131,7 @@ export class GittensoryMcp { { description: "Explain the top deterministic next action and its scoreability/risk/maintainer impact.", inputSchema: agentPlanShape, + outputSchema: agentExplainNextActionOutputSchema, }, async (input) => this.toolResult(await this.agentExplainNextAction(input)), ); @@ -1010,6 +1141,7 @@ export class GittensoryMcp { { description: "Prepare a public-safe PR packet from local branch metadata. Source contents are not uploaded.", inputSchema: localBranchAnalysisShape, + outputSchema: agentRunBundleOutputSchema, }, async (input) => this.toolResult(await this.agentPreparePrPacket(input)), ); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 0a0424cf90..40789d25f6 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -1,7 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; -import { persistSignalSnapshot, upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { GittensoryMcp } from "../../src/mcp/server"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; @@ -51,6 +51,14 @@ describe("MCP output schema discovery", () => { } }); + it("exposes an outputSchema on EVERY registered tool (#550)", async () => { + const { client } = await connectTestClient(); + const { tools } = await client.listTools(); + expect(tools.length).toBeGreaterThan(0); + const missing = tools.filter((tool) => tool.outputSchema === undefined || tool.outputSchema.type !== "object").map((tool) => tool.name); + expect(missing, `tools missing a machine-validatable outputSchema: ${missing.join(", ")}`).toEqual([]); + }); + it("output schemas declare documented top-level properties", async () => { const { client } = await connectTestClient(); const { tools } = await client.listTools(); @@ -298,3 +306,56 @@ function repoOutcomePatternsPayload(repoFullName: string, generatedAt: string) { summary: "cached fixture", }; } + +// ── #550: the previously-unschematized tools are now call-tested so a future schema/type mismatch +// (which surfaces as an "Output validation error" → isError) can't slip through CI. ───────────── +describe("MCP output schemas validate on real tool calls (#550)", () => { + it("every newly-schematized tool returns schema-valid structured content", async () => { + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "octo/demo": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false } }, + { kind: "raw-github", url: "fixture://reg" }, + "2026-06-14T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertBounty(env, { id: "octo/demo#1", repoFullName: "octo/demo", issueNumber: 1, status: "active", payload: {} }); + const { client } = await connectTestClient(env); + + const local = { login: "oktofeesh1", repoFullName: "octo/demo" }; + const calls: Array<[string, Record]> = [ + ["gittensory_preflight_pr", { repoFullName: "octo/demo", title: "Add pagination" }], + ["gittensory_preflight_local_diff", { repoFullName: "octo/demo", title: "Add pagination" }], + ["gittensory_explain_review_risk", { repoFullName: "octo/demo", title: "Add pagination" }], + ["gittensory_preview_local_pr_score", { repoFullName: "octo/demo" }], + ["gittensory_compare_pr_variants", { variants: [{ repoFullName: "octo/demo" }] }], + ["gittensory_get_bounty_advisory", { id: "octo/demo#1" }], + ["gittensory_preflight_current_branch", local], + ["gittensory_preview_current_branch_score", local], + ["gittensory_rank_local_next_actions", local], + ["gittensory_explain_local_blockers", local], + ["gittensory_prepare_pr_packet", local], + ["gittensory_draft_pr_body", local], + ["gittensory_compare_local_variants", { variants: [local] }], + ["gittensory_agent_plan_next_work", { login: "oktofeesh1" }], + ["gittensory_agent_explain_next_action", { login: "oktofeesh1" }], + ["gittensory_agent_prepare_pr_packet", local], + ]; + for (const [name, args] of calls) { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError, `${name} errored: ${JSON.stringify(result.content)}`).toBeFalsy(); + expect(result.structuredContent, `${name} missing structuredContent`).toBeDefined(); + } + + // Stateful agent run lifecycle: start_run mints a run, get_run reads it back. + const started = await client.callTool({ name: "gittensory_agent_start_run", arguments: { objective: "Ship a PR", actorLogin: "oktofeesh1" } }); + expect(started.isError, `agent_start_run errored: ${JSON.stringify(started.content)}`).toBeFalsy(); + const runId = (started.structuredContent as { run?: { id?: string } }).run?.id; + expect(runId).toBeDefined(); + const fetched = await client.callTool({ name: "gittensory_agent_get_run", arguments: { runId } }); + expect(fetched.isError, `agent_get_run errored: ${JSON.stringify(fetched.content)}`).toBeFalsy(); + expect(fetched.structuredContent).toBeDefined(); + }); +});