diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 2f8e61d3e8..0ea9b9b5ac 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -10,6 +10,7 @@ import { buildFeasibilityVerdict, buildPrTextLint } from "@loopover/engine"; // registering them locally is just importing the same engine builders the remote server uses. import { buildApplyLabelsSpec, + buildClosePrSpec, buildCreateBranchSpec, buildDeleteBranchSpec, buildFileIssueSpec, @@ -362,6 +363,11 @@ const applyLabelsShape = { number: z.number().int().positive(), labels: z.array(z.string().min(1).max(100)).min(1).max(20), }; +const closePrShape = { + repoFullName: writeToolRepoFullName, + number: z.number().int().positive(), + comment: z.string().max(WRITE_TOOL_BODY_MAX).optional(), +}; const postEligibilityCommentShape = { repoFullName: writeToolRepoFullName, number: z.number().int().positive(), @@ -1094,6 +1100,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Build a LOCAL-execution spec to file a follow-up issue for a review finding a maintainer wants TRACKED rather than blocked on this PR. Composes a bounded, public-safe title/body from the finding (run it with your own gh creds; loopover never performs the write).", }, + { + name: "loopover_close_pr", + category: "agent", + description: + "Build a LOCAL-execution spec to close a pull request, optionally with a comment (run it with your own gh creds; loopover never performs the write).", + }, ]; // #6301 — coarse tool categories for grouping `loopover-mcp tools` output. Ordered @@ -2165,6 +2177,15 @@ registerStdioTool( (input) => localWriteSpecResult(buildFollowUpIssueSpec(input)), ); +registerStdioTool( + "loopover_close_pr", + { + description: stdioToolDescription("loopover_close_pr"), + inputSchema: closePrShape, + }, + (input) => localWriteSpecResult(buildClosePrSpec(input)), +); + // ── Resources: decision-pack, doctor, compatibility, changelog (#292) ───────── server.registerResource( diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2367d87aa3..127f2c23ba 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -146,6 +146,7 @@ import { computeLocalScorerTokens } from "../signals/local-scorer"; import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; import { buildApplyLabelsSpec, + buildClosePrSpec, buildCreateBranchSpec, buildDeleteBranchSpec, buildFileIssueSpec, @@ -422,6 +423,11 @@ const applyLabelsShape = { number: z.number().int().positive(), labels: z.array(z.string().min(1).max(100)).min(1).max(20), }; +const closePrShape = { + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + number: z.number().int().positive(), + comment: z.string().max(WRITE_TOOL_BODY_MAX).optional(), +}; const postEligibilityCommentShape = { repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), number: z.number().int().positive(), @@ -1772,6 +1778,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_delete_branch: "agent", loopover_generate_tests: "agent", loopover_file_follow_up_issue: "agent", + loopover_close_pr: "agent", loopover_build_plan: "agent", loopover_plan_status: "agent", loopover_record_step_result: "agent", @@ -2429,6 +2436,11 @@ export class LoopoverMcp { }, async (input) => this.toolResult(this.localWriteSpec(buildFollowUpIssueSpec(input))), ); + register( + "loopover_close_pr", + { description: "Build a LOCAL-execution spec to close a pull request, optionally with a comment (run it with your own gh creds; loopover never performs the write).", inputSchema: closePrShape, outputSchema: localWriteActionOutputSchema }, + async (input) => this.toolResult(this.localWriteSpec(buildClosePrSpec(input))), + ); // #783 multi-step plan DAG — stateless: pass the plan back each call. register( diff --git a/test/unit/mcp-cli-write-tools.test.ts b/test/unit/mcp-cli-write-tools.test.ts index 0de741ec63..a152b5b942 100644 --- a/test/unit/mcp-cli-write-tools.test.ts +++ b/test/unit/mcp-cli-write-tools.test.ts @@ -40,6 +40,7 @@ const WRITE_TOOLS = [ "loopover_delete_branch", "loopover_generate_tests", "loopover_file_follow_up_issue", + "loopover_close_pr", ]; function spec(result: unknown): { action: string; command: string; boundary: string } { @@ -47,7 +48,7 @@ function spec(result: unknown): { action: string; command: string; boundary: str } describe("loopover-mcp write-tools (#6149)", () => { - it("registers all 8 write-tools on the local stdio server", async () => { + it("registers all 9 write-tools on the local stdio server", async () => { const names = new Set((await client.listTools()).tools.map((t) => t.name)); for (const name of WRITE_TOOLS) expect(names, `missing ${name}`).toContain(name); }); @@ -86,6 +87,25 @@ describe("loopover-mcp write-tools (#6149)", () => { expect(spec(result).command).toContain("--add-label"); }); + it("loopover_close_pr composes a gh pr close spec, optionally with a follow-up comment", async () => { + const noComment = await client.callTool({ + name: "loopover_close_pr", + arguments: { repoFullName: "acme/widgets", number: 7 }, + }); + expect(noComment.isError).toBeFalsy(); + expect(spec(noComment).action).toBe("close_pr"); + expect(spec(noComment).command).toBe("gh pr close 7 --repo 'acme/widgets'"); + + const withComment = await client.callTool({ + name: "loopover_close_pr", + arguments: { repoFullName: "acme/widgets", number: 7, comment: "superseded by a fresh PR" }, + }); + expect(withComment.isError).toBeFalsy(); + expect(spec(withComment).command).toBe( + "gh pr close 7 --repo 'acme/widgets' && gh pr comment 7 --repo 'acme/widgets' --body 'superseded by a fresh PR'", + ); + }); + it("loopover_post_eligibility_comment composes a gh issue comment spec", async () => { const result = await client.callTool({ name: "loopover_post_eligibility_comment", diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index fa1194db8f..7c71330c8b 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -7,6 +7,7 @@ // (#6150 registered the local-scorer and plan-DAG/predict-gate tools, taking the count from 55 to 60.) // (#6619 registered the pr-ai-review-findings CLI mirror, taking the count from 60 to 61.) // (#6621 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 61 to 62.) +// (#6615 registered the loopover_close_pr write-tool — 9th of the 9 buildXSpec builders — taking the count from 62 to 63.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -50,14 +51,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 62 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 63 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(62); + expect(primary.length).toBe(63); expect(legacy.length).toBe(0); - expect(names.length).toBe(62); + expect(names.length).toBe(63); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -67,11 +68,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 62-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 63-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(62); + expect(payload.count).toBe(63); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/mcp-write-tools.test.ts b/test/unit/mcp-write-tools.test.ts index 0cbb28bf03..60c4809252 100644 --- a/test/unit/mcp-write-tools.test.ts +++ b/test/unit/mcp-write-tools.test.ts @@ -38,6 +38,12 @@ describe("MCP miner write-tools (#780)", () => { { name: "loopover_post_eligibility_comment", args: { repoFullName: "o/r", number: 7, body: "hi" }, expect: "gh issue comment 7 --repo 'o/r' --body 'hi'" }, { name: "loopover_create_branch", args: { branch: "feat/x", base: "main" }, expect: "git switch -c 'feat/x' 'main'" }, { name: "loopover_delete_branch", args: { branch: "feat/x", remote: true }, expect: "git branch -D 'feat/x' && git push origin --delete 'feat/x'" }, + { name: "loopover_close_pr", args: { repoFullName: "o/r", number: 7 }, expect: "gh pr close 7 --repo 'o/r'" }, + { + name: "loopover_close_pr", + args: { repoFullName: "o/r", number: 7, comment: "dup" }, + expect: "gh pr close 7 --repo 'o/r' && gh pr comment 7 --repo 'o/r' --body 'dup'", + }, ]; for (const testCase of cases) { const result = await client.callTool({ name: testCase.name, arguments: testCase.args });