diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 25d8d19bb1..d4c8dd121d 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -1086,6 +1086,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "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.", }, + { + name: "loopover_get_repo_outcome_patterns", + category: "maintainer", + description: + "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.", + }, { name: "loopover_preview_local_pr_score", category: "branch", @@ -1893,6 +1899,20 @@ registerStdioTool( }, ); +// #6734: CLI stdio mirror of loopover_get_repo_outcome_patterns — thin GET proxy of the already-public +// /v1/repos/:owner/:repo/outcome-patterns route (same ownerRepoShape + apiGet pattern as maintainer_noise). +registerStdioTool( + "loopover_get_repo_outcome_patterns", + { + description: stdioToolDescription("loopover_get_repo_outcome_patterns"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver repo outcome patterns.", await apiGet(`${prefix}/outcome-patterns`)); + }, +); + registerStdioTool( "loopover_preview_local_pr_score", { diff --git a/test/unit/mcp-cli-repo-outcome-patterns.test.ts b/test/unit/mcp-cli-repo-outcome-patterns.test.ts new file mode 100644 index 0000000000..fb89016f72 --- /dev/null +++ b/test/unit/mcp-cli-repo-outcome-patterns.test.ts @@ -0,0 +1,97 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + closeFixtureServer, + run, + startFixtureServer, +} from "./support/mcp-cli-harness"; + +// #6734: CLI stdio mirror of loopover_get_repo_outcome_patterns — thin GET proxy of the public +// /v1/repos/:owner/:repo/outcome-patterns route (same ownerRepoShape + apiGet pattern as maintainer_noise). +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +const FORBIDDEN_PUBLIC_TERMS = + /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; + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let apiUrl: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "loopover-outcome-patterns-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/outcome-patterns")) { + capturedRequests.push({ + url: request.url ?? "", + method: request.method ?? "GET", + }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "outcome-patterns-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("loopover_get_repo_outcome_patterns stdio proxy (#6734)", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain( + "loopover_get_repo_outcome_patterns", + ); + }); + + it("proxies the call to /outcome-patterns via apiGet and returns the payload", async () => { + const result = await client.callTool({ + name: "loopover_get_repo_outcome_patterns", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/outcome-patterns"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("owner/repo"); + expect(text).toContain("patterns"); + expect(text).toContain("fresh"); + }); + + it("lists the tool via loopover-mcp tools", () => { + const payload = JSON.parse(run(["tools", "--json"])) as { + tools: Array<{ name: string; description: string }>; + }; + const tool = payload.tools.find( + (entry) => entry.name === "loopover_get_repo_outcome_patterns", + ); + expect(tool?.description).toMatch(/outcome patterns/i); + expect(tool?.description.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 1efc0a3c27..991ae1d501 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -16,13 +16,18 @@ // (#6753 registered the loopover_build_progress_snapshot CLI mirror, taking the count from 70 to 71.) // (#6942 registered loopover_get_maintainer_lane without bumping this pin — live count became 72.) // (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.) +// (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness"; +import { + closeFixtureServer, + run, + startFixtureServer, +} from "./support/mcp-cli-harness"; const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); @@ -59,29 +64,36 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 74 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 75 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(74); + expect(primary.length).toBe(75); expect(legacy.length).toBe(0); - expect(names.length).toBe(74); + expect(names.length).toBe(75); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { const { tools } = await client.listTools(); for (const tool of tools) { - expect(tool.description ?? "", `${tool.name} description`).not.toMatch(/deprecated/i); + expect(tool.description ?? "", `${tool.name} description`).not.toMatch( + /deprecated/i, + ); } }); - it("`loopover-mcp tools --json` reports the same 74-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 75-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 }> }; + const payload = JSON.parse(run(["tools", "--json"])) as { + count: number; + tools: Array<{ name: string }>; + }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(74); - expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); + expect(payload.count).toBe(75); + expect([...payload.tools.map((t) => t.name)].sort()).toEqual( + [...tools.map((t) => t.name)].sort(), + ); }); }); @@ -105,8 +117,11 @@ describe("MCP legacy alias retirement (#4777) — old names no longer resolve", "gittensory_local_status", ]; - it.each(retiredNames)("calling the retired alias %s errors instead of falling through to the handler", async (oldName) => { - const result = await client.callTool({ name: oldName, arguments: {} }); - expect(result.isError).toBe(true); - }); + it.each(retiredNames)( + "calling the retired alias %s errors instead of falling through to the handler", + async (oldName) => { + const result = await client.callTool({ name: oldName, arguments: {} }); + expect(result.isError).toBe(true); + }, + ); }); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 515e3da7e9..90712048e3 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -464,6 +464,26 @@ export async function startFixtureServer( ); return; } + if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") { + response.end( + JSON.stringify({ + status: "ready", + source: "snapshot", + repoFullName: "owner/repo", + generatedAt: "2026-06-01T00:00:00.000Z", + ageSeconds: 120, + freshness: "fresh", + patterns: { + repoFullName: "owner/repo", + generatedAt: "2026-06-01T00:00:00.000Z", + accepted: { count: 3, themes: ["tests"] }, + rejected: { count: 1, themes: ["scope"] }, + evidenceCompleteness: "partial", + }, + }), + ); + return; + } if (request.url?.startsWith("/v1/repos/owner/repo/agent/pending-actions/") && request.method === "POST") { const accepted = request.url.endsWith("/accept"); response.end(JSON.stringify(accepted ? { status: "accepted", executionOutcome: "completed" } : { status: "rejected" }));