Skip to content

Commit c4432b4

Browse files
feat(mcp): add gittensory_get_gate_precision measurement tool (#4323)
The gate-precision report builder and HTTP route already exist (buildGatePrecisionReport / loadGatePrecisionReport in src/services/gate-precision.ts; GET /v1/repos/:owner/:repo/gate-precision), but there was no MCP tool wrapper, so agents could not ask 'how accurate is my gate?' over MCP the way they can for outcome calibration. Registers gittensory_get_gate_precision alongside the other maintainer-intel tools with the standard ownerRepoWindowShape input, behind the same requireRepoAccess read gate as getOutcomeCalibration. The handler calls the existing loadGatePrecisionReport with a spread-omitted windowDays option (exactOptionalPropertyTypes), and the zod outputSchema mirrors the maintainerMeasurementReportOutputSchema convention for report-shaped outputs. Tests cover the authorized path with a seeded gate-block ledger (windowDays pass-through, per-type and overall rates), the empty-ledger path (null false-positive rate), the forbidden path, and both branches of the summary's ?? fallback. Closes #2220
1 parent d329591 commit c4432b4

3 files changed

Lines changed: 118 additions & 0 deletions

File tree

src/mcp/server.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/m
9595
import { loadLabelAudit, labelAuditSummary } from "../services/label-audit";
9696
import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane";
9797
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
98+
import { loadGatePrecisionReport } from "../services/gate-precision";
9899
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
99100
import {
100101
applyMcpPlanningChoices,
@@ -778,6 +779,18 @@ const maintainerMeasurementReportOutputSchema = {
778779
status: z.string().optional(),
779780
};
780781

782+
// #2220 - gate-precision measurement surfaced over MCP. Mirrors the
783+
// maintainerMeasurementReportOutputSchema pattern: report fields optional, structured sub-reports as
784+
// z.unknown() (buildGatePrecisionReport is the single source of truth for their shape).
785+
const gatePrecisionOutputSchema = {
786+
repoFullName: z.string().optional(),
787+
generatedAt: z.string().optional(),
788+
windowDays: z.number().nullable().optional(),
789+
perGateType: z.array(z.unknown()).optional(),
790+
overall: z.unknown().optional(),
791+
signals: z.array(z.string()).optional(),
792+
};
793+
781794
const contributorProfileOutputSchema = {
782795
login: z.string().optional(),
783796
github: z.unknown().optional(),
@@ -1404,6 +1417,17 @@ export class GittensoryMcp {
14041417
async (input) => this.toolResult(await this.getOutcomeCalibration(input)),
14051418
);
14061419

1420+
server.registerTool(
1421+
"gittensory_get_gate_precision",
1422+
{
1423+
description:
1424+
"Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged / overridden counts and false-positive rates with low-sample guards. Maintainer-authenticated; measurement only.",
1425+
inputSchema: ownerRepoWindowShape,
1426+
outputSchema: gatePrecisionOutputSchema,
1427+
},
1428+
async (input) => this.toolResult(await this.getGatePrecision(input)),
1429+
);
1430+
14071431
server.registerTool(
14081432
"gittensory_get_fleet_analytics",
14091433
{
@@ -2587,6 +2611,20 @@ export class GittensoryMcp {
25872611
};
25882612
}
25892613

2614+
// #2220 - surface the existing gate-precision measurement over MCP. Same per-repo read gate as
2615+
// getOutcomeCalibration (requireRepoAccess); loadGatePrecisionReport is measurement-only and already
2616+
// scoped to the single repo, so nothing cross-repo is revealed. The options object is spread-omitted
2617+
// when windowDays is absent to satisfy exactOptionalPropertyTypes.
2618+
private async getGatePrecision(input: { owner: string; repo: string; windowDays?: number | undefined }): Promise<ToolPayload> {
2619+
const fullName = `${input.owner}/${input.repo}`;
2620+
await this.requireRepoAccess(fullName);
2621+
const report = await loadGatePrecisionReport(this.env, fullName, input.windowDays === undefined ? {} : { windowDays: input.windowDays });
2622+
return {
2623+
summary: `Gittensory gate precision for ${fullName}: ${report.overall.blocked} gate blocks, overall false-positive rate ${report.overall.falsePositiveRate ?? "n/a (below sample threshold)"}.`,
2624+
data: report as unknown as Record<string, unknown>,
2625+
};
2626+
}
2627+
25902628
// #2224 - surface the deterministic open-PR pressure simulator over MCP. Pure and read-only: the caller
25912629
// supplies all queue/role context, so nothing beyond a computation on that input is revealed and no repo
25922630
// access is required (mirrors gittensory_run_local_scorer). Output is already public-safe - every scenario
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { describe, expect, it } from "vitest";
4+
import { GittensoryMcp } from "../../src/mcp/server";
5+
import { recordGateBlockOutcome, upsertPullRequestFromGitHub } from "../../src/db/repositories";
6+
import { createTestEnv } from "../helpers/d1";
7+
8+
const REPO = "owner/widgets";
9+
10+
async function connect(env: Env) {
11+
const server = new GittensoryMcp(env).createServer();
12+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
13+
await server.connect(serverTransport);
14+
const client = new Client({ name: "gittensory-gate-precision-test", version: "0.1.0" }, { capabilities: {} });
15+
await client.connect(clientTransport);
16+
return client;
17+
}
18+
19+
// 6 blocks citing one code, 2 on PRs that later merged (false positives) → rate 2/6 = 0.333,
20+
// comfortably above the service's MIN_SAMPLE guard so the per-type rate is a number, not null.
21+
async function seedGateLedger(env: Env) {
22+
for (let n = 1; n <= 6; n += 1) {
23+
await recordGateBlockOutcome(env, { repoFullName: REPO, pullNumber: n, headSha: `sha${n}`, blockerCodes: ["missing_linked_issue"] });
24+
await upsertPullRequestFromGitHub(env, REPO, {
25+
number: n,
26+
title: `PR ${n}`,
27+
state: "closed",
28+
user: { login: "alice" },
29+
...(n <= 2 ? { merged_at: "2026-06-01T00:00:00.000Z" } : {}),
30+
});
31+
}
32+
}
33+
34+
describe("MCP gittensory_get_gate_precision (#2220)", () => {
35+
it("returns the per-gate-type precision report for an authorized caller and passes windowDays through", async () => {
36+
const env = createTestEnv();
37+
await seedGateLedger(env);
38+
const client = await connect(env);
39+
const result = await client.callTool({ name: "gittensory_get_gate_precision", arguments: { owner: "owner", repo: "widgets", windowDays: 30 } });
40+
expect(result.isError).toBeFalsy();
41+
const data = result.structuredContent as {
42+
repoFullName: string;
43+
windowDays: number | null;
44+
perGateType: Array<{ gateType: string; blocked: number; blockedThenMerged: number; falsePositiveRate: number | null }>;
45+
overall: { blocked: number; blockedThenMerged: number; falsePositiveRate: number | null };
46+
signals: string[];
47+
};
48+
expect(data.repoFullName).toBe(REPO);
49+
expect(data.windowDays).toBe(30);
50+
expect(data.overall).toMatchObject({ blocked: 6, blockedThenMerged: 2, falsePositiveRate: 0.333 });
51+
expect(data.perGateType[0]).toMatchObject({ gateType: "missing_linked_issue", blocked: 6, blockedThenMerged: 2, falsePositiveRate: 0.333 });
52+
expect(Array.isArray(data.signals)).toBe(true);
53+
// Numeric branch of the summary's ?? fallback.
54+
expect(JSON.stringify(result.content)).toContain("overall false-positive rate 0.333");
55+
});
56+
57+
it("returns an empty report with a null rate when no gate blocks are recorded (no windowDays)", async () => {
58+
const env = createTestEnv();
59+
const client = await connect(env);
60+
const result = await client.callTool({ name: "gittensory_get_gate_precision", arguments: { owner: "owner", repo: "widgets" } });
61+
expect(result.isError).toBeFalsy();
62+
const data = result.structuredContent as { windowDays: number | null; perGateType: unknown[]; overall: { blocked: number; falsePositiveRate: number | null } };
63+
expect(data.windowDays).toBeNull();
64+
expect(data.perGateType).toEqual([]);
65+
expect(data.overall.blocked).toBe(0);
66+
expect(data.overall.falsePositiveRate).toBeNull();
67+
// Null branch of the summary's ?? fallback.
68+
expect(JSON.stringify(result.content)).toContain("n/a (below sample threshold)");
69+
});
70+
71+
it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => {
72+
const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" });
73+
await seedGateLedger(env);
74+
const client = await connect(env);
75+
const result = await client.callTool({ name: "gittensory_get_gate_precision", arguments: { owner: "owner", repo: "widgets" } });
76+
expect(result.isError).toBeTruthy();
77+
expect(JSON.stringify(result.content)).toMatch(/cannot access this repository/i);
78+
});
79+
});

test/unit/mcp-output-schemas.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
3636
"gittensory_explain_score_breakdown",
3737
"gittensory_get_eligibility_plan",
3838
"gittensory_simulate_open_pr_pressure",
39+
"gittensory_get_gate_precision",
3940
];
4041

4142
async function connectTestClient(env: Env = createTestEnv(), identity?: AuthIdentity) {

0 commit comments

Comments
 (0)