From 7b3bda82dab2e0f85c16749f67883f78894c9ad1 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Wed, 22 Jul 2026 01:54:25 +0000 Subject: [PATCH] feat(mcp): register loopover_propose_action as a local stdio tool --- packages/loopover-mcp/bin/loopover-mcp.ts | 39 +++++++++++ test/unit/mcp-cli-propose-action.test.ts | 79 +++++++++++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 11 ++-- 3 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 test/unit/mcp-cli-propose-action.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 7fba4567f7..f51cce0445 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -370,6 +370,21 @@ const repoOnboardingPackShape = { refresh: z.boolean().optional(), }; +// #7753: mirrors the remote loopover_propose_action input (src/mcp/server.ts's proposeActionShape) so the local +// stdio tool validates identically. actionClass reuses PROPOSE_ACTION_CLASSES (same enum the route + +// `maintain propose` accept); the optional fields carry per-action-class detail and are stripped when absent. +const proposeActionShape = { + owner: z.string().min(1), + repo: z.string().min(1), + pullNumber: z.number().int().positive(), + actionClass: z.enum(PROPOSE_ACTION_CLASSES), + reason: z.string().max(500).optional(), + label: z.string().min(1).max(100).optional(), + reviewBody: z.string().max(60000).optional(), + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), + closeComment: z.string().max(60000).optional(), +}; + const skippedPrAuditShape = { repoFullName: z.string().trim().min(1).max(200).optional(), reason: z.string().trim().min(1).max(64).optional(), @@ -1492,6 +1507,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "agent", 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.", }, + { + name: "loopover_propose_action", + category: "agent", + description: + "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.", + }, { name: "loopover_decide_pending_action", category: "agent", @@ -3014,6 +3035,24 @@ registerStdioTool( }, ); +// #7753: stdio mirror of the remote loopover_propose_action + the `maintain propose` CLI. 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 approved. +registerStdioTool( + "loopover_propose_action", + { + description: stdioToolDescription("loopover_propose_action"), + inputSchema: proposeActionShape, + }, + async ({ owner, repo, pullNumber, actionClass, reason, label, reviewBody, mergeMethod, closeComment }: any) => { + const payload = await apiPost( + `${toolRepoBase(owner, repo)}/agent/pending-actions`, + stripUndefined({ pullNumber, actionClass, reason, label, reviewBody, mergeMethod, closeComment }), + ); + return toolResult(`Staged ${actionClass} on ${owner}/${repo}#${pullNumber} into the approval queue.`, payload); + }, +); + registerStdioTool( "loopover_decide_pending_action", { diff --git a/test/unit/mcp-cli-propose-action.test.ts b/test/unit/mcp-cli-propose-action.test.ts new file mode 100644 index 0000000000..1a4eb2acd5 --- /dev/null +++ b/test/unit/mcp-cli-propose-action.test.ts @@ -0,0 +1,79 @@ +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"; + +// #7753: in-process coverage for the loopover_propose_action stdio tool. Same #7764 entrypoint-guard pattern as +// mcp-cli-repo-focus-manifest -- import the .ts, hold the exported `server`, connect an InMemoryTransport so +// v8/Codecov attributes the registerStdioTool block (a subprocess spawn CANNOT be instrumented -- earlier +// subprocess-only attempts at this exact tool were closed for 0% patch coverage). +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const proposeCalls: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-propose-action-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (r) => { + if (r.method === "POST" && r.url && r.url.includes("/agent/pending-actions")) proposeCalls.push({ url: r.url ?? "", method: r.method ?? "" }); + }, + }); + 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_propose_action stdio tool (in-process, #7753)", () => { + it.each(MODULES)("stages an action via POST .../agent/pending-actions, forwarding the body — %s", async (specifier) => { + proposeCalls.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "propose-action-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const tool = (await client.listTools()).tools.find((entry) => entry.name === "loopover_propose_action"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/approval queue|NOT executed until approved/i); + + const result = await client.callTool({ + name: "loopover_propose_action", + arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "label", reason: "needs triage", label: "bug" }, + }); + expect(result.isError).toBeFalsy(); + expect(proposeCalls).toEqual([{ url: "/v1/repos/owner/repo/agent/pending-actions", method: "POST" }]); + // The fixture echoes the posted actionClass/pullNumber/reason, proving the body was serialized + forwarded. + const data = result.structuredContent as { created?: boolean; action?: { actionClass?: string; pullNumber?: number; reason?: string } }; + expect(data.created).toBe(true); + expect(data.action?.actionClass).toBe("label"); + expect(data.action?.pullNumber).toBe(7); + expect(data.action?.reason).toBe("needs triage"); + expect(JSON.stringify(result)).toContain("Staged label on owner/repo#7 into the approval queue."); + } 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 bdbff8def7..34265262fc 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -41,6 +41,7 @@ // (#7754 registered the loopover_refresh_repo_docs stdio tool, taking the count from 96 to 97.) // (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 97 to 98.) // (#7755 registered the loopover_generate_contributor_issue_drafts stdio tool, taking the count from 98 to 99.) +// (#7753 registered the loopover_propose_action stdio tool, taking the count from 99 to 100.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -87,14 +88,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 99 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 100 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(99); + expect(primary.length).toBe(100); expect(legacy.length).toBe(0); - expect(names.length).toBe(99); + expect(names.length).toBe(100); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -106,14 +107,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 99-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 100-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(99); + expect(payload.count).toBe(100); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), );