Skip to content

Commit eb7cd9c

Browse files
fix(mcp): mirror loopover_clear_selftune_override in the CLI stdio package (#9300)
Add the write-side sibling of loopover_get_selftune_override_audit so CLI/stdio users can DELETE a live self-tune override with confirm:true, matching the remote MCP tool. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d158034 commit eb7cd9c

4 files changed

Lines changed: 164 additions & 5 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,14 @@ const selftuneOverrideAuditShape = {
10701070
limit: z.number().int().positive().optional(),
10711071
};
10721072

1073+
// #9300: write-side sibling of selftuneOverrideAuditShape — mirrors src/mcp/server.ts's
1074+
// clearSelftuneOverrideShape (`confirm` must be the literal true; omitted/false is schema-rejected).
1075+
const clearSelftuneOverrideShape = {
1076+
owner: z.string().min(1),
1077+
repo: z.string().min(1),
1078+
confirm: z.literal(true),
1079+
};
1080+
10731081
// #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape),
10741082
// minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose --
10751083
// forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer
@@ -1583,6 +1591,12 @@ const STDIO_TOOL_DESCRIPTORS = [
15831591
description:
15841592
"Return the self-tune override audit trail for a repo — why the self-tune loop promoted, shadowed, or cleared a live gate override, newest first. Optionally capped by limit. Maintainer-authenticated; read-only measurement.",
15851593
},
1594+
{
1595+
name: "loopover_clear_selftune_override",
1596+
category: "maintainer",
1597+
description:
1598+
"Clear a repo's LIVE self-tune gate override (the operator's \"reset to config base\" control), mirroring DELETE /v1/repos/:owner/:repo/selftune/overrides. Requires confirm:true; the automatic self-tune promote path is untouched. Maintainer access required.",
1599+
},
15861600
{
15871601
name: "loopover_get_automation_state",
15881602
category: "agent",
@@ -3192,6 +3206,20 @@ registerStdioTool(
31923206
},
31933207
);
31943208

3209+
// #9300: write-side sibling of loopover_get_selftune_override_audit — DELETE {repoBase}/selftune/overrides
3210+
// with confirm:true (schema-enforced; never silently defaulted). Same apiDelete helper the unwatch action uses.
3211+
registerStdioTool(
3212+
"loopover_clear_selftune_override",
3213+
{
3214+
description: stdioToolDescription("loopover_clear_selftune_override"),
3215+
inputSchema: clearSelftuneOverrideShape,
3216+
},
3217+
async ({ owner, repo, confirm }: any) => {
3218+
const payload = await apiDelete(`${toolRepoBase(owner, repo)}/selftune/overrides`, { confirm });
3219+
return toolResult(`Cleared the live self-tune gate override for ${owner}/${repo}.`, payload);
3220+
},
3221+
);
3222+
31953223
// #7752: read-side counterpart to the pause/resume/set-level write tools above. Proxies the same
31963224
// GET {repoBase}/automation-state the `maintain automation-state` CLI already calls — no duplicated HTTP path.
31973225
// Summary is intentionally branch-free (no ?? / ?. / ternaries) so codecov/patch stays at 100%; the full
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #9300: in-process coverage for loopover_clear_selftune_override in packages/loopover-mcp/bin/loopover-mcp.ts.
10+
// Same entrypoint-guard pattern as mcp-cli-selftune-audit — import the committed .ts so v8/Codecov
11+
// attributes the new registerStdioTool + apiDelete lines.
12+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
13+
14+
type BinModule = {
15+
server: { connect: (transport: unknown) => Promise<void> };
16+
};
17+
18+
let tempDir = "";
19+
const capturedDeletes: Array<{ url: string; method: string; body: { confirm?: boolean } }> = [];
20+
const loaded = new Map<string, BinModule>();
21+
22+
beforeAll(async () => {
23+
tempDir = mkdtempSync(join(tmpdir(), "loopover-clear-selftune-"));
24+
const apiUrl = await startFixtureServer({
25+
onClearSelftuneOverride: (body) => {
26+
capturedDeletes.push({
27+
url: "/v1/repos/owner/repo/selftune/overrides",
28+
method: "DELETE",
29+
body,
30+
});
31+
},
32+
});
33+
process.env.LOOPOVER_API_URL = apiUrl;
34+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
35+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
36+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
37+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
38+
for (const specifier of MODULES) {
39+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
40+
}
41+
}, 120_000);
42+
43+
afterAll(async () => {
44+
await closeFixtureServer();
45+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
46+
delete process.env.LOOPOVER_API_URL;
47+
delete process.env.LOOPOVER_API_TOKEN;
48+
delete process.env.LOOPOVER_CONFIG_DIR;
49+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
50+
});
51+
52+
describe("bin loopover_clear_selftune_override stdio tool (in-process, #9300)", () => {
53+
it.each(MODULES)("registers and proxies DELETE .../selftune/overrides with confirm:true — %s", async (specifier) => {
54+
capturedDeletes.length = 0;
55+
const mod = loaded.get(specifier)!;
56+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
57+
await mod.server.connect(serverTransport);
58+
const client = new Client({ name: "clear-selftune-test", version: "0.1.0" }, { capabilities: {} });
59+
await client.connect(clientTransport);
60+
try {
61+
const { tools } = await client.listTools();
62+
const tool = tools.find((entry) => entry.name === "loopover_clear_selftune_override");
63+
expect(tool).toBeDefined();
64+
expect(tool?.description).toMatch(/clear.*self-tune.*override/i);
65+
66+
const result = await client.callTool({
67+
name: "loopover_clear_selftune_override",
68+
arguments: { owner: "owner", repo: "repo", confirm: true },
69+
});
70+
expect(result.isError).toBeFalsy();
71+
expect(capturedDeletes).toEqual([
72+
{
73+
url: "/v1/repos/owner/repo/selftune/overrides",
74+
method: "DELETE",
75+
body: { confirm: true },
76+
},
77+
]);
78+
expect(JSON.stringify(result)).toMatch(/Cleared the live self-tune gate override for owner\/repo/);
79+
expect(JSON.stringify(result)).toContain('"cleared":true');
80+
} finally {
81+
await client.close().catch(() => undefined);
82+
}
83+
});
84+
85+
it.each(MODULES)("rejects missing or false confirm without calling DELETE — %s", async (specifier) => {
86+
capturedDeletes.length = 0;
87+
const mod = loaded.get(specifier)!;
88+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
89+
await mod.server.connect(serverTransport);
90+
const client = new Client({ name: "clear-selftune-reject-test", version: "0.1.0" }, { capabilities: {} });
91+
await client.connect(clientTransport);
92+
try {
93+
const missing = await client
94+
.callTool({
95+
name: "loopover_clear_selftune_override",
96+
arguments: { owner: "owner", repo: "repo" },
97+
})
98+
.then(
99+
(r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }),
100+
(e: unknown) => ({ isError: true, text: String(e) }),
101+
);
102+
expect(missing.isError).toBe(true);
103+
104+
const falsy = await client
105+
.callTool({
106+
name: "loopover_clear_selftune_override",
107+
arguments: { owner: "owner", repo: "repo", confirm: false },
108+
})
109+
.then(
110+
(r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }),
111+
(e: unknown) => ({ isError: true, text: String(e) }),
112+
);
113+
expect(falsy.isError).toBe(true);
114+
115+
// Schema rejection must never reach the REST route.
116+
expect(capturedDeletes).toEqual([]);
117+
} finally {
118+
await client.close().catch(() => undefined);
119+
}
120+
});
121+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
// (#7755 registered the loopover_generate_contributor_issue_drafts stdio tool, taking the count from 98 to 99.)
4444
// (#7753 registered the loopover_propose_action stdio tool, taking the count from 99 to 100.)
4545
// (#7798 registered the loopover_get_selftune_override_audit remote+stdio tool, taking the count from 100 to 101.)
46+
// (#9300 registered the loopover_clear_selftune_override CLI mirror, taking the count from 101 to 102.)
4647
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4748
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4849
import { mkdtempSync, rmSync } from "node:fs";
@@ -89,14 +90,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8990
});
9091
afterEach(disconnect);
9192

92-
it("lists exactly 101 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
93+
it("lists exactly 102 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
9394
const { tools } = await client.listTools();
9495
const names = tools.map((t) => t.name);
9596
const primary = names.filter((n) => n.startsWith("loopover_"));
9697
const legacy = names.filter((n) => n.startsWith("gittensory_"));
97-
expect(primary.length).toBe(101);
98+
expect(primary.length).toBe(102);
9899
expect(legacy.length).toBe(0);
99-
expect(names.length).toBe(101);
100+
expect(names.length).toBe(102);
100101
});
101102

102103
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -108,14 +109,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
108109
}
109110
});
110111

111-
it("`loopover-mcp tools --json` reports the same 101-tool count the live server registers", async () => {
112+
it("`loopover-mcp tools --json` reports the same 102-tool count the live server registers", async () => {
112113
const { tools } = await client.listTools();
113114
const payload = JSON.parse(run(["tools", "--json"])) as {
114115
count: number;
115116
tools: Array<{ name: string }>;
116117
};
117118
expect(payload.count).toBe(tools.length);
118-
expect(payload.count).toBe(101);
119+
expect(payload.count).toBe(102);
119120
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
120121
[...tools.map((t) => t.name)].sort(),
121122
);

test/unit/support/mcp-cli-harness.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ export async function startFixtureServer(
189189
onPlanIssuesRequest?: (body: { goal?: string; dryRun?: boolean; create?: boolean; limit?: number }) => void;
190190
onWatchRequest?: (req: { method: string; body: { repoFullName?: string; labels?: string[] } }) => void;
191191
onApiRequest?: (request: IncomingMessage) => void;
192+
/** #9300: captures DELETE /v1/repos/:owner/:repo/selftune/overrides body ({ confirm }). */
193+
onClearSelftuneOverride?: (body: { confirm?: boolean }) => void;
192194
validateConfigWarnings?: string[];
193195
openPrMonitor?: Record<string, unknown>;
194196
prOutcomes?: Record<string, unknown>;
@@ -748,6 +750,13 @@ export async function startFixtureServer(
748750
response.end(JSON.stringify({ repoFullName: "owner/bare" }));
749751
return;
750752
}
753+
// #9300: clear live self-tune override (write-side sibling of the audit GET above).
754+
if (request.url?.startsWith("/v1/repos/owner/repo/selftune/overrides") && !request.url.includes("/audit") && request.method === "DELETE") {
755+
const body = (await readJsonRequest(request)) as { confirm?: boolean };
756+
options.onClearSelftuneOverride?.(body);
757+
response.end(JSON.stringify({ repoFullName: "owner/repo", cleared: true }));
758+
return;
759+
}
751760
if (request.url?.startsWith("/v1/repos/owner/repo/outcome-calibration") && request.method === "GET") {
752761
const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays");
753762
response.end(

0 commit comments

Comments
 (0)