From 4065baf7148371519abe2ea64244a2506daf9901 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 22 Jul 2026 02:34:32 +0800 Subject: [PATCH] feat(mcp): add remote + stdio surfaces for loopover_get_pr_maintainer_packet Mirror loopover_get_pr_reviewability's owner/repo/number shape and mcp allowlist gate, assembling the same buildPullRequestMaintainerPacket + attachDataQuality path as the REST route. Cover forbidden/found remotely and instrument the bin via the #7764 in-process exported-server pattern for codecov/patch. Closes #7802 Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.ts | 18 +++++ src/mcp/server.ts | 57 ++++++++++++- test/integration/api.test.ts | 2 + .../unit/mcp-cli-pr-maintainer-packet.test.ts | 80 +++++++++++++++++++ test/unit/mcp-pr-maintainer-packet.test.ts | 61 ++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 11 +-- test/unit/support/mcp-cli-harness.ts | 14 ++++ 7 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 test/unit/mcp-cli-pr-maintainer-packet.test.ts create mode 100644 test/unit/mcp-pr-maintainer-packet.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index e4b917072..886c9e0f4 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -970,6 +970,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Return the reviewability report for an open PR: how ready it is to review/merge, the blocking or advisory signals against it, and its lane/duplicate/linked-issue context. Metadata-only, no GitHub writes.", }, + { + name: "loopover_get_pr_maintainer_packet", + category: "review", + description: + "Return the full maintainer packet for an open PR: triage context assembled from cached repo/PR/issue/review/check metadata, wrapped with data-quality. Metadata-only; takes owner, repo, and pull number.", + }, { name: "loopover_get_pr_ai_review_findings", category: "review", @@ -1513,6 +1519,18 @@ registerStdioTool( }, ); +registerStdioTool( + "loopover_get_pr_maintainer_packet", + { + description: stdioToolDescription("loopover_get_pr_maintainer_packet"), + inputSchema: ownerRepoPullShape, + }, + async ({ owner, repo, number }: any) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver PR maintainer packet.", await apiGet(`${prefix}/pulls/${number}/maintainer-packet`)); + }, +); + // #6619: CLI mirror of the remote server's loopover_get_pr_ai_review_findings. The route is the single source // of truth (it delegates to the same loadPrAiReviewFindings the MCP server uses); this tool only resolves the // author login and proxies. Self-scoped: the route's requireContributorAccess rejects another login's PR. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e73384aad..7b98b4377 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -140,6 +140,7 @@ import { buildPreflightResult, buildPreStartCheck, buildPrTextLint, + buildPullRequestMaintainerPacket, buildQueueHealth, buildRegistryChangeReport, } from "../signals/engine"; @@ -181,7 +182,7 @@ import { buildProgressSnapshot } from "../loop-progress"; import { evaluateEscalation } from "../loop-escalation"; import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; -import { buildRepoDataQuality } from "../signals/data-quality"; +import { attachDataQuality, buildRepoDataQuality } from "../signals/data-quality"; import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { loadUpstreamStatus } from "../upstream/ruleset"; @@ -1903,6 +1904,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_upstream_ruleset: "utility", loopover_get_issue_quality: "maintainer", loopover_get_pr_reviewability: "review", + loopover_get_pr_maintainer_packet: "review", loopover_get_live_gate_thresholds: "maintainer", loopover_validate_linked_issue: "discovery", loopover_check_before_start: "discovery", @@ -2455,6 +2457,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getPrReviewability(input)), ); + register( + "loopover_get_pr_maintainer_packet", + { + description: + "Return the full maintainer packet for an open PR: triage context assembled from cached repo/PR/issue/review/check metadata, wrapped with data-quality. Metadata-only, repo-scoped, no GitHub writes.", + inputSchema: ownerRepoPullShape, + outputSchema: freshnessResponseOutputSchema, + }, + async (input) => this.toolResult(await this.getPrMaintainerPacket(input)), + ); + register( "loopover_get_live_gate_thresholds", { @@ -3416,6 +3429,48 @@ export class LoopoverMcp { }; } + private async getPrMaintainerPacket(input: { owner: string; repo: string; number: number }): Promise { + // Mirrors GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet: same data-assembly path as the REST + // route (buildPullRequestMaintainerPacket → attachDataQuality), with the reviewability-style mcp allowlist + // gate so the shared static mcp token stays repo-scoped. + const fullName = `${input.owner}/${input.repo}`; + if (!(await this.canAccessRepo(fullName))) { + return { + summary: `Forbidden: session cannot access PR maintainer packet for ${fullName}.`, + data: { status: "forbidden", repoFullName: fullName }, + }; + } + const [repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([ + getRepository(this.env, fullName), + getPullRequest(this.env, fullName, input.number), + listIssues(this.env, fullName), + listPullRequests(this.env, fullName), + listPullRequestFiles(this.env, fullName, input.number), + listPullRequestReviews(this.env, fullName, input.number), + listCheckSummaries(this.env, fullName, input.number), + listRecentMergedPullRequests(this.env, fullName), + ]); + const packet = attachDataQuality( + buildPullRequestMaintainerPacket({ + repo, + pullRequest, + issues, + pullRequests, + files, + reviews, + checks, + recentMergedPullRequests, + repoFullName: fullName, + pullNumber: input.number, + }) as unknown as Record, + await this.loadRepoDataQuality(fullName), + ); + return { + summary: `LoopOver PR maintainer packet for ${fullName}#${input.number}.`, + data: packet as unknown as Record, + }; + } + private async getLiveGateThresholds(input: { owner: string; repo: string }): Promise { // Mirrors GET /v1/repos/:owner/:repo/live-gate-thresholds: same mcp allowlist gate as reviewability, // same authoritative live/shadow projection, and a normal not-found result (never throw) when neither diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index e35711f2e..7a1ad31da 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5529,6 +5529,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_get_upstream_drift"); expect(toolNames).toContain("loopover_get_upstream_ruleset"); expect(toolNames).toContain("loopover_get_live_gate_thresholds"); + expect(toolNames).toContain("loopover_get_pr_maintainer_packet"); expect(toolNames).toContain("loopover_explain_review_risk"); expect(toolNames).toContain("loopover_compare_pr_variants"); expect(toolNames).toContain("loopover_local_status"); @@ -5804,6 +5805,7 @@ describe("api routes", () => { ["loopover_get_upstream_drift", {}], ["loopover_get_upstream_ruleset", {}], ["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }], + ["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }], [ "loopover_preview_local_pr_score", { diff --git a/test/unit/mcp-cli-pr-maintainer-packet.test.ts b/test/unit/mcp-cli-pr-maintainer-packet.test.ts new file mode 100644 index 000000000..423d06f14 --- /dev/null +++ b/test/unit/mcp-cli-pr-maintainer-packet.test.ts @@ -0,0 +1,80 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7802: in-process coverage for the loopover_get_pr_maintainer_packet stdio tool. +// Same #7764 entrypoint-guard pattern as mcp-cli-live-gate-thresholds — import .ts, hold exported `server`, +// connect InMemoryTransport so v8/Codecov attributes registerStdioTool (subprocess spawn alone does not). +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-maintainer-packet-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/maintainer-packet")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + for (const specifier of MODULES) { + loaded.set(specifier, (await import(specifier)) as unknown as BinModule); + } +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_API_URL; + delete process.env.LOOPOVER_API_TOKEN; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +describe("bin loopover_get_pr_maintainer_packet stdio tool (in-process, #7802)", () => { + it.each(MODULES)("registers and proxies GET .../maintainer-packet — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "maintainer-packet-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_pr_maintainer_packet"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/maintainer packet/i); + + const result = await client.callTool({ + name: "loopover_get_pr_maintainer_packet", + arguments: { owner: "owner", repo: "repo", number: 7 }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/pulls/7/maintainer-packet"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).toContain("owner/repo"); + expect(text).toContain("maintainer packet"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-pr-maintainer-packet.test.ts b/test/unit/mcp-pr-maintainer-packet.test.ts new file mode 100644 index 000000000..4b6e25390 --- /dev/null +++ b/test/unit/mcp-pr-maintainer-packet.test.ts @@ -0,0 +1,61 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env) { + const server = new LoopoverMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "loopover-maintainer-packet-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +function prPayload(overrides: Record = {}) { + return { + number: 7, + title: "Add retry to the upload client", + state: "open", + user: { login: "contributor" }, + author_association: "CONTRIBUTOR", + head: { sha: "abc123", ref: "contributor/attempt-1" }, + base: { ref: "main" }, + html_url: "https://github.com/owner/repo/pull/7", + merged_at: null, + draft: false, + mergeable: true, + body: "Closes #1", + created_at: "2026-07-03T00:00:00Z", + updated_at: "2026-07-03T00:00:00Z", + closed_at: null, + labels: [{ name: "enhancement" }], + ...overrides, + }; +} + +describe("MCP loopover_get_pr_maintainer_packet (#7802)", () => { + it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_pr_maintainer_packet", arguments: { owner: "owner", repo: "repo", number: 7 } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "owner/repo" }); + }); + + it("returns the maintainer packet assembled from cached metadata", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" }); + await upsertPullRequestFromGitHub(env, "owner/repo", prPayload()); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_pr_maintainer_packet", arguments: { owner: "owner", repo: "repo", number: 7 } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("owner/repo"); + expect(data.pullNumber).toBe(7); + expect(data.dataQuality).toBeDefined(); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 5b009c732..45e2d3e4d 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -27,6 +27,7 @@ // (#7803 registered the loopover_get_registry_snapshot remote+stdio tool, taking the count from 82 to 83.) // (#7807 registered the loopover_get_upstream_ruleset remote+stdio tool, taking the count from 83 to 84.) // (#7801 registered the loopover_get_live_gate_thresholds remote+stdio tool, taking the count from 84 to 85.) +// (#7802 registered the loopover_get_pr_maintainer_packet remote+stdio tool, taking the count from 85 to 86.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -73,14 +74,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 85 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 86 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(85); + expect(primary.length).toBe(86); expect(legacy.length).toBe(0); - expect(names.length).toBe(85); + expect(names.length).toBe(86); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -92,14 +93,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 85-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 86-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }>; }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(85); + expect(payload.count).toBe(86); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 9a360fa6b..c1fdd6bfa 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -921,6 +921,20 @@ export async function startFixtureServer( ); return; } + // #7802: maintainer packet sibling of reviewability. + if (request.url === "/v1/repos/owner/repo/pulls/7/maintainer-packet" && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + pullNumber: 7, + generatedAt: "2026-05-30T00:00:00.000Z", + summary: "PR 7 maintainer packet.", + actions: ["review_now"], + dataQuality: { status: "ok" }, + }), + ); + return; + } // #6619: the route carries the author login as a query param, so match on the path prefix. if (request.url?.startsWith("/v1/repos/owner/repo/pulls/7/ai-review-findings") && request.method === "GET") { response.end(