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
132 changes: 132 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createMcpHandler } from "agents/mcp";

Check warning on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #550.

Check notice on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #550.

Check notice on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import type { Context } from "hono";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
Expand Down Expand Up @@ -540,6 +540,120 @@
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<Response> {
if (c.req.method === "OPTIONS") return new Response(null, { status: 204 });
Expand Down Expand Up @@ -765,6 +879,7 @@
{
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)),
);
Expand All @@ -774,6 +889,7 @@
{
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)),
);
Expand Down Expand Up @@ -846,6 +962,7 @@
{
description: "Preflight local git-diff metadata without uploading code content.",
inputSchema: localDiffPreflightShape,
outputSchema: preflightLocalDiffOutputSchema,
},
async (input) => this.toolResult(await this.preflightLocalDiff(input)),
);
Expand All @@ -855,6 +972,7 @@
{
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)),
);
Expand All @@ -864,6 +982,7 @@
{
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)),
);
Expand All @@ -873,6 +992,7 @@
{
description: "Compare private scoring previews for multiple PR variants.",
inputSchema: variantsShape,
outputSchema: variantsOutputSchema,
},
async (input) => this.toolResult(await this.comparePrVariants(input.variants)),
);
Expand Down Expand Up @@ -911,6 +1031,7 @@
{
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")),
);
Expand All @@ -920,6 +1041,7 @@
{
description: "Analyze current-branch metadata and return private scoreability context.",
inputSchema: localBranchAnalysisShape,
outputSchema: previewCurrentBranchScoreOutputSchema,
},
async (input) => this.toolResult(await this.localBranchSlice(input, "scorePreview")),
);
Expand All @@ -929,6 +1051,7 @@
{
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")),
);
Expand All @@ -938,6 +1061,7 @@
{
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")),
);
Expand All @@ -947,6 +1071,7 @@
{
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")),
);
Expand All @@ -956,6 +1081,7 @@
{
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)),
);
Expand All @@ -965,6 +1091,7 @@
{
description: "Compare private local-branch analysis variants without source uploads.",
inputSchema: localBranchVariantsShape,
outputSchema: variantsOutputSchema,
},
async (input) => this.toolResult(await this.compareLocalVariants(input.variants)),
);
Expand All @@ -974,6 +1101,7 @@
{
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)),
);
Expand All @@ -983,6 +1111,7 @@
{
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)),
);
Expand All @@ -992,6 +1121,7 @@
{
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)),
);
Expand All @@ -1001,6 +1131,7 @@
{
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)),
);
Expand All @@ -1010,6 +1141,7 @@
{
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)),
);
Expand Down
63 changes: 62 additions & 1 deletion test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

Check warning on line 1 in test/unit/mcp-output-schemas.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #550.

Check notice on line 1 in test/unit/mcp-output-schemas.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #550.

Check notice on line 1 in test/unit/mcp-output-schemas.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in test/unit/mcp-output-schemas.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in test/unit/mcp-output-schemas.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
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";
Expand Down Expand Up @@ -51,6 +51,14 @@
}
});

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();
Expand Down Expand Up @@ -298,3 +306,56 @@
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<string, unknown>]> = [
["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();
});
});
Loading