Skip to content

Commit 03b78dd

Browse files
feat(mcp): CLI stdio mirror for loopover_get_repo_outcome_patterns
Closes #6734. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a4310f1 commit 03b78dd

4 files changed

Lines changed: 165 additions & 13 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,6 +1086,12 @@ const STDIO_TOOL_DESCRIPTORS = [
10861086
description:
10871087
"Return the repo's cached maintainer burden forecast (projected review load, queue-growth risk, and stale-PR signals) with a freshness marker, from the private LoopOver API.",
10881088
},
1089+
{
1090+
name: "loopover_get_repo_outcome_patterns",
1091+
category: "maintainer",
1092+
description:
1093+
"Return cached or freshly-computed per-repo accepted/rejected PR outcome patterns: what maintainers actually merge or close, separated from maintainer-lane activity, with a freshness marker and explicit evidence-completeness.",
1094+
},
10891095
{
10901096
name: "loopover_preview_local_pr_score",
10911097
category: "branch",
@@ -1893,6 +1899,20 @@ registerStdioTool(
18931899
},
18941900
);
18951901

1902+
// #6734: CLI stdio mirror of loopover_get_repo_outcome_patterns — thin GET proxy of the already-public
1903+
// /v1/repos/:owner/:repo/outcome-patterns route (same ownerRepoShape + apiGet pattern as maintainer_noise).
1904+
registerStdioTool(
1905+
"loopover_get_repo_outcome_patterns",
1906+
{
1907+
description: stdioToolDescription("loopover_get_repo_outcome_patterns"),
1908+
inputSchema: ownerRepoShape,
1909+
},
1910+
async ({ owner, repo }) => {
1911+
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
1912+
return toolResult("LoopOver repo outcome patterns.", await apiGet(`${prefix}/outcome-patterns`));
1913+
},
1914+
);
1915+
18961916
registerStdioTool(
18971917
"loopover_preview_local_pr_score",
18981918
{
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
7+
import {
8+
closeFixtureServer,
9+
run,
10+
startFixtureServer,
11+
} from "./support/mcp-cli-harness";
12+
13+
// #6734: CLI stdio mirror of loopover_get_repo_outcome_patterns — thin GET proxy of the public
14+
// /v1/repos/:owner/:repo/outcome-patterns route (same ownerRepoShape + apiGet pattern as maintainer_noise).
15+
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
16+
const FORBIDDEN_PUBLIC_TERMS =
17+
/wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i;
18+
19+
let client: Client;
20+
let transport: StdioClientTransport;
21+
let configDir: string;
22+
let apiUrl: string;
23+
let capturedRequests: Array<{ url: string; method: string }>;
24+
25+
async function connect() {
26+
configDir = mkdtempSync(join(tmpdir(), "loopover-outcome-patterns-"));
27+
capturedRequests = [];
28+
apiUrl = await startFixtureServer({
29+
onApiRequest: (request) => {
30+
if (request.url && request.url.includes("/outcome-patterns")) {
31+
capturedRequests.push({
32+
url: request.url ?? "",
33+
method: request.method ?? "GET",
34+
});
35+
}
36+
},
37+
});
38+
transport = new StdioClientTransport({
39+
command: "node",
40+
args: [bin, "--stdio"],
41+
env: {
42+
...process.env,
43+
LOOPOVER_CONFIG_DIR: configDir,
44+
LOOPOVER_API_URL: apiUrl,
45+
LOOPOVER_TOKEN: "session-token",
46+
LOOPOVER_API_TIMEOUT_MS: "5000",
47+
},
48+
});
49+
client = new Client({ name: "outcome-patterns-test", version: "0.0.1" });
50+
await client.connect(transport);
51+
}
52+
53+
async function disconnect() {
54+
await client.close().catch(() => undefined);
55+
await closeFixtureServer();
56+
if (configDir) rmSync(configDir, { recursive: true, force: true });
57+
}
58+
59+
describe("loopover_get_repo_outcome_patterns stdio proxy (#6734)", () => {
60+
beforeEach(connect);
61+
afterEach(disconnect);
62+
63+
it("registers the tool in the stdio server tool list", async () => {
64+
const { tools } = await client.listTools();
65+
expect(tools.map((tool) => tool.name)).toContain(
66+
"loopover_get_repo_outcome_patterns",
67+
);
68+
});
69+
70+
it("proxies the call to /outcome-patterns via apiGet and returns the payload", async () => {
71+
const result = await client.callTool({
72+
name: "loopover_get_repo_outcome_patterns",
73+
arguments: { owner: "owner", repo: "repo" },
74+
});
75+
expect(capturedRequests.length).toBe(1);
76+
const captured = capturedRequests[0]!;
77+
expect(captured.url).toContain("/v1/repos/owner/repo/outcome-patterns");
78+
expect(captured.method).toBe("GET");
79+
expect(result.isError).toBeFalsy();
80+
const text = JSON.stringify(result);
81+
expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
82+
expect(text).toContain("owner/repo");
83+
expect(text).toContain("patterns");
84+
expect(text).toContain("fresh");
85+
});
86+
87+
it("lists the tool via loopover-mcp tools", () => {
88+
const payload = JSON.parse(run(["tools", "--json"])) as {
89+
tools: Array<{ name: string; description: string }>;
90+
};
91+
const tool = payload.tools.find(
92+
(entry) => entry.name === "loopover_get_repo_outcome_patterns",
93+
);
94+
expect(tool?.description).toMatch(/outcome patterns/i);
95+
expect(tool?.description.trim().length).toBeGreaterThan(0);
96+
});
97+
});

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

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@
1616
// (#6753 registered the loopover_build_progress_snapshot CLI mirror, taking the count from 70 to 71.)
1717
// (#6942 registered loopover_get_maintainer_lane without bumping this pin — live count became 72.)
1818
// (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.)
19+
// (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.)
1920
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2021
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2122
import { mkdtempSync, rmSync } from "node:fs";
2223
import { tmpdir } from "node:os";
2324
import { join } from "node:path";
2425
import { afterEach, beforeEach, describe, expect, it } from "vitest";
25-
import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness";
26+
import {
27+
closeFixtureServer,
28+
run,
29+
startFixtureServer,
30+
} from "./support/mcp-cli-harness";
2631

2732
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
2833

@@ -59,29 +64,36 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
5964
});
6065
afterEach(disconnect);
6166

62-
it("lists exactly 74 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
67+
it("lists exactly 75 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
6368
const { tools } = await client.listTools();
6469
const names = tools.map((t) => t.name);
6570
const primary = names.filter((n) => n.startsWith("loopover_"));
6671
const legacy = names.filter((n) => n.startsWith("gittensory_"));
67-
expect(primary.length).toBe(74);
72+
expect(primary.length).toBe(75);
6873
expect(legacy.length).toBe(0);
69-
expect(names.length).toBe(74);
74+
expect(names.length).toBe(75);
7075
});
7176

7277
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
7378
const { tools } = await client.listTools();
7479
for (const tool of tools) {
75-
expect(tool.description ?? "", `${tool.name} description`).not.toMatch(/deprecated/i);
80+
expect(tool.description ?? "", `${tool.name} description`).not.toMatch(
81+
/deprecated/i,
82+
);
7683
}
7784
});
7885

79-
it("`loopover-mcp tools --json` reports the same 74-tool count the live server registers", async () => {
86+
it("`loopover-mcp tools --json` reports the same 75-tool count the live server registers", async () => {
8087
const { tools } = await client.listTools();
81-
const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> };
88+
const payload = JSON.parse(run(["tools", "--json"])) as {
89+
count: number;
90+
tools: Array<{ name: string }>;
91+
};
8292
expect(payload.count).toBe(tools.length);
83-
expect(payload.count).toBe(74);
84-
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
93+
expect(payload.count).toBe(75);
94+
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
95+
[...tools.map((t) => t.name)].sort(),
96+
);
8597
});
8698
});
8799

@@ -105,8 +117,11 @@ describe("MCP legacy alias retirement (#4777) — old names no longer resolve",
105117
"gittensory_local_status",
106118
];
107119

108-
it.each(retiredNames)("calling the retired alias %s errors instead of falling through to the handler", async (oldName) => {
109-
const result = await client.callTool({ name: oldName, arguments: {} });
110-
expect(result.isError).toBe(true);
111-
});
120+
it.each(retiredNames)(
121+
"calling the retired alias %s errors instead of falling through to the handler",
122+
async (oldName) => {
123+
const result = await client.callTool({ name: oldName, arguments: {} });
124+
expect(result.isError).toBe(true);
125+
},
126+
);
112127
});

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,26 @@ export async function startFixtureServer(
464464
);
465465
return;
466466
}
467+
if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") {
468+
response.end(
469+
JSON.stringify({
470+
status: "ready",
471+
source: "snapshot",
472+
repoFullName: "owner/repo",
473+
generatedAt: "2026-06-01T00:00:00.000Z",
474+
ageSeconds: 120,
475+
freshness: "fresh",
476+
patterns: {
477+
repoFullName: "owner/repo",
478+
generatedAt: "2026-06-01T00:00:00.000Z",
479+
accepted: { count: 3, themes: ["tests"] },
480+
rejected: { count: 1, themes: ["scope"] },
481+
evidenceCompleteness: "partial",
482+
},
483+
}),
484+
);
485+
return;
486+
}
467487
if (request.url?.startsWith("/v1/repos/owner/repo/agent/pending-actions/") && request.method === "POST") {
468488
const accepted = request.url.endsWith("/accept");
469489
response.end(JSON.stringify(accepted ? { status: "accepted", executionOutcome: "completed" } : { status: "rejected" }));

0 commit comments

Comments
 (0)