Skip to content

Commit aa83a48

Browse files
feat(mcp): register the maintain REST surface as local stdio tools (#6382)
The maintain CLI subcommands call real REST endpoints that the remote server has exposed as tools since #6087, but the local stdio server registered none of them. An agent on the local server had to shell out to the CLI to list the approval queue, decide a staged action, toggle the kill-switch, set an autonomy level, or read gate precision. Register the five missing counterparts: loopover_list_pending_actions, loopover_decide_pending_action, loopover_set_agent_paused, loopover_set_action_autonomy, and loopover_get_gate_precision. Each calls the endpoint its CLI subcommand already calls, through the same apiGet/apiPost/apiFetch client, so auth, timeouts, and error shaping come from there rather than a second HTTP path. Shapes mirror the remote's, and categories mirror the remote's MCP_TOOL_CATEGORIES entries for the same names, so one caller sees one surface. The remote server is untouched. set_action_autonomy read-merge-writes like `maintain set-level` does: PUT /settings replaces the whole autonomy map, so sending one class alone would silently clear the others. A test pins that the other classes survive, and that the write is a GET-then-PUT. One deliberate divergence from the remote: its list_pending_actions takes an optional `status`, which it can honour because it queries the queue store directly. This server has only GET /agent/pending-actions, which takes no query parameters and hardcodes status "pending" (src/api/routes.ts). Offering the filter here would let a caller ask for "rejected", receive the pending list, and be told it succeeded -- so the schema omits it and the description names the queue it really returns. Tests cover success and an API-failure path for each tool, plus the read-merge-write invariant and pre-flight rejection of unknown action classes and autonomy levels. The alias-retirement suite pins the exact registered-tool count; these five take it from 42 to 47. Closes #6152 Co-authored-by: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com>
1 parent 6c539bd commit aa83a48

3 files changed

Lines changed: 300 additions & 6 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,52 @@ const agentRunIdShape = {
457457
runId: z.string().min(1),
458458
};
459459

460+
// #6152 maintain-surface tools. Each shape mirrors its already-shipped remote counterpart in src/mcp/server.ts
461+
// (listPendingActionsShape, decidePendingActionShape, setAgentPausedShape, setActionAutonomyShape,
462+
// ownerRepoWindowShape) so the same call works against either server. The `decision` verb is accept|reject --
463+
// the approval-queue route's own vocabulary (#779) -- rather than the maintain CLI's approve|reject, because a
464+
// tool caller is talking to the route, not to the CLI's surface.
465+
//
466+
// One deliberate divergence: the remote's listPendingActionsShape takes an optional `status`, which it can honour
467+
// because it queries the approval-queue store directly. This server reaches the queue only through
468+
// GET /v1/repos/:owner/:repo/agent/pending-actions, which takes no query parameters and hardcodes status
469+
// "pending" (src/api/routes.ts). Offering a `status` here would let a caller ask for "rejected", get the pending
470+
// list, and be told it succeeded -- so it is left out of the schema and the description names the queue as the
471+
// pending one. An agent picks its arguments from the published schema, so a filter that isn't there is one it
472+
// won't ask for; a key sent anyway is dropped by the MCP layer before this handler and never reaches the URL.
473+
const listPendingActionsShape = {
474+
owner: z.string().min(1),
475+
repo: z.string().min(1),
476+
};
477+
478+
const decidePendingActionShape = {
479+
owner: z.string().min(1),
480+
repo: z.string().min(1),
481+
id: z.string().min(1),
482+
decision: z.enum(["accept", "reject"]),
483+
};
484+
485+
const setAgentPausedShape = {
486+
owner: z.string().min(1),
487+
repo: z.string().min(1),
488+
paused: z.boolean(),
489+
};
490+
491+
// Reuses the CLI's own constants, so `maintain set-level`'s validation and this tool's schema can never disagree
492+
// about what the server accepts.
493+
const setActionAutonomyShape = {
494+
owner: z.string().min(1),
495+
repo: z.string().min(1),
496+
action: z.enum(MAINTAIN_ACTION_CLASSES),
497+
level: z.enum(MAINTAIN_AUTONOMY_LEVELS),
498+
};
499+
500+
const gatePrecisionShape = {
501+
owner: z.string().min(1),
502+
repo: z.string().min(1),
503+
windowDays: z.number().int().positive().optional(),
504+
};
505+
460506
// Single source of truth for stdio tool name + one-line description (#2233).
461507
// Registration and `loopover-mcp tools` both read this list.
462508
const STDIO_TOOL_DESCRIPTORS = [
@@ -672,6 +718,34 @@ const STDIO_TOOL_DESCRIPTORS = [
672718
category: "maintainer",
673719
description: "Return the skipped-PR audit trail: pull requests LoopOver's automated reviewer intentionally stayed quiet on, each with a reason code and a remediation hint. Optionally filter by repoFullName, reason, or since. Maintainer-authenticated; read-only measurement, not a moderation or override action.",
674720
},
721+
// #6152 — the maintain CLI's REST surface, exposed as tools so an agent can drive it without shelling out.
722+
// Categories mirror the remote server's MCP_TOOL_CATEGORIES entries for the same names, so a caller sees one
723+
// consistent grouping across both surfaces.
724+
{
725+
name: "loopover_list_pending_actions",
726+
category: "agent",
727+
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.",
728+
},
729+
{
730+
name: "loopover_decide_pending_action",
731+
category: "agent",
732+
description: "Accept (execute) or reject a staged approval-queue action by id. Accept runs it through the live executor gates; reject cancels it. Scoped to this repo, same as `loopover-mcp maintain approve|reject <id>`. Maintainer access required.",
733+
},
734+
{
735+
name: "loopover_set_agent_paused",
736+
category: "agent",
737+
description: "Pause or resume ALL agent actions on a repo (the kill-switch toggle), same as `loopover-mcp maintain pause|resume`. Maintainer access required.",
738+
},
739+
{
740+
name: "loopover_set_action_autonomy",
741+
category: "agent",
742+
description: "Set the autonomy level for one action class via a read-merge-write, so the other classes are left untouched. Same as `loopover-mcp maintain set-level <action> <level>`. Maintainer access required.",
743+
},
744+
{
745+
name: "loopover_get_gate_precision",
746+
category: "maintainer",
747+
description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.",
748+
},
675749
];
676750

677751
// #6301 — coarse tool categories for grouping `loopover-mcp tools` output. Ordered
@@ -1444,6 +1518,86 @@ registerStdioTool(
14441518
},
14451519
);
14461520

1521+
// ── #6152 maintain surface: the REST calls maintainCli already makes, exposed as tools ───────────────────────
1522+
//
1523+
// These five mirror remote tools that have existed since #6087 but were never registered locally, so an agent on
1524+
// the stdio server had to shell out to the `maintain` CLI to reach them. Each one calls the same endpoint its
1525+
// CLI subcommand calls, through the same apiGet/apiPost/apiFetch client (auth, timeouts, and error shaping come
1526+
// from there) -- no new HTTP paths, and no behaviour the CLI doesn't already have.
1527+
1528+
/** `/v1/repos/:owner/:repo` for a tool's owner+repo input, matching maintainCli's own repoBase. */
1529+
function toolRepoBase(owner, repo) {
1530+
return `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
1531+
}
1532+
1533+
registerStdioTool(
1534+
"loopover_list_pending_actions",
1535+
{
1536+
description: stdioToolDescription("loopover_list_pending_actions"),
1537+
inputSchema: listPendingActionsShape,
1538+
},
1539+
async ({ owner, repo }) => {
1540+
const payload = await apiGet(`${toolRepoBase(owner, repo)}/agent/pending-actions`);
1541+
return toolResult(`Agent approval queue for ${owner}/${repo}: ${(payload.pendingActions ?? []).length} pending.`, payload);
1542+
},
1543+
);
1544+
1545+
registerStdioTool(
1546+
"loopover_decide_pending_action",
1547+
{
1548+
description: stdioToolDescription("loopover_decide_pending_action"),
1549+
inputSchema: decidePendingActionShape,
1550+
},
1551+
async ({ owner, repo, id, decision }) => {
1552+
const payload = await apiPost(`${toolRepoBase(owner, repo)}/agent/pending-actions/${encodeURIComponent(id)}/${decision}`, {});
1553+
return toolResult(`${decision === "accept" ? "Accepted" : "Rejected"} ${id}: ${payload.status ?? "ok"}.`, payload);
1554+
},
1555+
);
1556+
1557+
registerStdioTool(
1558+
"loopover_set_agent_paused",
1559+
{
1560+
description: stdioToolDescription("loopover_set_agent_paused"),
1561+
inputSchema: setAgentPausedShape,
1562+
},
1563+
async ({ owner, repo, paused }) => {
1564+
const payload = await apiFetch(`${toolRepoBase(owner, repo)}/settings`, { method: "PUT", body: JSON.stringify({ agentPaused: paused }) });
1565+
return toolResult(`Agent actions ${paused ? "paused" : "resumed"} for ${owner}/${repo}.`, payload);
1566+
},
1567+
);
1568+
1569+
registerStdioTool(
1570+
"loopover_set_action_autonomy",
1571+
{
1572+
description: stdioToolDescription("loopover_set_action_autonomy"),
1573+
inputSchema: setActionAutonomyShape,
1574+
},
1575+
async ({ owner, repo, action, level }) => {
1576+
// Read-merge-write, exactly as `maintain set-level` does it: PUT /settings replaces the whole autonomy map,
1577+
// so sending only this class would silently clear every other one.
1578+
const base = toolRepoBase(owner, repo);
1579+
const current = await apiGet(`${base}/settings`);
1580+
const autonomy = { ...(current.autonomy ?? {}), [action]: level };
1581+
const payload = await apiFetch(`${base}/settings`, { method: "PUT", body: JSON.stringify({ autonomy }) });
1582+
return toolResult(`Set ${action} autonomy to ${level} for ${owner}/${repo}.`, payload);
1583+
},
1584+
);
1585+
1586+
registerStdioTool(
1587+
"loopover_get_gate_precision",
1588+
{
1589+
description: stdioToolDescription("loopover_get_gate_precision"),
1590+
inputSchema: gatePrecisionShape,
1591+
},
1592+
async ({ owner, repo, windowDays }) => {
1593+
// The schema already rejects a non-positive windowDays, so an omitted window is the only way to full history
1594+
// -- matching the route's own behaviour when ?windowDays is absent.
1595+
const query = windowDays ? `?windowDays=${encodeURIComponent(windowDays)}` : "";
1596+
const payload = await apiGet(`${toolRepoBase(owner, repo)}/gate-precision${query}`);
1597+
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
1598+
},
1599+
);
1600+
14471601
// ── Resources: decision-pack, doctor, compatibility, changelog (#292) ─────────
14481602

14491603
server.registerResource(
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterEach, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, run, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
10+
11+
// #6152: the maintain CLI's REST surface, exposed as stdio tools. These assert the proxy contract -- that each
12+
// tool reaches the endpoint its CLI subcommand already calls, with the same method and body -- rather than
13+
// re-testing the endpoints themselves, which test/unit/mcp-cli-maintain.test.ts already covers via the CLI.
14+
let client: Client | null = null;
15+
let transport: StdioClientTransport | null = null;
16+
let configDir: string | null = null;
17+
let capturedRequests: Array<{ url: string; method: string }>;
18+
19+
async function connect() {
20+
configDir = mkdtempSync(join(tmpdir(), "loopover-maintain-tools-"));
21+
capturedRequests = [];
22+
const apiUrl = await startFixtureServer({
23+
onApiRequest: (request) => {
24+
const url = request.url ?? "";
25+
if (/pending-actions|settings|gate-precision/.test(url)) capturedRequests.push({ url, method: request.method ?? "GET" });
26+
},
27+
});
28+
transport = new StdioClientTransport({
29+
command: "node",
30+
args: [bin, "--stdio"],
31+
env: {
32+
...process.env,
33+
LOOPOVER_CONFIG_DIR: configDir,
34+
LOOPOVER_API_URL: apiUrl,
35+
LOOPOVER_TOKEN: "session-token",
36+
LOOPOVER_API_TIMEOUT_MS: "5000",
37+
},
38+
});
39+
client = new Client({ name: "maintain-tools-test", version: "0.0.1" });
40+
await client.connect(transport);
41+
}
42+
43+
afterEach(async () => {
44+
await client?.close().catch(() => undefined);
45+
client = null;
46+
transport = null;
47+
await closeFixtureServer();
48+
if (configDir) rmSync(configDir, { recursive: true, force: true });
49+
configDir = null;
50+
});
51+
52+
const REPO = { owner: "owner", repo: "repo" };
53+
54+
/** Every #6152 tool, with an argument set the fixture serves and a field its real payload carries. */
55+
const MAINTAIN_TOOLS = [
56+
{ name: "loopover_list_pending_actions", args: REPO, contains: "pa-1" },
57+
{ name: "loopover_decide_pending_action", args: { ...REPO, id: "pa-1", decision: "accept" }, contains: "accepted" },
58+
{ name: "loopover_set_agent_paused", args: { ...REPO, paused: true }, contains: "agentPaused" },
59+
{ name: "loopover_set_action_autonomy", args: { ...REPO, action: "merge", level: "auto" }, contains: "autonomy" },
60+
{ name: "loopover_get_gate_precision", args: REPO, contains: "falsePositiveRate" },
61+
] as const;
62+
63+
describe("loopover-mcp maintain stdio proxies (#6152)", () => {
64+
it("registers all 5 maintain tools in the stdio server tool list", async () => {
65+
await connect();
66+
const names = (await client!.listTools()).tools.map((tool) => tool.name);
67+
for (const tool of MAINTAIN_TOOLS) expect(names).toContain(tool.name);
68+
});
69+
70+
it("lists all 5 maintain tools via `loopover-mcp tools --json` with non-empty descriptions", async () => {
71+
await connect();
72+
const payload = JSON.parse(run(["tools", "--json"])) as { tools: Array<{ name: string; description: string; category?: string }> };
73+
for (const tool of MAINTAIN_TOOLS) {
74+
const entry = payload.tools.find((t) => t.name === tool.name);
75+
expect(entry, `missing descriptor for ${tool.name}`).toBeTruthy();
76+
expect(entry!.description.trim().length).toBeGreaterThan(0);
77+
}
78+
});
79+
80+
for (const tool of MAINTAIN_TOOLS) {
81+
it(`${tool.name} proxies to its REST endpoint and returns the payload`, async () => {
82+
await connect();
83+
const result = await client!.callTool({ name: tool.name, arguments: { ...tool.args } });
84+
expect(result.isError).toBeFalsy();
85+
expect(JSON.stringify(result)).toContain(tool.contains);
86+
expect(capturedRequests.length).toBeGreaterThan(0);
87+
for (const request of capturedRequests) expect(request.url).toContain("/v1/repos/owner/repo/");
88+
});
89+
90+
// The fixture serves owner/repo only and 404s anything else, so an unregistered repo exercises the same
91+
// failure path a real caller hits without maintainer access to the target: an API error, surfaced as a tool
92+
// error rather than a silent empty success.
93+
it(`${tool.name} surfaces an API failure as a tool error`, async () => {
94+
await connect();
95+
const result = await client!.callTool({ name: tool.name, arguments: { ...tool.args, owner: "nobody", repo: "missing" } });
96+
expect(result.isError).toBe(true);
97+
expect(JSON.stringify(result.content)).toMatch(/404|not_found/);
98+
});
99+
}
100+
101+
// GET /agent/pending-actions takes no query parameters and hardcodes status "pending" (src/api/routes.ts), so
102+
// this server cannot honour the `status` filter its remote counterpart offers. The tool therefore doesn't
103+
// advertise one: an agent reads the published schema to decide what to send, so a filter absent from the schema
104+
// is a filter it won't ask for -- and can't be told "ok" about. (An unknown key sent anyway is dropped by the
105+
// MCP layer before the handler, so it can never reach the URL either.)
106+
it("list_pending_actions advertises no status filter, which this server's route could not honour", async () => {
107+
await connect();
108+
const tool = (await client!.listTools()).tools.find((entry) => entry.name === "loopover_list_pending_actions");
109+
expect(tool, "loopover_list_pending_actions is not registered").toBeTruthy();
110+
expect(Object.keys(tool!.inputSchema.properties ?? {}).sort()).toEqual(["owner", "repo"]);
111+
112+
const result = await client!.callTool({ name: "loopover_list_pending_actions", arguments: { ...REPO, status: "rejected" } });
113+
expect(result.isError).toBeFalsy();
114+
for (const request of capturedRequests) expect(request.url).not.toContain("status=");
115+
});
116+
117+
it("set_action_autonomy read-merge-writes so the other action classes survive", async () => {
118+
await connect();
119+
const result = await client!.callTool({ name: "loopover_set_action_autonomy", arguments: { ...REPO, action: "merge", level: "auto" } });
120+
expect(result.isError).toBeFalsy();
121+
// The fixture's stored autonomy is { label: "auto" }; a blind PUT of just `merge` would drop it.
122+
const payload = JSON.stringify(result);
123+
expect(payload).toContain("label");
124+
expect(payload).toContain("merge");
125+
expect(capturedRequests.map((request) => request.method)).toEqual(["GET", "PUT"]);
126+
});
127+
128+
it("rejects an unknown action class and an unknown autonomy level before any API call", async () => {
129+
await connect();
130+
for (const args of [
131+
{ ...REPO, action: "bogus", level: "auto" },
132+
{ ...REPO, action: "merge", level: "bogus" },
133+
]) {
134+
const result = await client!.callTool({ name: "loopover_set_action_autonomy", arguments: args });
135+
expect(result.isError).toBe(true);
136+
}
137+
expect(capturedRequests).toEqual([]);
138+
});
139+
});

test/unit/mcp-tool-rename-aliases.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// #4777: retire every gittensory_-prefixed deprecated alias that #4775 left in place for one
2-
// minor-version deprecation cycle. This suite pins the post-retirement shape: exactly the 42
2+
// minor-version deprecation cycle. This suite pins the post-retirement shape: exactly the 47
33
// canonical loopover_-prefixed stdio tools are registered, none of their old gittensory_-prefixed
44
// alias names resolve anymore, no description carries a stale deprecation notice, and the CLI's
55
// `tools --json` listing stays in lockstep with what the live server actually registers.
6+
// (#6152 registered the 5 maintain-surface tools, taking the count from 42 to 47.)
67
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
78
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
89
import { mkdtempSync, rmSync } from "node:fs";
@@ -46,14 +47,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
4647
});
4748
afterEach(disconnect);
4849

49-
it("lists exactly 42 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
50+
it("lists exactly 47 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
5051
const { tools } = await client.listTools();
5152
const names = tools.map((t) => t.name);
5253
const primary = names.filter((n) => n.startsWith("loopover_"));
5354
const legacy = names.filter((n) => n.startsWith("gittensory_"));
54-
expect(primary.length).toBe(42);
55+
expect(primary.length).toBe(47);
5556
expect(legacy.length).toBe(0);
56-
expect(names.length).toBe(42);
57+
expect(names.length).toBe(47);
5758
});
5859

5960
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -63,11 +64,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
6364
}
6465
});
6566

66-
it("`loopover-mcp tools --json` reports the same 42-tool count the live server registers", async () => {
67+
it("`loopover-mcp tools --json` reports the same 47-tool count the live server registers", async () => {
6768
const { tools } = await client.listTools();
6869
const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> };
6970
expect(payload.count).toBe(tools.length);
70-
expect(payload.count).toBe(42);
71+
expect(payload.count).toBe(47);
7172
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
7273
});
7374
});

0 commit comments

Comments
 (0)