diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 57153d64aa..c7a96ebfad 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, @@ -364,6 +365,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(), @@ -1057,6 +1063,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Build a LOCAL-execution spec to open a pull request from your branch (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 PR, optionally with a comment (run it with your own gh creds; loopover never performs the write).", + }, { name: "loopover_file_issue", category: "agent", @@ -2104,6 +2116,15 @@ registerStdioTool( (input) => localWriteSpecResult(buildOpenPrSpec(input)), ); +registerStdioTool( + "loopover_close_pr", + { + description: stdioToolDescription("loopover_close_pr"), + inputSchema: closePrShape, + }, + (input) => localWriteSpecResult(buildClosePrSpec(input)), +); + registerStdioTool( "loopover_file_issue", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2367d87aa3..ad1718056f 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(), @@ -1765,6 +1771,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_eligibility_plan: "discovery", loopover_run_local_scorer: "branch", loopover_open_pr: "agent", + loopover_close_pr: "agent", loopover_file_issue: "agent", loopover_apply_labels: "agent", loopover_post_eligibility_comment: "agent", @@ -2389,6 +2396,11 @@ export class LoopoverMcp { { description: "Build a LOCAL-execution spec to file an issue (run it with your own gh creds; loopover never performs the write).", inputSchema: fileIssueShape, outputSchema: localWriteActionOutputSchema }, async (input) => this.toolResult(this.localWriteSpec(buildFileIssueSpec(input))), ); + register( + "loopover_close_pr", + { description: "Build a LOCAL-execution spec to close a PR, 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))), + ); register( "loopover_apply_labels", { description: "Build a LOCAL-execution spec to add labels to an issue or PR (run it with your own gh creds; loopover never performs the write).", inputSchema: applyLabelsShape, outputSchema: localWriteActionOutputSchema }, diff --git a/test/unit/mcp-cli-write-tools.test.ts b/test/unit/mcp-cli-write-tools.test.ts index 0de741ec63..6b70175c02 100644 --- a/test/unit/mcp-cli-write-tools.test.ts +++ b/test/unit/mcp-cli-write-tools.test.ts @@ -33,6 +33,7 @@ afterEach(async () => { const WRITE_TOOLS = [ "loopover_open_pr", + "loopover_close_pr", "loopover_file_issue", "loopover_apply_labels", "loopover_post_eligibility_comment", @@ -86,6 +87,17 @@ 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 comment", async () => { + const plain = await client.callTool({ name: "loopover_close_pr", arguments: { repoFullName: "acme/widgets", number: 7 } }); + expect(plain.isError).toBeFalsy(); + expect(spec(plain).action).toBe("close_pr"); + expect(spec(plain).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" } }); + 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'"); + }); + 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..870bfeee38 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -6,7 +6,8 @@ // (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.) // (#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.) +// (#6631 registered the loopover_get_eligibility_plan REST/CLI mirror, taking the count from 61 to 62.) +// (#6615 registered the loopover_close_pr write-tool, 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..d57c9c198f 100644 --- a/test/unit/mcp-write-tools.test.ts +++ b/test/unit/mcp-write-tools.test.ts @@ -35,6 +35,8 @@ describe("MCP miner write-tools (#780)", () => { const cases: Array<{ name: string; args: Record; expect: string }> = [ { name: "loopover_file_issue", args: { repoFullName: "o/r", title: "T", body: "B", labels: ["bug"] }, expect: "gh issue create --repo 'o/r' --title 'T' --body 'B' --label 'bug'" }, { name: "loopover_apply_labels", args: { repoFullName: "o/r", number: 7, labels: ["x"] }, expect: "gh issue edit 7 --repo 'o/r' --add-label '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'" }, { 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'" },