Skip to content

Commit a4c15d6

Browse files
feat(mcp): register loopover_check_improvement_potential stdio tool (#7963)
* feat(mcp): register loopover_check_improvement_potential stdio tool * fix(test): pin MCP stdio tool count at 93 after main merge --------- Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
1 parent 43099df commit a4c15d6

3 files changed

Lines changed: 144 additions & 5 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,42 @@ const checkSlopRiskShape = {
720720
testFiles: z.array(z.string().max(400)).max(2000).optional(),
721721
};
722722

723+
// #7759: mirrors checkImprovementPotentialShape in src/mcp/server.ts — same optional local-metadata fields the
724+
// CLI / REST route already accept. Stdio proxies POST /v1/lint/improvement-potential (builders stay app-side).
725+
const checkImprovementPotentialShape = {
726+
changedFiles: z
727+
.array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() }))
728+
.max(2000)
729+
.optional(),
730+
tests: z.array(z.string().max(400)).max(2000).optional(),
731+
testFiles: z.array(z.string().max(400)).max(2000).optional(),
732+
patchCoverageDeltaPercent: z.number().optional(),
733+
complexityDeltas: z
734+
.array(
735+
z.object({
736+
file: z.string().min(1).max(400),
737+
line: z.number().int().min(1),
738+
name: z.string().min(1).max(400),
739+
before: z.number().int().min(0),
740+
after: z.number().int().min(0),
741+
delta: z.number().int(),
742+
}),
743+
)
744+
.max(2000)
745+
.optional(),
746+
duplicationDeltas: z
747+
.array(
748+
z.object({
749+
file: z.string().min(1).max(400),
750+
line: z.number().int().min(1),
751+
duplicateOfLine: z.number().int().min(1),
752+
lines: z.number().int().min(1),
753+
}),
754+
)
755+
.max(2000)
756+
.optional(),
757+
};
758+
723759
const checkIssueSlopShape = {
724760
title: z.string().max(500).optional(),
725761
body: z.string().max(40000).optional(),
@@ -1079,6 +1115,12 @@ const STDIO_TOOL_DESCRIPTORS = [
10791115
category: "review",
10801116
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.",
10811117
},
1118+
{
1119+
name: "loopover_check_improvement_potential",
1120+
category: "review",
1121+
description:
1122+
"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.",
1123+
},
10821124
{
10831125
name: "loopover_simulate_open_pr_pressure",
10841126
category: "discovery",
@@ -1857,6 +1899,18 @@ registerStdioTool(
18571899
(input: any) => toolResult("LoopOver slop-risk self-check.", { ...buildSlopAssessment(input), rubric: SLOP_RUBRIC_MARKDOWN }),
18581900
);
18591901

1902+
// #7759: CLI already proxies POST /v1/lint/improvement-potential (#6748); register the matching stdio tool.
1903+
// Proxies rather than computing in-process (same rationale as the CLI): builders live app-side, not in
1904+
// @loopover/engine. Forward the validated input object as the POST body — no local branching.
1905+
registerStdioTool(
1906+
"loopover_check_improvement_potential",
1907+
{
1908+
description: stdioToolDescription("loopover_check_improvement_potential"),
1909+
inputSchema: checkImprovementPotentialShape,
1910+
},
1911+
async (input: any) => toolResult("LoopOver improvement-potential self-check.", await apiPost("/v1/lint/improvement-potential", input)),
1912+
);
1913+
18601914
// #6751: CLI mirror of the remote server's loopover_simulate_open_pr_pressure. Proxies rather than computing
18611915
// in-process (like the boundary-tests mirror, #6750): simulateOpenPrPressure lives app-side in
18621916
// src/services/open-pr-pressure-scenarios.ts, not in @loopover/engine, so POST /v1/lint/open-pr-pressure stays
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7759: in-process coverage for the loopover_check_improvement_potential stdio tool.
10+
// Same #7764 entrypoint-guard pattern as sibling maintainer tools — import .ts, hold exported `server`,
11+
// connect InMemoryTransport so v8/Codecov attributes registerStdioTool.
12+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
13+
14+
type BinModule = {
15+
server: { connect: (transport: unknown) => Promise<void> };
16+
};
17+
18+
let tempDir = "";
19+
const capturedRequests: Array<{ url: string; method: string }> = [];
20+
const loaded = new Map<string, BinModule>();
21+
22+
beforeAll(async () => {
23+
tempDir = mkdtempSync(join(tmpdir(), "loopover-improvement-potential-stdio-"));
24+
const apiUrl = await startFixtureServer({
25+
onApiRequest: (request) => {
26+
if (request.url && request.url.includes("/lint/improvement-potential")) {
27+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
28+
}
29+
},
30+
});
31+
process.env.LOOPOVER_API_URL = apiUrl;
32+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
33+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
34+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
35+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
36+
for (const specifier of MODULES) {
37+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
38+
}
39+
}, 120_000);
40+
41+
afterAll(async () => {
42+
await closeFixtureServer();
43+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
44+
delete process.env.LOOPOVER_API_URL;
45+
delete process.env.LOOPOVER_API_TOKEN;
46+
delete process.env.LOOPOVER_CONFIG_DIR;
47+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
48+
});
49+
50+
describe("bin loopover_check_improvement_potential stdio tool (in-process, #7759)", () => {
51+
it.each(MODULES)("registers and proxies POST /v1/lint/improvement-potential - %s", async (specifier) => {
52+
capturedRequests.length = 0;
53+
const mod = loaded.get(specifier)!;
54+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
55+
await mod.server.connect(serverTransport);
56+
const client = new Client({ name: "improvement-potential-stdio-test", version: "0.1.0" }, { capabilities: {} });
57+
await client.connect(clientTransport);
58+
try {
59+
const { tools } = await client.listTools();
60+
const tool = tools.find((entry) => entry.name === "loopover_check_improvement_potential");
61+
expect(tool).toBeDefined();
62+
expect(tool?.description).toMatch(/improvement/i);
63+
64+
const result = await client.callTool({
65+
name: "loopover_check_improvement_potential",
66+
arguments: {
67+
changedFiles: [{ path: "src/widget.ts", additions: 80, deletions: 2 }],
68+
testFiles: ["test/unit/widget.test.ts"],
69+
},
70+
});
71+
expect(capturedRequests.length).toBe(1);
72+
const captured = capturedRequests[0]!;
73+
expect(captured.url).toContain("/v1/lint/improvement-potential");
74+
expect(captured.method).toBe("POST");
75+
expect(result.isError).toBeFalsy();
76+
const text = JSON.stringify(result);
77+
expect(text).toContain("improvementScore");
78+
expect(text).toContain("minor");
79+
expect(text).not.toMatch(/wallet|hotkey|reward|trust score/i);
80+
} finally {
81+
await client.close().catch(() => undefined);
82+
}
83+
});
84+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
// (#7762 registered the loopover_mark_notifications_read stdio tool, taking the count from 89 to 90.)
3535
// (#7760 registered the loopover_get_contributor_profile stdio tool, taking the count from 90 to 91.)
3636
// (#7763 registered the loopover_watch_issues stdio tool, taking the count from 91 to 92.)
37+
// (#7759 registered the loopover_check_improvement_potential stdio tool, taking the count from 92 to 93.)
3738
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3839
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3940
import { mkdtempSync, rmSync } from "node:fs";
@@ -80,14 +81,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8081
});
8182
afterEach(disconnect);
8283

83-
it("lists exactly 92 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
84+
it("lists exactly 93 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
8485
const { tools } = await client.listTools();
8586
const names = tools.map((t) => t.name);
8687
const primary = names.filter((n) => n.startsWith("loopover_"));
8788
const legacy = names.filter((n) => n.startsWith("gittensory_"));
88-
expect(primary.length).toBe(92);
89+
expect(primary.length).toBe(93);
8990
expect(legacy.length).toBe(0);
90-
expect(names.length).toBe(92);
91+
expect(names.length).toBe(93);
9192
});
9293

9394
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -99,14 +100,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
99100
}
100101
});
101102

102-
it("`loopover-mcp tools --json` reports the same 92-tool count the live server registers", async () => {
103+
it("`loopover-mcp tools --json` reports the same 93-tool count the live server registers", async () => {
103104
const { tools } = await client.listTools();
104105
const payload = JSON.parse(run(["tools", "--json"])) as {
105106
count: number;
106107
tools: Array<{ name: string }>;
107108
};
108109
expect(payload.count).toBe(tools.length);
109-
expect(payload.count).toBe(92);
110+
expect(payload.count).toBe(93);
110111
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
111112
[...tools.map((t) => t.name)].sort(),
112113
);

0 commit comments

Comments
 (0)