Skip to content

Commit d0f7122

Browse files
committed
feat(mcp): add loopover_file_incident_report write tool
Mirror POST /v1/repos/:owner/:repo/pulls/:number/incident-reports over the MCP surface so a maintainer-authenticated client can file a post-merge incident report on a harmful rented-loop PR, closing the write-side gap next to the already-wrapped maintainer-packet/reviewability read tools. The handler replays the REST route exactly: maintainer-manage auth, the PR-must-exist-and-be-merged validation, then recordPostMergeIncidentReport with reporterKind "customer" and the calling actor, returning the same { ok, repoFullName, pullNumber, ...report } shape. Reuses postMergeIncidentReportSchema's field validation for the body input.
1 parent c2ac612 commit d0f7122

3 files changed

Lines changed: 195 additions & 2 deletions

File tree

src/api/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1099,7 +1099,7 @@ const digestSubscriptionSchema = z
10991099

11001100
const postMergeIncidentSeveritySchema = z.enum(["low", "medium", "high", "critical"]);
11011101

1102-
const postMergeIncidentReportSchema = z
1102+
export const postMergeIncidentReportSchema = z
11031103
.object({
11041104
description: z.string().min(1).max(4000),
11051105
severity: postMergeIncidentSeveritySchema,

src/mcp/server.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
MAX_NOTIFICATION_MARK_READ_IDS,
7979
markNotificationDeliveriesRead,
8080
recordAuditEvent,
81+
recordPostMergeIncidentReport,
8182
recordProductUsageEvent,
8283
} from "../db/repositories";
8384
import { decidePendingAgentAction } from "../services/agent-approval-queue";
@@ -123,7 +124,7 @@ import { buildMaintainerActivationPreview } from "../services/maintainer-activat
123124
import { loadLabelAudit, labelAuditSummary } from "../services/label-audit";
124125
import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane";
125126
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
126-
import { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse } from "../api/routes";
127+
import { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse, postMergeIncidentReportSchema } from "../api/routes";
127128
import { loadGatePrecisionReport } from "../services/gate-precision";
128129
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
129130
import {
@@ -256,6 +257,13 @@ const clearSelftuneOverrideShape = {
256257
confirm: z.literal(true),
257258
};
258259

260+
// (#9298) owner/repo/pull (mirrors ownerRepoPullShape) plus the exact body fields the REST route's
261+
// postMergeIncidentReportSchema validates -- reuse its field-level schemas rather than redefining them loosely.
262+
const fileIncidentReportShape = {
263+
...ownerRepoPullShape,
264+
...postMergeIncidentReportSchema.shape,
265+
};
266+
259267
const windowOnlyShape = {
260268
windowDays: z.number().int().positive().optional(),
261269
};
@@ -1120,6 +1128,17 @@ const clearSelftuneOverrideOutputSchema = {
11201128
cleared: z.boolean().optional(),
11211129
};
11221130

1131+
// (#9298) mirrors the REST incident-report route's response: `{ ok: true, repoFullName, pullNumber, ...report }`
1132+
// on success, or `{ ok: false, error }` when the PR is missing/unmerged (the REST route's 404/409 bodies).
1133+
const fileIncidentReportOutputSchema = {
1134+
ok: z.boolean(),
1135+
repoFullName: z.string(),
1136+
pullNumber: z.number().int().positive(),
1137+
id: z.string().optional(),
1138+
createdAt: z.string().optional(),
1139+
error: z.enum(["pull_request_not_found", "pull_request_not_merged"]).optional(),
1140+
};
1141+
11231142
// #5825 - maintainer-authenticated skipped-PR audit trail, mirroring GET /v1/app/skipped-pr-audit's
11241143
// filters (all optional: a bare call returns the caller's own repo-scoped feed). No owner/repo shape
11251144
// here on purpose: unlike ownerRepoShape tools this report can legitimately span every repo the caller
@@ -2007,6 +2026,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
20072026
loopover_get_gate_precision: "maintainer",
20082027
loopover_get_selftune_override_audit: "maintainer",
20092028
loopover_clear_selftune_override: "maintainer",
2029+
loopover_file_incident_report: "maintainer",
20102030
loopover_get_skipped_pr_audit: "maintainer",
20112031
loopover_get_fleet_analytics: "maintainer",
20122032
loopover_get_recommendation_quality: "maintainer",
@@ -2309,6 +2329,20 @@ export class LoopoverMcp {
23092329
async (input) => this.toolResult(await this.clearSelftuneOverride(input)),
23102330
);
23112331

2332+
// (#9298) MCP mirror of POST /v1/repos/:owner/:repo/pulls/:number/incident-reports (#5672): the missing
2333+
// write tool next to the already-wrapped PR read surfaces (maintainer-packet, reviewability). Same
2334+
// maintainer-manage boundary and recordPostMergeIncidentReport persistence path as the REST route.
2335+
register(
2336+
"loopover_file_incident_report",
2337+
{
2338+
description:
2339+
"File a post-merge incident report on an already-merged rented-loop PR later found harmful, mirroring POST /v1/repos/:owner/:repo/pulls/:number/incident-reports. Persists an audit_events row keyed to the PR; the PR must exist and be merged. Maintainer access required.",
2340+
inputSchema: fileIncidentReportShape,
2341+
outputSchema: fileIncidentReportOutputSchema,
2342+
},
2343+
async (input) => this.toolResult(await this.fileIncidentReport(input)),
2344+
);
2345+
23122346
register(
23132347
"loopover_get_skipped_pr_audit",
23142348
{
@@ -4270,6 +4304,43 @@ export class LoopoverMcp {
42704304
};
42714305
}
42724306

4307+
// (#9298) Mirrors POST /v1/repos/:owner/:repo/pulls/:number/incident-reports (#5672): maintainer-manage
4308+
// gate, then the REST route's exact PR-must-exist-and-be-merged validation, then the same
4309+
// recordPostMergeIncidentReport persistence (reporterKind "customer", the calling actor) and response shape.
4310+
// Missing/unmerged PRs return the route's 404/409 error codes as a normal `{ ok: false, error }` tool result.
4311+
private async fileIncidentReport(input: z.infer<z.ZodObject<typeof fileIncidentReportShape>>): Promise<ToolPayload> {
4312+
const fullName = `${input.owner}/${input.repo}`;
4313+
await this.requireRepoManageAccess(fullName);
4314+
const pullRequest = await getPullRequest(this.env, fullName, input.number);
4315+
if (!pullRequest) {
4316+
return {
4317+
summary: `No pull request ${fullName}#${input.number} to file a post-merge incident report against.`,
4318+
data: { ok: false, error: "pull_request_not_found", repoFullName: fullName, pullNumber: input.number },
4319+
};
4320+
}
4321+
if (!pullRequest.mergedAt) {
4322+
return {
4323+
summary: `Pull request ${fullName}#${input.number} is not merged; a post-merge incident report cannot be filed.`,
4324+
data: { ok: false, error: "pull_request_not_merged", repoFullName: fullName, pullNumber: input.number },
4325+
};
4326+
}
4327+
const actor = this.identity.kind === "session" ? this.identity.actor : "mcp";
4328+
const report = await recordPostMergeIncidentReport(this.env, {
4329+
repoFullName: fullName,
4330+
pullNumber: input.number,
4331+
description: input.description,
4332+
severity: input.severity,
4333+
mergedSha: input.mergedSha,
4334+
reporterKind: "customer",
4335+
actor,
4336+
route: `/v1/repos/${input.owner}/${input.repo}/pulls/${input.number}/incident-reports`,
4337+
});
4338+
return {
4339+
summary: `Filed a post-merge incident report on ${fullName}#${input.number} (severity ${input.severity}).`,
4340+
data: { ok: true, repoFullName: fullName, pullNumber: input.number, ...report },
4341+
};
4342+
}
4343+
42734344
// #5825 - repo-scope resolution for the skipped-PR audit tool. Mirrors skippedPrAuditRepoScope in
42744345
// src/api/routes.ts (same underlying loadControlPanelRoleSummary/loadControlPanelAccessScope calls,
42754346
// same maintainer/owner/operator role gate, same "no filter -> caller's own scoped repos" fallback),
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { LoopoverMcp } from "../../src/mcp/server";
5+
import { getRepositoryCollaboratorPermission } from "../../src/github/app";
6+
import { listAuditEventsForTarget, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories";
7+
import type { AuthIdentity } from "../../src/auth/security";
8+
import { createTestEnv } from "../helpers/d1";
9+
10+
// #9298: MCP mirror of POST /v1/repos/:owner/:repo/pulls/:number/incident-reports (#5672). The write itself
11+
// persists through the same recordPostMergeIncidentReport helper into a PR-keyed `audit_events` row, read
12+
// back here through listAuditEventsForTarget -- the exact `repo#number` target the REST route documents (the
13+
// agent-audit-feed tool is deliberately scoped to `agent.action.%`/`agent.pending_action.%`, not this event).
14+
15+
vi.mock("../../src/github/app", async (importOriginal) => ({
16+
...(await importOriginal<typeof import("../../src/github/app")>()),
17+
getRepositoryCollaboratorPermission: vi.fn(),
18+
}));
19+
const mockedPermission = vi.mocked(getRepositoryCollaboratorPermission);
20+
21+
beforeEach(() => {
22+
mockedPermission.mockReset();
23+
mockedPermission.mockResolvedValue("write");
24+
});
25+
26+
async function connect(env: Env, identity?: AuthIdentity) {
27+
const server = new LoopoverMcp(env, identity).createServer();
28+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
29+
await server.connect(serverTransport);
30+
const client = new Client({ name: "loopover-file-incident-report-test", version: "0.1.0" }, { capabilities: {} });
31+
await client.connect(clientTransport);
32+
return client;
33+
}
34+
35+
async function seedRepoWithPulls(env: Env) {
36+
await upsertInstallation(env, {
37+
installation: { id: 5, account: { login: "owner", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", contents: "write", pull_requests: "write", issues: "write" }, events: ["pull_request"] },
38+
});
39+
await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5);
40+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "Merged PR", state: "closed", merged_at: "2026-06-18T10:00:00.000Z", user: { login: "a-miner" }, head: { sha: "deadbeef" }, labels: [], body: "x" });
41+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 8, title: "Open PR", state: "open", user: { login: "a-miner" }, head: { sha: "open-sha" }, labels: [], body: "x" });
42+
}
43+
44+
async function metadataRow(env: Env): Promise<{ target_key: string; actor: string; detail: string; metadata_json: string } | null> {
45+
return env.DB.prepare(
46+
"select target_key, actor, detail, metadata_json from audit_events where event_type = 'agent.post_merge_incident_reported' order by created_at desc limit 1",
47+
).first<{ target_key: string; actor: string; detail: string; metadata_json: string }>();
48+
}
49+
50+
describe("MCP loopover_file_incident_report (#9298)", () => {
51+
it("files a report on a merged PR for the shared mcp token, and it reads back on the PR's audit target", async () => {
52+
const env = createTestEnv();
53+
await seedRepoWithPulls(env);
54+
const client = await connect(env); // default identity: { kind: "static", actor: "mcp" }
55+
56+
const result = await client.callTool({ name: "loopover_file_incident_report", arguments: { owner: "owner", repo: "repo", number: 7, description: "broke prod config", severity: "high", mergedSha: "deadbeef" } });
57+
expect(result.isError).toBeFalsy();
58+
const data = result.structuredContent as { ok: boolean; repoFullName: string; pullNumber: number; id: string; createdAt: string };
59+
expect(data).toMatchObject({ ok: true, repoFullName: "owner/repo", pullNumber: 7 });
60+
expect(typeof data.id).toBe("string");
61+
expect(typeof data.createdAt).toBe("string");
62+
expect(JSON.stringify(result.content)).toContain("Filed a post-merge incident report on owner/repo#7");
63+
64+
// Regression: the recorded incident is one `audit_events` row keyed to the PR (`repo#number`), readable
65+
// back through the same listAuditEventsForTarget path recordPostMergeIncidentReport documents.
66+
const events = await listAuditEventsForTarget(env, { repoFullName: "owner/repo", pullNumber: 7 });
67+
expect(events).toHaveLength(1);
68+
expect(events[0]).toMatchObject({ eventType: "agent.post_merge_incident_reported", outcome: "completed", actor: "mcp", detail: "broke prod config" });
69+
const row = await metadataRow(env);
70+
expect(row?.target_key).toBe("owner/repo#7");
71+
expect(JSON.parse(row!.metadata_json)).toMatchObject({ severity: "high", mergedSha: "deadbeef", reporterKind: "customer" });
72+
});
73+
74+
it("records the reporting maintainer's own login as actor for a session caller, and omits mergedSha as null", async () => {
75+
const env = createTestEnv();
76+
await seedRepoWithPulls(env);
77+
const client = await connect(env, { kind: "session", actor: "owner" } as AuthIdentity);
78+
79+
const result = await client.callTool({ name: "loopover_file_incident_report", arguments: { owner: "owner", repo: "repo", number: 7, description: "silent data loss", severity: "critical" } });
80+
expect(result.isError).toBeFalsy();
81+
expect(result.structuredContent).toMatchObject({ ok: true, repoFullName: "owner/repo", pullNumber: 7 });
82+
83+
const events = await listAuditEventsForTarget(env, { repoFullName: "owner/repo", pullNumber: 7 });
84+
expect(events).toHaveLength(1);
85+
expect(events[0]?.actor).toBe("owner");
86+
const row = await metadataRow(env);
87+
expect(JSON.parse(row!.metadata_json)).toMatchObject({ severity: "critical", mergedSha: null, reporterKind: "customer" });
88+
});
89+
90+
it("returns pull_request_not_found for an unknown PR without recording anything", async () => {
91+
const env = createTestEnv();
92+
await seedRepoWithPulls(env);
93+
const client = await connect(env);
94+
95+
const result = await client.callTool({ name: "loopover_file_incident_report", arguments: { owner: "owner", repo: "repo", number: 999, description: "x", severity: "low" } });
96+
expect(result.isError).toBeFalsy(); // a business rejection is a normal tool result, not an MCP-level error
97+
expect(result.structuredContent).toMatchObject({ ok: false, error: "pull_request_not_found", repoFullName: "owner/repo", pullNumber: 999 });
98+
expect(await metadataRow(env)).toBeFalsy();
99+
});
100+
101+
it("returns pull_request_not_merged for an open PR without recording anything", async () => {
102+
const env = createTestEnv();
103+
await seedRepoWithPulls(env);
104+
const client = await connect(env);
105+
106+
const result = await client.callTool({ name: "loopover_file_incident_report", arguments: { owner: "owner", repo: "repo", number: 8, description: "x", severity: "low" } });
107+
expect(result.isError).toBeFalsy();
108+
expect(result.structuredContent).toMatchObject({ ok: false, error: "pull_request_not_merged", repoFullName: "owner/repo", pullNumber: 8 });
109+
expect(await metadataRow(env)).toBeFalsy();
110+
});
111+
112+
it("rejects a caller lacking maintainer-manage access, recording nothing", async () => {
113+
const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" });
114+
await seedRepoWithPulls(env);
115+
const client = await connect(env); // default static mcp identity, no actuation allowlist
116+
117+
const result = await client.callTool({ name: "loopover_file_incident_report", arguments: { owner: "owner", repo: "repo", number: 7, description: "x", severity: "low" } });
118+
expect(result.isError).toBe(true);
119+
expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/);
120+
expect(await metadataRow(env)).toBeFalsy();
121+
});
122+
});

0 commit comments

Comments
 (0)