Skip to content

Commit 1257c20

Browse files
feat(mcp): add remote + stdio surfaces for loopover_get_pr_maintainer_packet (#7926)
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 <cursoragent@cursor.com>
1 parent 510d25b commit 1257c20

7 files changed

Lines changed: 237 additions & 6 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,12 @@ const STDIO_TOOL_DESCRIPTORS = [
970970
category: "review",
971971
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.",
972972
},
973+
{
974+
name: "loopover_get_pr_maintainer_packet",
975+
category: "review",
976+
description:
977+
"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.",
978+
},
973979
{
974980
name: "loopover_get_pr_ai_review_findings",
975981
category: "review",
@@ -1513,6 +1519,18 @@ registerStdioTool(
15131519
},
15141520
);
15151521

1522+
registerStdioTool(
1523+
"loopover_get_pr_maintainer_packet",
1524+
{
1525+
description: stdioToolDescription("loopover_get_pr_maintainer_packet"),
1526+
inputSchema: ownerRepoPullShape,
1527+
},
1528+
async ({ owner, repo, number }: any) => {
1529+
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
1530+
return toolResult("LoopOver PR maintainer packet.", await apiGet(`${prefix}/pulls/${number}/maintainer-packet`));
1531+
},
1532+
);
1533+
15161534
// #6619: CLI mirror of the remote server's loopover_get_pr_ai_review_findings. The route is the single source
15171535
// of truth (it delegates to the same loadPrAiReviewFindings the MCP server uses); this tool only resolves the
15181536
// author login and proxies. Self-scoped: the route's requireContributorAccess rejects another login's PR.

src/mcp/server.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import {
140140
buildPreflightResult,
141141
buildPreStartCheck,
142142
buildPrTextLint,
143+
buildPullRequestMaintainerPacket,
143144
buildQueueHealth,
144145
buildRegistryChangeReport,
145146
} from "../signals/engine";
@@ -181,7 +182,7 @@ import { buildProgressSnapshot } from "../loop-progress";
181182
import { evaluateEscalation } from "../loop-escalation";
182183
import { buildStructuralImprovementAssessment } from "../signals/improvement";
183184
import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation";
184-
import { buildRepoDataQuality } from "../signals/data-quality";
185+
import { attachDataQuality, buildRepoDataQuality } from "../signals/data-quality";
185186
import { PREFLIGHT_LIMITS } from "../signals/preflight-limits";
186187
import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model";
187188
import { loadUpstreamStatus } from "../upstream/ruleset";
@@ -1903,6 +1904,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
19031904
loopover_get_upstream_ruleset: "utility",
19041905
loopover_get_issue_quality: "maintainer",
19051906
loopover_get_pr_reviewability: "review",
1907+
loopover_get_pr_maintainer_packet: "review",
19061908
loopover_get_live_gate_thresholds: "maintainer",
19071909
loopover_validate_linked_issue: "discovery",
19081910
loopover_check_before_start: "discovery",
@@ -2455,6 +2457,17 @@ export class LoopoverMcp {
24552457
async (input) => this.toolResult(await this.getPrReviewability(input)),
24562458
);
24572459

2460+
register(
2461+
"loopover_get_pr_maintainer_packet",
2462+
{
2463+
description:
2464+
"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.",
2465+
inputSchema: ownerRepoPullShape,
2466+
outputSchema: freshnessResponseOutputSchema,
2467+
},
2468+
async (input) => this.toolResult(await this.getPrMaintainerPacket(input)),
2469+
);
2470+
24582471
register(
24592472
"loopover_get_live_gate_thresholds",
24602473
{
@@ -3416,6 +3429,48 @@ export class LoopoverMcp {
34163429
};
34173430
}
34183431

3432+
private async getPrMaintainerPacket(input: { owner: string; repo: string; number: number }): Promise<ToolPayload> {
3433+
// Mirrors GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet: same data-assembly path as the REST
3434+
// route (buildPullRequestMaintainerPacket → attachDataQuality), with the reviewability-style mcp allowlist
3435+
// gate so the shared static mcp token stays repo-scoped.
3436+
const fullName = `${input.owner}/${input.repo}`;
3437+
if (!(await this.canAccessRepo(fullName))) {
3438+
return {
3439+
summary: `Forbidden: session cannot access PR maintainer packet for ${fullName}.`,
3440+
data: { status: "forbidden", repoFullName: fullName },
3441+
};
3442+
}
3443+
const [repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([
3444+
getRepository(this.env, fullName),
3445+
getPullRequest(this.env, fullName, input.number),
3446+
listIssues(this.env, fullName),
3447+
listPullRequests(this.env, fullName),
3448+
listPullRequestFiles(this.env, fullName, input.number),
3449+
listPullRequestReviews(this.env, fullName, input.number),
3450+
listCheckSummaries(this.env, fullName, input.number),
3451+
listRecentMergedPullRequests(this.env, fullName),
3452+
]);
3453+
const packet = attachDataQuality(
3454+
buildPullRequestMaintainerPacket({
3455+
repo,
3456+
pullRequest,
3457+
issues,
3458+
pullRequests,
3459+
files,
3460+
reviews,
3461+
checks,
3462+
recentMergedPullRequests,
3463+
repoFullName: fullName,
3464+
pullNumber: input.number,
3465+
}) as unknown as Record<string, unknown>,
3466+
await this.loadRepoDataQuality(fullName),
3467+
);
3468+
return {
3469+
summary: `LoopOver PR maintainer packet for ${fullName}#${input.number}.`,
3470+
data: packet as unknown as Record<string, unknown>,
3471+
};
3472+
}
3473+
34193474
private async getLiveGateThresholds(input: { owner: string; repo: string }): Promise<ToolPayload> {
34203475
// Mirrors GET /v1/repos/:owner/:repo/live-gate-thresholds: same mcp allowlist gate as reviewability,
34213476
// same authoritative live/shadow projection, and a normal not-found result (never throw) when neither

test/integration/api.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5529,6 +5529,7 @@ describe("api routes", () => {
55295529
expect(toolNames).toContain("loopover_get_upstream_drift");
55305530
expect(toolNames).toContain("loopover_get_upstream_ruleset");
55315531
expect(toolNames).toContain("loopover_get_live_gate_thresholds");
5532+
expect(toolNames).toContain("loopover_get_pr_maintainer_packet");
55325533
expect(toolNames).toContain("loopover_explain_review_risk");
55335534
expect(toolNames).toContain("loopover_compare_pr_variants");
55345535
expect(toolNames).toContain("loopover_local_status");
@@ -5804,6 +5805,7 @@ describe("api routes", () => {
58045805
["loopover_get_upstream_drift", {}],
58055806
["loopover_get_upstream_ruleset", {}],
58065807
["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }],
5808+
["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }],
58075809
[
58085810
"loopover_preview_local_pr_score",
58095811
{
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7802: in-process coverage for the loopover_get_pr_maintainer_packet stdio tool.
10+
// Same #7764 entrypoint-guard pattern as mcp-cli-live-gate-thresholds — import .ts, hold exported `server`,
11+
// connect InMemoryTransport so v8/Codecov attributes registerStdioTool (subprocess spawn alone does not).
12+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
13+
14+
type BinModule = {
15+
server: { connect: (transport: unknown) => Promise<void> };
16+
};
17+
18+
let tempDir = "";
19+
const capturedRequests: Array<{ url: string; method: string }> = [];
20+
const loaded = new Map<string, BinModule>();
21+
22+
beforeAll(async () => {
23+
tempDir = mkdtempSync(join(tmpdir(), "loopover-maintainer-packet-"));
24+
const apiUrl = await startFixtureServer({
25+
onApiRequest: (request) => {
26+
if (request.url && request.url.includes("/maintainer-packet")) {
27+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
28+
}
29+
},
30+
});
31+
process.env.LOOPOVER_API_URL = apiUrl;
32+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
33+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
34+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
35+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
36+
for (const specifier of MODULES) {
37+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
38+
}
39+
}, 120_000);
40+
41+
afterAll(async () => {
42+
await closeFixtureServer();
43+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
44+
delete process.env.LOOPOVER_API_URL;
45+
delete process.env.LOOPOVER_API_TOKEN;
46+
delete process.env.LOOPOVER_CONFIG_DIR;
47+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
48+
});
49+
50+
describe("bin loopover_get_pr_maintainer_packet stdio tool (in-process, #7802)", () => {
51+
it.each(MODULES)("registers and proxies GET .../maintainer-packet — %s", async (specifier) => {
52+
capturedRequests.length = 0;
53+
const mod = loaded.get(specifier)!;
54+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
55+
await mod.server.connect(serverTransport);
56+
const client = new Client({ name: "maintainer-packet-test", version: "0.1.0" }, { capabilities: {} });
57+
await client.connect(clientTransport);
58+
try {
59+
const { tools } = await client.listTools();
60+
const tool = tools.find((entry) => entry.name === "loopover_get_pr_maintainer_packet");
61+
expect(tool).toBeDefined();
62+
expect(tool?.description).toMatch(/maintainer packet/i);
63+
64+
const result = await client.callTool({
65+
name: "loopover_get_pr_maintainer_packet",
66+
arguments: { owner: "owner", repo: "repo", number: 7 },
67+
});
68+
expect(capturedRequests.length).toBe(1);
69+
const captured = capturedRequests[0]!;
70+
expect(captured.url).toContain("/v1/repos/owner/repo/pulls/7/maintainer-packet");
71+
expect(captured.method).toBe("GET");
72+
expect(result.isError).toBeFalsy();
73+
const text = JSON.stringify(result);
74+
expect(text).toContain("owner/repo");
75+
expect(text).toContain("maintainer packet");
76+
} finally {
77+
await client.close().catch(() => undefined);
78+
}
79+
});
80+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 { LoopoverMcp } from "../../src/mcp/server";
5+
import { upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories";
6+
import { createTestEnv } from "../helpers/d1";
7+
8+
async function connect(env: Env) {
9+
const server = new LoopoverMcp(env).createServer();
10+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
11+
await server.connect(serverTransport);
12+
const client = new Client({ name: "loopover-maintainer-packet-test", version: "0.1.0" }, { capabilities: {} });
13+
await client.connect(clientTransport);
14+
return client;
15+
}
16+
17+
function prPayload(overrides: Record<string, unknown> = {}) {
18+
return {
19+
number: 7,
20+
title: "Add retry to the upload client",
21+
state: "open",
22+
user: { login: "contributor" },
23+
author_association: "CONTRIBUTOR",
24+
head: { sha: "abc123", ref: "contributor/attempt-1" },
25+
base: { ref: "main" },
26+
html_url: "https://github.com/owner/repo/pull/7",
27+
merged_at: null,
28+
draft: false,
29+
mergeable: true,
30+
body: "Closes #1",
31+
created_at: "2026-07-03T00:00:00Z",
32+
updated_at: "2026-07-03T00:00:00Z",
33+
closed_at: null,
34+
labels: [{ name: "enhancement" }],
35+
...overrides,
36+
};
37+
}
38+
39+
describe("MCP loopover_get_pr_maintainer_packet (#7802)", () => {
40+
it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => {
41+
const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" });
42+
const client = await connect(env);
43+
const result = await client.callTool({ name: "loopover_get_pr_maintainer_packet", arguments: { owner: "owner", repo: "repo", number: 7 } });
44+
expect(result.isError).toBeFalsy();
45+
expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "owner/repo" });
46+
});
47+
48+
it("returns the maintainer packet assembled from cached metadata", async () => {
49+
const env = createTestEnv();
50+
await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" });
51+
await upsertPullRequestFromGitHub(env, "owner/repo", prPayload());
52+
const client = await connect(env);
53+
const result = await client.callTool({ name: "loopover_get_pr_maintainer_packet", arguments: { owner: "owner", repo: "repo", number: 7 } });
54+
expect(result.isError).toBeFalsy();
55+
const data = result.structuredContent as Record<string, unknown>;
56+
expect(data.repoFullName).toBe("owner/repo");
57+
expect(data.pullNumber).toBe(7);
58+
expect(data.dataQuality).toBeDefined();
59+
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
60+
});
61+
});

test/unit/mcp-tool-rename-aliases.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
// (#7803 registered the loopover_get_registry_snapshot remote+stdio tool, taking the count from 82 to 83.)
2828
// (#7807 registered the loopover_get_upstream_ruleset remote+stdio tool, taking the count from 83 to 84.)
2929
// (#7801 registered the loopover_get_live_gate_thresholds remote+stdio tool, taking the count from 84 to 85.)
30+
// (#7802 registered the loopover_get_pr_maintainer_packet remote+stdio tool, taking the count from 85 to 86.)
3031
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3132
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3233
import { mkdtempSync, rmSync } from "node:fs";
@@ -73,14 +74,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7374
});
7475
afterEach(disconnect);
7576

76-
it("lists exactly 85 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
77+
it("lists exactly 86 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
7778
const { tools } = await client.listTools();
7879
const names = tools.map((t) => t.name);
7980
const primary = names.filter((n) => n.startsWith("loopover_"));
8081
const legacy = names.filter((n) => n.startsWith("gittensory_"));
81-
expect(primary.length).toBe(85);
82+
expect(primary.length).toBe(86);
8283
expect(legacy.length).toBe(0);
83-
expect(names.length).toBe(85);
84+
expect(names.length).toBe(86);
8485
});
8586

8687
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -92,14 +93,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
9293
}
9394
});
9495

95-
it("`loopover-mcp tools --json` reports the same 85-tool count the live server registers", async () => {
96+
it("`loopover-mcp tools --json` reports the same 86-tool count the live server registers", async () => {
9697
const { tools } = await client.listTools();
9798
const payload = JSON.parse(run(["tools", "--json"])) as {
9899
count: number;
99100
tools: Array<{ name: string }>;
100101
};
101102
expect(payload.count).toBe(tools.length);
102-
expect(payload.count).toBe(85);
103+
expect(payload.count).toBe(86);
103104
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
104105
[...tools.map((t) => t.name)].sort(),
105106
);

test/unit/support/mcp-cli-harness.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,20 @@ export async function startFixtureServer(
921921
);
922922
return;
923923
}
924+
// #7802: maintainer packet sibling of reviewability.
925+
if (request.url === "/v1/repos/owner/repo/pulls/7/maintainer-packet" && request.method === "GET") {
926+
response.end(
927+
JSON.stringify({
928+
repoFullName: "owner/repo",
929+
pullNumber: 7,
930+
generatedAt: "2026-05-30T00:00:00.000Z",
931+
summary: "PR 7 maintainer packet.",
932+
actions: ["review_now"],
933+
dataQuality: { status: "ok" },
934+
}),
935+
);
936+
return;
937+
}
924938
// #6619: the route carries the author login as a query param, so match on the path prefix.
925939
if (request.url?.startsWith("/v1/repos/owner/repo/pulls/7/ai-review-findings") && request.method === "GET") {
926940
response.end(

0 commit comments

Comments
 (0)