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
109 changes: 109 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
if (c.req.method === "OPTIONS") return new Response(null, { status: 204 });
const identity = await authenticateMcpRequest(c);
Expand Down Expand Up @@ -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)),
);
Expand All @@ -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)),
);
Expand All @@ -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)),
);
Expand All @@ -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)),
);
Expand All @@ -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)),
);
Expand All @@ -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)),
);
Expand All @@ -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)),
);
Expand Down Expand Up @@ -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()),
);
Expand All @@ -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()),
);
Expand All @@ -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)),
);
Expand Down Expand Up @@ -469,6 +577,7 @@ export class GittensoryMcp {
{
description: "Return Gittensory local-MCP contract status and privacy defaults.",
inputSchema: {},
outputSchema: localStatusOutputSchema,
},
async () =>
this.toolResult({
Expand Down
141 changes: 141 additions & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>);
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<string, unknown>);
expect(upstreamProps).toEqual(expect.arrayContaining(["status", "highestSeverity"]));

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"]));
});

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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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,
);
}
});
});