|
| 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