diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 4720e1196b..09b6307d1a 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -720,6 +720,42 @@ const checkSlopRiskShape = { testFiles: z.array(z.string().max(400)).max(2000).optional(), }; +// #7759: mirrors checkImprovementPotentialShape in src/mcp/server.ts — same optional local-metadata fields the +// CLI / REST route already accept. Stdio proxies POST /v1/lint/improvement-potential (builders stay app-side). +const checkImprovementPotentialShape = { + changedFiles: z + .array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() })) + .max(2000) + .optional(), + tests: z.array(z.string().max(400)).max(2000).optional(), + testFiles: z.array(z.string().max(400)).max(2000).optional(), + patchCoverageDeltaPercent: z.number().optional(), + complexityDeltas: z + .array( + z.object({ + file: z.string().min(1).max(400), + line: z.number().int().min(1), + name: z.string().min(1).max(400), + before: z.number().int().min(0), + after: z.number().int().min(0), + delta: z.number().int(), + }), + ) + .max(2000) + .optional(), + duplicationDeltas: z + .array( + z.object({ + file: z.string().min(1).max(400), + line: z.number().int().min(1), + duplicateOfLine: z.number().int().min(1), + lines: z.number().int().min(1), + }), + ) + .max(2000) + .optional(), +}; + const checkIssueSlopShape = { title: z.string().max(500).optional(), body: z.string().max(40000).optional(), @@ -1079,6 +1115,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. Computed in-process; no repo data and no API round-trip.", }, + { + name: "loopover_check_improvement_potential", + category: "review", + description: + "Assess the deterministic structural-improvement potential of a planned change from local diff metadata plus optional complexity/duplication/patch-coverage deltas — mirrors loopover_check_slop_risk on the positive axis. Same as `loopover-mcp improvement-potential` / POST /v1/lint/improvement-potential.", + }, { name: "loopover_simulate_open_pr_pressure", category: "discovery", @@ -1857,6 +1899,18 @@ registerStdioTool( (input: any) => toolResult("LoopOver slop-risk self-check.", { ...buildSlopAssessment(input), rubric: SLOP_RUBRIC_MARKDOWN }), ); +// #7759: CLI already proxies POST /v1/lint/improvement-potential (#6748); register the matching stdio tool. +// Proxies rather than computing in-process (same rationale as the CLI): builders live app-side, not in +// @loopover/engine. Forward the validated input object as the POST body — no local branching. +registerStdioTool( + "loopover_check_improvement_potential", + { + description: stdioToolDescription("loopover_check_improvement_potential"), + inputSchema: checkImprovementPotentialShape, + }, + async (input: any) => toolResult("LoopOver improvement-potential self-check.", await apiPost("/v1/lint/improvement-potential", input)), +); + // #6751: CLI mirror of the remote server's loopover_simulate_open_pr_pressure. Proxies rather than computing // in-process (like the boundary-tests mirror, #6750): simulateOpenPrPressure lives app-side in // src/services/open-pr-pressure-scenarios.ts, not in @loopover/engine, so POST /v1/lint/open-pr-pressure stays diff --git a/test/unit/mcp-cli-improvement-potential-stdio.test.ts b/test/unit/mcp-cli-improvement-potential-stdio.test.ts new file mode 100644 index 0000000000..8e535222f8 --- /dev/null +++ b/test/unit/mcp-cli-improvement-potential-stdio.test.ts @@ -0,0 +1,84 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7759: in-process coverage for the loopover_check_improvement_potential stdio tool. +// Same #7764 entrypoint-guard pattern as sibling maintainer tools — import .ts, hold exported `server`, +// connect InMemoryTransport so v8/Codecov attributes registerStdioTool. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-improvement-potential-stdio-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/lint/improvement-potential")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + for (const specifier of MODULES) { + loaded.set(specifier, (await import(specifier)) as unknown as BinModule); + } +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_API_URL; + delete process.env.LOOPOVER_API_TOKEN; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +describe("bin loopover_check_improvement_potential stdio tool (in-process, #7759)", () => { + it.each(MODULES)("registers and proxies POST /v1/lint/improvement-potential - %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "improvement-potential-stdio-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_check_improvement_potential"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/improvement/i); + + const result = await client.callTool({ + name: "loopover_check_improvement_potential", + arguments: { + changedFiles: [{ path: "src/widget.ts", additions: 80, deletions: 2 }], + testFiles: ["test/unit/widget.test.ts"], + }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/lint/improvement-potential"); + expect(captured.method).toBe("POST"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).toContain("improvementScore"); + expect(text).toContain("minor"); + expect(text).not.toMatch(/wallet|hotkey|reward|trust score/i); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index f6626c207c..0ca097bdba 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -34,6 +34,7 @@ // (#7762 registered the loopover_mark_notifications_read stdio tool, taking the count from 89 to 90.) // (#7760 registered the loopover_get_contributor_profile stdio tool, taking the count from 90 to 91.) // (#7763 registered the loopover_watch_issues stdio tool, taking the count from 91 to 92.) +// (#7759 registered the loopover_check_improvement_potential stdio tool, taking the count from 92 to 93.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -80,14 +81,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 92 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 93 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(92); + expect(primary.length).toBe(93); expect(legacy.length).toBe(0); - expect(names.length).toBe(92); + expect(names.length).toBe(93); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -99,14 +100,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 92-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 93-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(92); + expect(payload.count).toBe(93); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );