diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 9bfb159faf..ab3990cb19 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -166,6 +166,13 @@ const ownerRepoShape = { repo: z.string().min(1), }; +const skippedPrAuditShape = { + repoFullName: z.string().trim().min(1).max(200).optional(), + reason: z.string().trim().min(1).max(64).optional(), + since: z.string().trim().min(1).max(64).optional(), + limit: z.number().int().positive().optional(), +}; + const loginShape = { login: z.string().min(1), }; @@ -579,6 +586,22 @@ const STDIO_TOOL_DESCRIPTORS = [ name: "loopover_feasibility_gate", description: "Pure local go/raise/avoid feasibility verdict from claim status, duplicate-cluster risk, and issue quality/lifecycle status — the same discriminants the analyze-phase feasibility gate branches on. When repoFullName/issueNumber are supplied and a local loopover-miner install's claim ledger is present, claimStatus is read from that ledger instead of the caller-supplied value; otherwise falls back to the caller-supplied claimStatus unchanged. Advisory-only — never blocks, cancels, or overrides a claim or attempt; real claim-conflict resolution authority stays with the maintainer-only path. No API round-trip.", }, + { + name: "loopover_get_issue_quality", + description: "Return the cached or freshly-computed issue-quality report for a repo, ranking which open issues are actionable, need proof, are stale/duplicate-prone, or already solved.", + }, + { + name: "loopover_get_registration_readiness", + description: "Preview-only registration-readiness report for a repository: what's missing/present before/after registering with LoopOver (direct-PR and issue-discovery lane readiness, label policy, maintainer-cut readiness, queue health, docs, and the GitHub App install state). Advisory only, not a registration action.", + }, + { + name: "loopover_get_config_recommendation", + description: "Return recommended .loopover.yml additions for a repository, derived from the repo's live, currently-active configured behavior (the raw dashboard/API-configured settings, not a yml-merged view — so the recommendation never compares itself against an override that already exists). Advisory only, not a write action.", + }, + { + name: "loopover_get_skipped_pr_audit", + description: "Return the skipped-PR audit trail: pull requests LoopOver's automated reviewer intentionally stayed quiet on, each with a reason code and a remediation hint. Optionally filter by repoFullName, reason, or since. Maintainer-authenticated; read-only measurement, not a moderation or override action.", + }, ]; function stdioToolDescription(name) { @@ -631,6 +654,59 @@ registerStdioTool( }, ); +registerStdioTool( + "loopover_get_issue_quality", + { + description: stdioToolDescription("loopover_get_issue_quality"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver issue-quality report.", await apiGet(`${prefix}/issue-quality`)); + }, +); + +registerStdioTool( + "loopover_get_registration_readiness", + { + description: stdioToolDescription("loopover_get_registration_readiness"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver registration-readiness report.", await apiGet(`${prefix}/registration-readiness`)); + }, +); + +registerStdioTool( + "loopover_get_config_recommendation", + { + description: stdioToolDescription("loopover_get_config_recommendation"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver config recommendation.", await apiGet(`${prefix}/gittensor-config-recommendation`)); + }, +); + +registerStdioTool( + "loopover_get_skipped_pr_audit", + { + description: stdioToolDescription("loopover_get_skipped_pr_audit"), + inputSchema: skippedPrAuditShape, + }, + async ({ repoFullName, reason, since, limit }) => { + const query = new URLSearchParams(); + if (repoFullName) query.set("repoFullName", repoFullName); + if (reason) query.set("reason", reason); + if (since) query.set("since", since); + if (limit != null) query.set("limit", String(limit)); + const qs = query.toString(); + return toolResult("LoopOver skipped-PR audit trail.", await apiGet(`/v1/app/skipped-pr-audit${qs ? `?${qs}` : ""}`)); + }, +); + registerStdioTool( "loopover_preflight_pr", { diff --git a/test/unit/mcp-cli-intake-tools.test.ts b/test/unit/mcp-cli-intake-tools.test.ts new file mode 100644 index 0000000000..ed6daf03d5 --- /dev/null +++ b/test/unit/mcp-cli-intake-tools.test.ts @@ -0,0 +1,122 @@ +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, describe, expect, it } from "vitest"; +import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness"; + +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +// #6151: the maintainer-triage / repo-owner-intake profiles recommend these 4 tools; assert none leak +// miner-private reward internals through the local stdio proxy. +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 | null = null; +let transport: StdioClientTransport | null = null; +let configDir: string | null = null; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect(options: { intakeStatus?: number } = {}) { + configDir = mkdtempSync(join(tmpdir(), "loopover-intake-tools-")); + capturedRequests = []; + const apiUrl = await startFixtureServer({ + ...options, + onApiRequest: (request) => { + const url = request.url ?? ""; + if (/issue-quality|registration-readiness|gittensor-config-recommendation|skipped-pr-audit/.test(url)) { + capturedRequests.push({ 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: "intake-tools-test", version: "0.0.1" }); + await client.connect(transport); +} + +afterEach(async () => { + await client?.close().catch(() => undefined); + client = null; + transport = null; + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); + configDir = null; +}); + +const INTAKE_TOOLS = [ + { + name: "loopover_get_issue_quality", + args: { owner: "owner", repo: "repo" }, + endpoint: "/v1/repos/owner/repo/issue-quality", + contains: "actionability", + }, + { + name: "loopover_get_registration_readiness", + args: { owner: "owner", repo: "repo" }, + endpoint: "/v1/repos/owner/repo/registration-readiness", + contains: "directPrLaneReady", + }, + { + name: "loopover_get_config_recommendation", + args: { owner: "owner", repo: "repo" }, + endpoint: "/v1/repos/owner/repo/gittensor-config-recommendation", + contains: "privateOnly", + }, + { + name: "loopover_get_skipped_pr_audit", + args: {}, + endpoint: "/v1/app/skipped-pr-audit", + contains: "remediation", + }, +] as const; + +describe("loopover-mcp intake stdio proxies (#6151)", () => { + it("registers all 4 intake tools in the stdio server tool list", async () => { + await connect(); + const { tools } = await client!.listTools(); + const names = tools.map((tool) => tool.name); + for (const tool of INTAKE_TOOLS) expect(names).toContain(tool.name); + }); + + it("lists all 4 intake tools via `loopover-mcp tools --json` with non-empty descriptions", async () => { + await connect(); + const payload = JSON.parse(run(["tools", "--json"])) as { + tools: Array<{ name: string; description: string }>; + }; + for (const tool of INTAKE_TOOLS) { + const entry = payload.tools.find((t) => t.name === tool.name); + expect(entry, `missing descriptor for ${tool.name}`).toBeTruthy(); + expect(entry!.description.trim().length).toBeGreaterThan(0); + } + }); + + for (const tool of INTAKE_TOOLS) { + it(`${tool.name} proxies to its REST endpoint and returns the payload`, async () => { + await connect(); + const result = await client!.callTool({ name: tool.name, arguments: tool.args }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests.length).toBe(1); + expect(capturedRequests[0]!.url).toContain(tool.endpoint); + expect(capturedRequests[0]!.method).toBe("GET"); + const text = JSON.stringify(result); + expect(text).toContain(tool.contains); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it(`${tool.name} surfaces an API failure as a tool error`, async () => { + await connect({ intakeStatus: 503 }); + const result = await client!.callTool({ name: tool.name, arguments: tool.args }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/503/); + }); + } +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 7cd2477be3..30fa61027e 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -1,5 +1,5 @@ // #4777: retire every gittensory_-prefixed deprecated alias that #4775 left in place for one -// minor-version deprecation cycle. This suite pins the post-retirement shape: exactly the 37 +// minor-version deprecation cycle. This suite pins the post-retirement shape: exactly the 41 // canonical loopover_-prefixed stdio tools are registered, none of their old gittensory_-prefixed // alias names resolve anymore, no description carries a stale deprecation notice, and the CLI's // `tools --json` listing stays in lockstep with what the live server actually registers. @@ -46,14 +46,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 37 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 41 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(37); + expect(primary.length).toBe(41); expect(legacy.length).toBe(0); - expect(names.length).toBe(37); + expect(names.length).toBe(41); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -63,11 +63,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 37-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 41-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(37); + expect(payload.count).toBe(41); 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 cec8135c9c..bdcd2063ee 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -144,6 +144,7 @@ export async function startFixtureServer( onPacketRequest?: (body: unknown) => void; onApiRequest?: (request: IncomingMessage) => void; validateConfigWarnings?: string[]; + intakeStatus?: number; } = {}, ) { server = createServer(async (request, response) => { @@ -440,6 +441,42 @@ export async function startFixtureServer( ); return; } + if (request.url === "/v1/repos/owner/repo/issue-quality" && request.method === "GET") { + if (options.intakeStatus && options.intakeStatus >= 400) { + response.statusCode = options.intakeStatus; + response.end(JSON.stringify({ error: "issue_quality_unavailable" })); + return; + } + response.end(JSON.stringify({ repoFullName: "owner/repo", generatedAt: "2026-05-30T00:00:00.000Z", issues: [{ number: 12, actionability: "high" }] })); + return; + } + if (request.url === "/v1/repos/owner/repo/registration-readiness" && request.method === "GET") { + if (options.intakeStatus && options.intakeStatus >= 400) { + response.statusCode = options.intakeStatus; + response.end(JSON.stringify({ error: "registration_readiness_unavailable" })); + return; + } + response.end(JSON.stringify({ repoFullName: "owner/repo", registered: false, directPrLaneReady: true, appInstalled: false })); + return; + } + if (request.url === "/v1/repos/owner/repo/gittensor-config-recommendation" && request.method === "GET") { + if (options.intakeStatus && options.intakeStatus >= 400) { + response.statusCode = options.intakeStatus; + response.end(JSON.stringify({ error: "config_recommendation_unavailable" })); + return; + } + response.end(JSON.stringify({ repoFullName: "owner/repo", privateOnly: true, recommendations: [] })); + return; + } + if (request.url?.startsWith("/v1/app/skipped-pr-audit") && request.method === "GET") { + if (options.intakeStatus && options.intakeStatus >= 400) { + response.statusCode = options.intakeStatus; + response.end(JSON.stringify({ error: "skipped_pr_audit_unavailable" })); + return; + } + response.end(JSON.stringify({ generatedAt: "2026-05-30T00:00:00.000Z", limit: 20, hasMore: false, items: [{ prNumber: 7, reason: "duplicate", remediation: "link the canonical PR" }] })); + return; + } response.statusCode = 404; response.end(JSON.stringify({ error: "not_found" })); });