Skip to content

Commit 544cb31

Browse files
feat(mcp): mirror loopover_monitor_open_prs to the stdio wrapper and CLI (#6789)
The MCP tool loopover_monitor_open_prs and GET /v1/contributors/:login/open-pr-monitor already served this, but the stdio wrapper registered no matching tool and the CLI had no command, so a contributor could not review their own open PRs from the terminal. - register loopover_monitor_open_prs in loopover-mcp.js, proxying the existing route (category: discovery, alongside loopover_get_decision_pack), taking the tool count 63 -> 64 - add `loopover-mcp monitor-open-prs --login <login> [--json]`, resolving the login from --login / LOOPOVER_LOGIN / GITHUB_LOGIN exactly as decisionPackCli does - reuse the API's own summary so the CLI and the MCP tool never drift into two sentences - sanitize API-chosen text on the plain-text path per #6261 (the API composes the summary and guidance and echoes third-party PR titles); --json stays raw, as JSON.stringify escapes U+001B and callers parse that contract No new dependency, no route change, no source upload. Co-authored-by: reyanthony062001-ops <reyanthony062001-ops@users.noreply.github.com>
1 parent 8cd0b24 commit 544cb31

6 files changed

Lines changed: 239 additions & 7 deletions

File tree

packages/loopover-mcp/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ loopover-mcp completion fish
6161
loopover-mcp completion powershell
6262
loopover-mcp decision-pack --login jsonbored --json
6363
loopover-mcp repo-decision --login jsonbored --repo we-promise/sure --json
64+
loopover-mcp monitor-open-prs --login jsonbored --json
6465
loopover-mcp analyze-branch --login jsonbored --json
6566
loopover-mcp preflight --login jsonbored --json
6667
loopover-mcp review-pr --login jsonbored --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const CLI_COMMAND_SPEC = {
7777
"init-client": [],
7878
"decision-pack": [],
7979
"repo-decision": [],
80+
"monitor-open-prs": [],
8081
"analyze-branch": [],
8182
preflight: [],
8283
"review-pr": [],
@@ -924,6 +925,12 @@ const STDIO_TOOL_DESCRIPTORS = [
924925
category: "discovery",
925926
description: "Return the go/raise/avoid decision for one specific contributor-and-repo pair, drawn from that contributor's decision pack — narrower than loopover_get_decision_pack, which returns the whole pack. Takes login (GitHub username), owner, and repo.",
926927
},
928+
{
929+
name: "loopover_monitor_open_prs",
930+
category: "discovery",
931+
description:
932+
"Inspect a contributor's open PRs on registered repos, classify queue state, and return public-safe next-step packets from cached metadata.",
933+
},
927934
{
928935
name: "loopover_compare_pr_variants",
929936
category: "branch",
@@ -1655,6 +1662,18 @@ registerStdioTool(
16551662
},
16561663
);
16571664

1665+
registerStdioTool(
1666+
"loopover_monitor_open_prs",
1667+
{
1668+
description: stdioToolDescription("loopover_monitor_open_prs"),
1669+
inputSchema: loginShape,
1670+
},
1671+
async ({ login }) => {
1672+
const payload = await getOpenPrMonitor(login);
1673+
return toolResult(openPrMonitorToolSummary(login, payload), payload);
1674+
},
1675+
);
1676+
16581677
registerStdioTool(
16591678
"loopover_compare_pr_variants",
16601679
{
@@ -2826,6 +2845,7 @@ async function runCli(args) {
28262845
if (command === "issue-slop") return issueSlopCli(args.slice(1));
28272846
if (command === "decision-pack") return decisionPackCli(options);
28282847
if (command === "repo-decision") return repoDecisionCli(options);
2848+
if (command === "monitor-open-prs") return monitorOpenPrsCli(options);
28292849
if (command === "review-pr") return reviewPrCli(options);
28302850
if (command !== "analyze-branch" && command !== "preflight") {
28312851
const suggestion = suggestCommand(command);
@@ -3189,6 +3209,40 @@ async function decisionPackCli(options) {
31893209
if (payload.cache?.rerunGuidance) process.stdout.write(`Rerun when: ${sanitizePlainTextTerminalOutput(payload.cache.rerunGuidance)}\n`);
31903210
}
31913211

3212+
function printMonitorOpenPrsHelp() {
3213+
process.stdout.write(
3214+
[
3215+
"Usage: loopover-mcp monitor-open-prs --login <github-login> [--json]",
3216+
"",
3217+
"Review your open PRs across registered repos: queue classification and next steps per PR.",
3218+
"Mirrors the loopover_monitor_open_prs MCP tool and GET /v1/contributors/{login}/open-pr-monitor. No source upload.",
3219+
"",
3220+
"Pass --json for machine-readable output.",
3221+
].join("\n") + "\n",
3222+
);
3223+
}
3224+
3225+
async function monitorOpenPrsCli(options) {
3226+
if (options.help === true) return printMonitorOpenPrsHelp();
3227+
const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
3228+
if (!login) throw new Error("Pass --login <github-login> or set LOOPOVER_LOGIN.");
3229+
const payload = await getOpenPrMonitor(login);
3230+
if (options.json) {
3231+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
3232+
return;
3233+
}
3234+
// #6261: every value below is the API's to choose -- the summary/guidance it composes, and PR titles it echoes
3235+
// back from third-party repos -- so all of it is sanitized before it reaches the terminal. `login` is the user's
3236+
// own --login/env value and needs no sanitizing, but it only reaches stdout via the literal fallback branch.
3237+
process.stdout.write(`${sanitizePlainTextTerminalOutput(openPrMonitorToolSummary(login, payload))}\n`);
3238+
for (const line of payload?.guidance ?? []) process.stdout.write(`${sanitizePlainTextTerminalOutput(line)}\n`);
3239+
for (const pr of payload?.pullRequests ?? []) {
3240+
const heading = `${pr.repoFullName}#${pr.number} [${pr.classification}] ${pr.title}`;
3241+
process.stdout.write(`${sanitizePlainTextTerminalOutput(heading)}\n`);
3242+
for (const step of pr.nextSteps ?? []) process.stdout.write(` - ${sanitizePlainTextTerminalOutput(step)}\n`);
3243+
}
3244+
}
3245+
31923246
function printRepoDecisionHelp() {
31933247
process.stdout.write(
31943248
[
@@ -3667,6 +3721,7 @@ function printHelp() {
36673721
loopover-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json]
36683722
loopover-mcp decision-pack --login <github-login> [--json]
36693723
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
3724+
loopover-mcp monitor-open-prs --login <github-login> [--json]
36703725
loopover-mcp analyze-branch --login <github-login> [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--format table] [--json]
36713726
loopover-mcp preflight --login <github-login> [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--format table] [--json]
36723727
loopover-mcp review-pr --login <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
@@ -3684,7 +3739,7 @@ function printHelp() {
36843739
LOOPOVER_PROFILE
36853740
LOOPOVER_CONFIG_PATH or LOOPOVER_CONFIG_DIR
36863741
LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_TOKEN, or a session from loopover-mcp login
3687-
LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, and agent plan/packet)
3742+
LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, and agent plan/packet)
36883743
GITHUB_TOKEN for non-interactive login bootstrap
36893744
GITTENSOR_SCORE_PREVIEW_CMD
36903745
GITTENSOR_ROOT
@@ -4795,6 +4850,18 @@ function repoDecisionToolSummary(login, repoFullName, payload) {
47954850
return `LoopOver repo decision for ${login} in ${repoFullName}.`;
47964851
}
47974852

4853+
function getOpenPrMonitor(login) {
4854+
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/open-pr-monitor`);
4855+
}
4856+
4857+
// Mirror the API's own `summary` when it sends one, so the CLI and the loopover_monitor_open_prs MCP
4858+
// tool (which returns monitor.summary verbatim) never drift into two different sentences for one payload.
4859+
function openPrMonitorToolSummary(login, payload) {
4860+
const summary = typeof payload?.summary === "string" ? payload.summary.trim() : "";
4861+
if (summary) return summary;
4862+
return `LoopOver open-PR monitor for ${login}.`;
4863+
}
4864+
47984865
function isCacheableDecisionPack(payload, login) {
47994866
return payload?.status === "ready" && typeof payload.login === "string" && payload.login.toLowerCase() === login.toLowerCase();
48004867
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// #6732: the CLI mirror for loopover_monitor_open_prs. The MCP tool and GET
2+
// /v1/contributors/:login/open-pr-monitor already served this; only the stdio/CLI surface was missing.
3+
// These pin the three things that can silently rot: the tool is registered, both surfaces hit the same
4+
// route, and `monitor-open-prs --json` stays byte-identical to what the tool returns for one input.
5+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7+
import { mkdtempSync, rmSync } from "node:fs";
8+
import { tmpdir } from "node:os";
9+
import { join } from "node:path";
10+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
11+
// Any CLI command that calls the API must go through runAsync: the fixture server lives in this process,
12+
// so run()'s execFileSync would block the event loop and the child's fetch would abort before a response.
13+
import { closeFixtureServer, openPrMonitorFixture, run, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness";
14+
15+
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
16+
17+
let client: Client;
18+
let transport: StdioClientTransport;
19+
let configDir: string;
20+
let apiUrl: string;
21+
let capturedRequests: Array<{ url: string; method: string }>;
22+
23+
async function connect() {
24+
configDir = mkdtempSync(join(tmpdir(), "loopover-monitor-open-prs-"));
25+
capturedRequests = [];
26+
apiUrl = await startFixtureServer({
27+
onApiRequest: (request) => {
28+
if (request.url && request.url.includes("/open-pr-monitor")) {
29+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
30+
}
31+
},
32+
});
33+
transport = new StdioClientTransport({
34+
command: "node",
35+
args: [bin, "--stdio"],
36+
env: {
37+
...process.env,
38+
LOOPOVER_CONFIG_DIR: configDir,
39+
LOOPOVER_API_URL: apiUrl,
40+
LOOPOVER_TOKEN: "session-token",
41+
LOOPOVER_API_TIMEOUT_MS: "5000",
42+
},
43+
});
44+
client = new Client({ name: "monitor-open-prs-test", version: "0.0.1" });
45+
await client.connect(transport);
46+
}
47+
48+
async function disconnect() {
49+
await client.close().catch(() => undefined);
50+
await closeFixtureServer();
51+
if (configDir) rmSync(configDir, { recursive: true, force: true });
52+
}
53+
54+
describe("loopover_monitor_open_prs stdio proxy", () => {
55+
beforeEach(connect);
56+
afterEach(disconnect);
57+
58+
it("registers the tool in the stdio server tool list", async () => {
59+
const { tools } = await client.listTools();
60+
expect(tools.map((t) => t.name)).toContain("loopover_monitor_open_prs");
61+
});
62+
63+
it("proxies login to /v1/contributors/:login/open-pr-monitor via apiGet and returns the monitor", async () => {
64+
const result = await client.callTool({ name: "loopover_monitor_open_prs", arguments: { login: "JSONbored" } });
65+
expect(capturedRequests.length).toBe(1);
66+
const captured = capturedRequests[0]!;
67+
expect(captured.url).toContain("/v1/contributors/JSONbored/open-pr-monitor");
68+
expect(captured.method).toBe("GET");
69+
expect(result.isError).toBeFalsy();
70+
const text = JSON.stringify(result);
71+
expect(text).toContain("JSONbored/gittensory");
72+
expect(text).toContain("failing_checks");
73+
// The tool summary is the API's own sentence, not a second one invented client-side.
74+
expect(text).toContain(openPrMonitorFixture().summary);
75+
});
76+
});
77+
78+
describe("loopover-mcp monitor-open-prs CLI", () => {
79+
beforeEach(connect);
80+
afterEach(disconnect);
81+
82+
it("--json emits exactly the payload the MCP tool surfaces for the same login (mirror parity)", async () => {
83+
const viaTool = await client.callTool({ name: "loopover_monitor_open_prs", arguments: { login: "JSONbored" } });
84+
const toolData = (viaTool as { structuredContent?: unknown }).structuredContent;
85+
const viaCli = JSON.parse(await runAsync(["monitor-open-prs", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }));
86+
expect(viaCli).toEqual(openPrMonitorFixture());
87+
if (toolData !== undefined) expect(viaCli).toEqual(toolData);
88+
});
89+
90+
it("prints the API summary, guidance, and a next-step line per open PR", async () => {
91+
const out = await runAsync(["monitor-open-prs", "--login", "JSONbored"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" });
92+
const fixture = openPrMonitorFixture();
93+
expect(out).toContain(fixture.summary);
94+
expect(out).toContain(fixture.guidance[0]!);
95+
expect(out).toContain("JSONbored/gittensory#42 [failing_checks] fix(queue): drain stale entries");
96+
expect(out).toContain(" - Fix the failing check, then push.");
97+
});
98+
99+
it("resolves the login from LOOPOVER_LOGIN, then GITHUB_LOGIN, the way decision-pack does", async () => {
100+
const viaLoopoverLogin = await runAsync(["monitor-open-prs", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "JSONbored" });
101+
expect(JSON.parse(viaLoopoverLogin)).toEqual(openPrMonitorFixture());
102+
const viaGithubLogin = await runAsync(["monitor-open-prs", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", GITHUB_LOGIN: "JSONbored" });
103+
expect(JSON.parse(viaGithubLogin)).toEqual(openPrMonitorFixture());
104+
});
105+
106+
it("fails with the shared login-required message when no login is resolvable", () => {
107+
const failure = runExpectingFailure(["monitor-open-prs"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" });
108+
expect(failure.status).toBe(1);
109+
expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login <github-login> or set LOOPOVER_LOGIN\./);
110+
});
111+
112+
// #6261: the API composes the summary/guidance and echoes PR titles back from third-party repos, so a hostile
113+
// string must not be able to repaint the terminal. --json stays raw on purpose: JSON.stringify escapes U+001B.
114+
it("strips ANSI escapes from API-chosen text on the plain-text path but not from --json", async () => {
115+
await closeFixtureServer();
116+
const hostileUrl = await startFixtureServer({ openPrMonitor: { summary: "FAKE PASS", guidance: ["rewritten"] } });
117+
const env = { LOOPOVER_API_URL: hostileUrl, LOOPOVER_TOKEN: "session-token" };
118+
119+
const plain = await runAsync(["monitor-open-prs", "--login", "JSONbored"], env);
120+
expect(plain).not.toContain("");
121+
expect(plain).toContain("FAKE PASS");
122+
expect(plain).toContain("rewritten");
123+
124+
const asJson = await runAsync(["monitor-open-prs", "--login", "JSONbored", "--json"], env);
125+
expect(JSON.parse(asJson).summary).toBe("FAKE PASS");
126+
});
127+
128+
it("documents itself in --help and in the shell-completion command list", () => {
129+
expect(run(["--help"])).toContain("loopover-mcp monitor-open-prs --login <github-login> [--json]");
130+
expect(run(["monitor-open-prs", "--help"])).toContain("Mirrors the loopover_monitor_open_prs MCP tool");
131+
expect(run(["completion", "bash"])).toContain("monitor-open-prs");
132+
});
133+
});

test/unit/mcp-cli-tools.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe("loopover-mcp CLI — tools", () => {
123123
it("documents LOOPOVER_LOGIN / GITHUB_LOGIN in the --help Environment block (#5930)", () => {
124124
const help = run(["--help"]);
125125
expect(help).toContain("Environment:");
126-
// Six subcommands resolve the login from LOOPOVER_LOGIN (then GITHUB_LOGIN); help must list it.
126+
// Seven subcommands resolve the login from LOOPOVER_LOGIN (then GITHUB_LOGIN); help must list it.
127127
expect(help).toMatch(/LOOPOVER_LOGIN or GITHUB_LOGIN/);
128128
});
129129
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// (#6619 registered the pr-ai-review-findings CLI mirror, taking the count from 60 to 61.)
99
// (#6621 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 61 to 62.)
1010
// (#6615 registered the loopover_close_pr write-tool — 9th of the 9 buildXSpec builders — taking the count from 62 to 63.)
11+
// (#6732 registered the loopover_monitor_open_prs CLI mirror, taking the count from 63 to 64.)
1112
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1213
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
1314
import { mkdtempSync, rmSync } from "node:fs";
@@ -51,14 +52,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
5152
});
5253
afterEach(disconnect);
5354

54-
it("lists exactly 63 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
55+
it("lists exactly 64 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
5556
const { tools } = await client.listTools();
5657
const names = tools.map((t) => t.name);
5758
const primary = names.filter((n) => n.startsWith("loopover_"));
5859
const legacy = names.filter((n) => n.startsWith("gittensory_"));
59-
expect(primary.length).toBe(63);
60+
expect(primary.length).toBe(64);
6061
expect(legacy.length).toBe(0);
61-
expect(names.length).toBe(63);
62+
expect(names.length).toBe(64);
6263
});
6364

6465
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -68,11 +69,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
6869
}
6970
});
7071

71-
it("`loopover-mcp tools --json` reports the same 63-tool count the live server registers", async () => {
72+
it("`loopover-mcp tools --json` reports the same 64-tool count the live server registers", async () => {
7273
const { tools } = await client.listTools();
7374
const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> };
7475
expect(payload.count).toBe(tools.length);
75-
expect(payload.count).toBe(63);
76+
expect(payload.count).toBe(64);
7677
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
7778
});
7879
});

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export async function startFixtureServer(
153153
onPacketRequest?: (body: unknown) => void;
154154
onApiRequest?: (request: IncomingMessage) => void;
155155
validateConfigWarnings?: string[];
156+
openPrMonitor?: Record<string, unknown>;
156157
intakeStatus?: number;
157158
localBranchAnalysisStatus?: number;
158159
} = {},
@@ -249,6 +250,10 @@ export async function startFixtureServer(
249250
);
250251
return;
251252
}
253+
if (request.url === "/v1/contributors/JSONbored/open-pr-monitor" && request.method === "GET") {
254+
response.end(JSON.stringify({ ...openPrMonitorFixture(), ...(options.openPrMonitor ?? {}) }));
255+
return;
256+
}
252257
if (request.url === "/v1/contributors/JSONbored/repos/JSONbored/gittensory/decision" && request.method === "GET") {
253258
if (options.repoDecisionStatus && options.repoDecisionStatus >= 400) {
254259
response.statusCode = options.repoDecisionStatus;
@@ -625,6 +630,31 @@ export function localBranchAnalysisFixture() {
625630
};
626631
}
627632

633+
/** Mirrors the ContributorOpenPrMonitor shape src/signals/contributor-open-pr-monitor.ts returns. */
634+
export function openPrMonitorFixture() {
635+
return {
636+
login: "JSONbored",
637+
generatedAt: "2026-06-01T00:00:00.000Z",
638+
openPrCount: 1,
639+
registeredRepoCount: 2,
640+
cleanupFirst: true,
641+
summary: "1 open PR needs attention before starting new work.",
642+
guidance: ["Clear the failing check on JSONbored/gittensory#42 before opening anything new."],
643+
pendingScenarios: [],
644+
pullRequests: [
645+
{
646+
repoFullName: "JSONbored/gittensory",
647+
number: 42,
648+
title: "fix(queue): drain stale entries",
649+
classification: "failing_checks",
650+
summary: "CI is red on this PR.",
651+
reasons: ["1 check is failing."],
652+
nextSteps: ["Fix the failing check, then push."],
653+
},
654+
],
655+
};
656+
}
657+
628658
export function decisionPackFixture() {
629659
return {
630660
status: "ready",

0 commit comments

Comments
 (0)