From eb7cd9cc6889e531ee22f4fa04831793530f14b8 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Mon, 27 Jul 2026 14:11:00 +0000 Subject: [PATCH] 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 --- packages/loopover-mcp/bin/loopover-mcp.ts | 28 ++++ .../mcp-cli-clear-selftune-override.test.ts | 121 ++++++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 11 +- test/unit/support/mcp-cli-harness.ts | 9 ++ 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 test/unit/mcp-cli-clear-selftune-override.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index e0d80d8e5a..51f35b5ed8 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1070,6 +1070,14 @@ const selftuneOverrideAuditShape = { limit: z.number().int().positive().optional(), }; +// #9300: write-side sibling of selftuneOverrideAuditShape — mirrors src/mcp/server.ts's +// clearSelftuneOverrideShape (`confirm` must be the literal true; omitted/false is schema-rejected). +const clearSelftuneOverrideShape = { + owner: z.string().min(1), + repo: z.string().min(1), + confirm: z.literal(true), +}; + // #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape), // minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose -- // forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer @@ -1583,6 +1591,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "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.", }, + { + name: "loopover_clear_selftune_override", + category: "maintainer", + description: + "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.", + }, { name: "loopover_get_automation_state", category: "agent", @@ -3192,6 +3206,20 @@ registerStdioTool( }, ); +// #9300: write-side sibling of loopover_get_selftune_override_audit — DELETE {repoBase}/selftune/overrides +// with confirm:true (schema-enforced; never silently defaulted). Same apiDelete helper the unwatch action uses. +registerStdioTool( + "loopover_clear_selftune_override", + { + description: stdioToolDescription("loopover_clear_selftune_override"), + inputSchema: clearSelftuneOverrideShape, + }, + async ({ owner, repo, confirm }: any) => { + const payload = await apiDelete(`${toolRepoBase(owner, repo)}/selftune/overrides`, { confirm }); + return toolResult(`Cleared the live self-tune gate override for ${owner}/${repo}.`, payload); + }, +); + // #7752: read-side counterpart to the pause/resume/set-level write tools above. Proxies the same // GET {repoBase}/automation-state the `maintain automation-state` CLI already calls — no duplicated HTTP path. // Summary is intentionally branch-free (no ?? / ?. / ternaries) so codecov/patch stays at 100%; the full diff --git a/test/unit/mcp-cli-clear-selftune-override.test.ts b/test/unit/mcp-cli-clear-selftune-override.test.ts new file mode 100644 index 0000000000..28f8f365eb --- /dev/null +++ b/test/unit/mcp-cli-clear-selftune-override.test.ts @@ -0,0 +1,121 @@ +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"; + +// #9300: in-process coverage for loopover_clear_selftune_override in packages/loopover-mcp/bin/loopover-mcp.ts. +// Same entrypoint-guard pattern as mcp-cli-selftune-audit — import the committed .ts so v8/Codecov +// attributes the new registerStdioTool + apiDelete lines. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedDeletes: Array<{ url: string; method: string; body: { confirm?: boolean } }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-clear-selftune-")); + const apiUrl = await startFixtureServer({ + onClearSelftuneOverride: (body) => { + capturedDeletes.push({ + url: "/v1/repos/owner/repo/selftune/overrides", + method: "DELETE", + body, + }); + }, + }); + 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_clear_selftune_override stdio tool (in-process, #9300)", () => { + it.each(MODULES)("registers and proxies DELETE .../selftune/overrides with confirm:true — %s", async (specifier) => { + capturedDeletes.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "clear-selftune-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_clear_selftune_override"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/clear.*self-tune.*override/i); + + const result = await client.callTool({ + name: "loopover_clear_selftune_override", + arguments: { owner: "owner", repo: "repo", confirm: true }, + }); + expect(result.isError).toBeFalsy(); + expect(capturedDeletes).toEqual([ + { + url: "/v1/repos/owner/repo/selftune/overrides", + method: "DELETE", + body: { confirm: true }, + }, + ]); + expect(JSON.stringify(result)).toMatch(/Cleared the live self-tune gate override for owner\/repo/); + expect(JSON.stringify(result)).toContain('"cleared":true'); + } finally { + await client.close().catch(() => undefined); + } + }); + + it.each(MODULES)("rejects missing or false confirm without calling DELETE — %s", async (specifier) => { + capturedDeletes.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "clear-selftune-reject-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const missing = await client + .callTool({ + name: "loopover_clear_selftune_override", + arguments: { owner: "owner", repo: "repo" }, + }) + .then( + (r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }), + (e: unknown) => ({ isError: true, text: String(e) }), + ); + expect(missing.isError).toBe(true); + + const falsy = await client + .callTool({ + name: "loopover_clear_selftune_override", + arguments: { owner: "owner", repo: "repo", confirm: false }, + }) + .then( + (r) => ({ isError: Boolean(r.isError), text: JSON.stringify(r) }), + (e: unknown) => ({ isError: true, text: String(e) }), + ); + expect(falsy.isError).toBe(true); + + // Schema rejection must never reach the REST route. + expect(capturedDeletes).toEqual([]); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 1db26b689c..d9fec101da 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -43,6 +43,7 @@ // (#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.) // (#7798 registered the loopover_get_selftune_override_audit remote+stdio tool, taking the count from 100 to 101.) +// (#9300 registered the loopover_clear_selftune_override CLI mirror, taking the count from 101 to 102.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -89,14 +90,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 101 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 102 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(101); + expect(primary.length).toBe(102); expect(legacy.length).toBe(0); - expect(names.length).toBe(101); + expect(names.length).toBe(102); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -108,14 +109,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 101-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 102-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(101); + expect(payload.count).toBe(102); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 5dc866b324..7976bdb7cf 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -189,6 +189,8 @@ export async function startFixtureServer( onPlanIssuesRequest?: (body: { goal?: string; dryRun?: boolean; create?: boolean; limit?: number }) => void; onWatchRequest?: (req: { method: string; body: { repoFullName?: string; labels?: string[] } }) => void; onApiRequest?: (request: IncomingMessage) => void; + /** #9300: captures DELETE /v1/repos/:owner/:repo/selftune/overrides body ({ confirm }). */ + onClearSelftuneOverride?: (body: { confirm?: boolean }) => void; validateConfigWarnings?: string[]; openPrMonitor?: Record; prOutcomes?: Record; @@ -748,6 +750,13 @@ export async function startFixtureServer( response.end(JSON.stringify({ repoFullName: "owner/bare" })); return; } + // #9300: clear live self-tune override (write-side sibling of the audit GET above). + if (request.url?.startsWith("/v1/repos/owner/repo/selftune/overrides") && !request.url.includes("/audit") && request.method === "DELETE") { + const body = (await readJsonRequest(request)) as { confirm?: boolean }; + options.onClearSelftuneOverride?.(body); + response.end(JSON.stringify({ repoFullName: "owner/repo", cleared: true })); + return; + } if (request.url?.startsWith("/v1/repos/owner/repo/outcome-calibration") && request.method === "GET") { const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays"); response.end(