Skip to content
Closed
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 @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
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 @@ -1765,6 +1771,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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 },
Expand Down
12 changes: 12 additions & 0 deletions test/unit/mcp-cli-write-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
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
2 changes: 2 additions & 0 deletions test/unit/mcp-write-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ describe("MCP miner write-tools (#780)", () => {
const cases: Array<{ name: string; args: Record<string, unknown>; 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'" },
Expand Down
Loading