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
39 changes: 39 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 is the same superset enum the route + `maintain propose` accept
// (PROPOSE_ACTION_CLASSES); 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(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label"]),
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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
79 changes: 79 additions & 0 deletions test/unit/mcp-cli-propose-action.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
};

let tempDir = "";
const proposeCalls: Array<{ url: string; method: string }> = [];
const loaded = new Map<string, BinModule>();

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);
}
});
});
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 @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down