Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ import { computeLocalScorerTokens } from "../signals/local-scorer";
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
import {
buildApplyLabelsSpec,
buildClosePrSpec,
buildCreateBranchSpec,
buildDeleteBranchSpec,
buildFileIssueSpec,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1772,6 +1778,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 21 additions & 1 deletion test/unit/mcp-cli-write-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@ 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 } {
return (result as { structuredContent?: unknown }).structuredContent as { action: string; command: string; boundary: string };
}

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);
});
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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());
});
});
Expand Down
6 changes: 6 additions & 0 deletions test/unit/mcp-write-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down