Skip to content

Commit f2ee393

Browse files
committed
feat(mcp): register loopover_propose_action as a local stdio tool
loopover_propose_action has a remote MCP tool (src/mcp/server.ts) and a `maintain propose` CLI command, but no local stdio MCP tool registration. #6744 added the REST route + CLI but never the matching stdio tool. Adds the registerStdioTool block mirroring the maintain-adjacent sibling pattern (loopover_list_pending_actions et al.) -- POSTs to the same {repoBase}/agent/pending-actions route the CLI hits, with the identical stripUndefined body so absent optional fields are omitted. Stages the action into the approval queue; the route never executes it until a maintainer approves. Input mirrors the remote proposeActionShape; description via stdioToolDescription; category "agent". test/unit/mcp-cli-propose-action.test.ts drives it IN-PROCESS (#7764 entrypoint guard + InMemoryTransport) so the registration + handler get real Codecov-measured coverage -- a subprocess spawn can't be v8-instrumented. Count 97 -> 98. Closes #7753
1 parent 6a55b8d commit f2ee393

3 files changed

Lines changed: 124 additions & 5 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,21 @@ const repoOnboardingPackShape = {
370370
refresh: z.boolean().optional(),
371371
};
372372

373+
// #7753: mirrors the remote loopover_propose_action input (src/mcp/server.ts's proposeActionShape) so the local
374+
// stdio tool validates identically. actionClass is the same superset enum the route + `maintain propose` accept
375+
// (PROPOSE_ACTION_CLASSES); the optional fields carry per-action-class detail and are stripped when absent.
376+
const proposeActionShape = {
377+
owner: z.string().min(1),
378+
repo: z.string().min(1),
379+
pullNumber: z.number().int().positive(),
380+
actionClass: z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label"]),
381+
reason: z.string().max(500).optional(),
382+
label: z.string().min(1).max(100).optional(),
383+
reviewBody: z.string().max(60000).optional(),
384+
mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(),
385+
closeComment: z.string().max(60000).optional(),
386+
};
387+
373388
const skippedPrAuditShape = {
374389
repoFullName: z.string().trim().min(1).max(200).optional(),
375390
reason: z.string().trim().min(1).max(64).optional(),
@@ -1492,6 +1507,12 @@ const STDIO_TOOL_DESCRIPTORS = [
14921507
category: "agent",
14931508
description: "List the agent actions currently staged and awaiting a decision in a repo's approval queue, so a maintainer can review what is pending. Returns the pending queue only — the same list as `loopover-mcp maintain queue`. Maintainer access required.",
14941509
},
1510+
{
1511+
name: "loopover_propose_action",
1512+
category: "agent",
1513+
description:
1514+
"Stage a PR action (label / request_changes / approve / merge / close) into the repo's approval queue for a maintainer to accept or reject. Maintainer access required; the action is NOT executed until approved.",
1515+
},
14951516
{
14961517
name: "loopover_decide_pending_action",
14971518
category: "agent",
@@ -3014,6 +3035,24 @@ registerStdioTool(
30143035
},
30153036
);
30163037

3038+
// #7753: stdio mirror of the remote loopover_propose_action + the `maintain propose` CLI. POSTs to the same
3039+
// {repoBase}/agent/pending-actions route the CLI hits, with the identical stripUndefined body so absent optional
3040+
// fields are omitted. Stages the action into the approval queue -- the route never executes it until approved.
3041+
registerStdioTool(
3042+
"loopover_propose_action",
3043+
{
3044+
description: stdioToolDescription("loopover_propose_action"),
3045+
inputSchema: proposeActionShape,
3046+
},
3047+
async ({ owner, repo, pullNumber, actionClass, reason, label, reviewBody, mergeMethod, closeComment }: any) => {
3048+
const payload = await apiPost(
3049+
`${toolRepoBase(owner, repo)}/agent/pending-actions`,
3050+
stripUndefined({ pullNumber, actionClass, reason, label, reviewBody, mergeMethod, closeComment }),
3051+
);
3052+
return toolResult(`Staged ${actionClass} on ${owner}/${repo}#${pullNumber} into the approval queue.`, payload);
3053+
},
3054+
);
3055+
30173056
registerStdioTool(
30183057
"loopover_decide_pending_action",
30193058
{
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
// #7753: in-process coverage for the loopover_propose_action stdio tool. Same #7764 entrypoint-guard pattern as
10+
// mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`, connect an InMemoryTransport so
11+
// v8/Codecov attributes the registerStdioTool block (a subprocess spawn CANNOT be instrumented -- earlier
12+
// subprocess-only attempts at this exact tool were closed for 0% patch coverage).
13+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
14+
15+
type BinModule = {
16+
server: { connect: (transport: unknown) => Promise<void> };
17+
};
18+
19+
let tempDir = "";
20+
const proposeCalls: Array<{ url: string; method: string }> = [];
21+
const loaded = new Map<string, BinModule>();
22+
23+
beforeAll(async () => {
24+
tempDir = mkdtempSync(join(tmpdir(), "loopover-propose-action-"));
25+
const apiUrl = await startFixtureServer({
26+
onApiRequest: (r) => {
27+
if (r.method === "POST" && r.url && r.url.includes("/agent/pending-actions")) proposeCalls.push({ url: r.url ?? "", method: r.method ?? "" });
28+
},
29+
});
30+
process.env.LOOPOVER_API_URL = apiUrl;
31+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
32+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
33+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
34+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
35+
for (const specifier of MODULES) {
36+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
37+
}
38+
}, 120_000);
39+
40+
afterAll(async () => {
41+
await closeFixtureServer();
42+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
43+
delete process.env.LOOPOVER_API_URL;
44+
delete process.env.LOOPOVER_API_TOKEN;
45+
delete process.env.LOOPOVER_CONFIG_DIR;
46+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
47+
});
48+
49+
describe("bin loopover_propose_action stdio tool (in-process, #7753)", () => {
50+
it.each(MODULES)("stages an action via POST .../agent/pending-actions, forwarding the body — %s", async (specifier) => {
51+
proposeCalls.length = 0;
52+
const mod = loaded.get(specifier)!;
53+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
54+
await mod.server.connect(serverTransport);
55+
const client = new Client({ name: "propose-action-test", version: "0.1.0" }, { capabilities: {} });
56+
await client.connect(clientTransport);
57+
try {
58+
const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_propose_action");
59+
expect(tool).toBeDefined();
60+
expect(tool?.description).toMatch(/approval queue|NOT executed until approved/i);
61+
62+
const result = await client.callTool({
63+
name: "loopover_propose_action",
64+
arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "label", reason: "needs triage", label: "bug" },
65+
});
66+
expect(result.isError).toBeFalsy();
67+
expect(proposeCalls).toEqual([{ url: "/v1/repos/owner/repo/agent/pending-actions", method: "POST" }]);
68+
// The fixture echoes the posted actionClass/pullNumber/reason, proving the body was serialized + forwarded.
69+
const data = result.structuredContent as { created?: boolean; action?: { actionClass?: string; pullNumber?: number; reason?: string } };
70+
expect(data.created).toBe(true);
71+
expect(data.action?.actionClass).toBe("label");
72+
expect(data.action?.pullNumber).toBe(7);
73+
expect(data.action?.reason).toBe("needs triage");
74+
expect(JSON.stringify(result)).toContain("Staged label on owner/repo#7 into the approval queue.");
75+
} finally {
76+
await client.close().catch(() => undefined);
77+
}
78+
});
79+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
// (#7754 registered the loopover_refresh_repo_docs stdio tool, taking the count from 96 to 97.)
4242
// (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 97 to 98.)
4343
// (#7755 registered the loopover_generate_contributor_issue_drafts stdio tool, taking the count from 98 to 99.)
44+
// (#7753 registered the loopover_propose_action stdio tool, taking the count from 99 to 100.)
4445
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4546
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4647
import { mkdtempSync, rmSync } from "node:fs";
@@ -87,14 +88,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8788
});
8889
afterEach(disconnect);
8990

90-
it("lists exactly 99 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
91+
it("lists exactly 100 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
9192
const { tools } = await client.listTools();
9293
const names = tools.map((t) => t.name);
9394
const primary = names.filter((n) => n.startsWith("loopover_"));
9495
const legacy = names.filter((n) => n.startsWith("gittensory_"));
95-
expect(primary.length).toBe(99);
96+
expect(primary.length).toBe(100);
9697
expect(legacy.length).toBe(0);
97-
expect(names.length).toBe(99);
98+
expect(names.length).toBe(100);
9899
});
99100

100101
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -106,14 +107,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
106107
}
107108
});
108109

109-
it("`loopover-mcp tools --json` reports the same 99-tool count the live server registers", async () => {
110+
it("`loopover-mcp tools --json` reports the same 100-tool count the live server registers", async () => {
110111
const { tools } = await client.listTools();
111112
const payload = JSON.parse(run(["tools", "--json"])) as {
112113
count: number;
113114
tools: Array<{ name: string }>;
114115
};
115116
expect(payload.count).toBe(tools.length);
116-
expect(payload.count).toBe(99);
117+
expect(payload.count).toBe(100);
117118
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
118119
[...tools.map((t) => t.name)].sort(),
119120
);

0 commit comments

Comments
 (0)