Skip to content

Commit aec0b28

Browse files
committed
feat(mcp): mirror DELETE selftune/overrides as loopover_clear_selftune_override
Add the write-side MCP tool for clearing a repo's live self-tune gate override, the missing counterpart to the read-only loopover_get_selftune_override_audit. It calls the same deleteLiveOverride the DELETE /v1/repos/:owner/:repo/selftune/overrides route uses, enforces the same maintainer-manage repo gate as the sibling maintainer-mutation tools (loopover_set_agent_paused/loopover_set_action_autonomy/ loopover_decide_pending_action), takes a required confirm:true, and returns { repoFullName, cleared: true }. Closes #8660
1 parent c5cc6c4 commit aec0b28

2 files changed

Lines changed: 96 additions & 0 deletions

File tree

src/mcp/server.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENA
193193
import { loadUpstreamStatus } from "../upstream/ruleset";
194194
import {
195195
authoritativeGateOverride,
196+
deleteLiveOverride,
196197
listOverrideAudit,
197198
loadOverride,
198199
loadShadowOverride,
@@ -242,6 +243,16 @@ const selftuneOverrideAuditShape = {
242243
limit: z.number().int().positive().optional(),
243244
};
244245

246+
// (#8660) write-side mirror of DELETE /v1/repos/:owner/:repo/selftune/overrides. `confirm` is the required
247+
// confirmation field this destructive reset must carry, matching the sibling maintainer-mutation tools'
248+
// deliberate action params (loopover_set_agent_paused's `paused`, loopover_set_action_autonomy's action/level)
249+
// and the REST route's own "an optional body is treated as a confirmation of the override being cleared" intent.
250+
const clearSelftuneOverrideShape = {
251+
owner: z.string().min(1),
252+
repo: z.string().min(1),
253+
confirm: z.literal(true),
254+
};
255+
245256
const windowOnlyShape = {
246257
windowDays: z.number().int().positive().optional(),
247258
};
@@ -1100,6 +1111,12 @@ const selftuneOverrideAuditOutputSchema = {
11001111
audit: z.array(z.unknown()).optional(),
11011112
};
11021113

1114+
// (#8660) confirmation shape for the write-side clear: mirrors the REST route's { repoFullName, cleared: true }.
1115+
const clearSelftuneOverrideOutputSchema = {
1116+
repoFullName: z.string().optional(),
1117+
cleared: z.boolean().optional(),
1118+
};
1119+
11031120
// #5825 - maintainer-authenticated skipped-PR audit trail, mirroring GET /v1/app/skipped-pr-audit's
11041121
// filters (all optional: a bare call returns the caller's own repo-scoped feed). No owner/repo shape
11051122
// here on purpose: unlike ownerRepoShape tools this report can legitimately span every repo the caller
@@ -1978,6 +1995,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
19781995
loopover_get_outcome_calibration: "maintainer",
19791996
loopover_get_gate_precision: "maintainer",
19801997
loopover_get_selftune_override_audit: "maintainer",
1998+
loopover_clear_selftune_override: "maintainer",
19811999
loopover_get_skipped_pr_audit: "maintainer",
19822000
loopover_get_fleet_analytics: "maintainer",
19832001
loopover_get_recommendation_quality: "maintainer",
@@ -2255,6 +2273,20 @@ export class LoopoverMcp {
22552273
async (input) => this.toolResult(await this.getSelftuneOverrideAudit(input)),
22562274
);
22572275

2276+
// (#8660) write-side counterpart to loopover_get_selftune_override_audit: the missing MCP mirror of
2277+
// DELETE /v1/repos/:owner/:repo/selftune/overrides. Maintainer-manage access required, same as the other
2278+
// maintainer-mutation tools (loopover_set_agent_paused/loopover_set_action_autonomy/loopover_decide_pending_action).
2279+
register(
2280+
"loopover_clear_selftune_override",
2281+
{
2282+
description:
2283+
"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.",
2284+
inputSchema: clearSelftuneOverrideShape,
2285+
outputSchema: clearSelftuneOverrideOutputSchema,
2286+
},
2287+
async (input) => this.toolResult(await this.clearSelftuneOverride(input)),
2288+
);
2289+
22582290
register(
22592291
"loopover_get_skipped_pr_audit",
22602292
{
@@ -4140,6 +4172,20 @@ export class LoopoverMcp {
41404172
};
41414173
}
41424174

4175+
// (#8660) MCP surface for DELETE /v1/repos/:owner/:repo/selftune/overrides. Uses the same maintainer-MANAGE
4176+
// gate as the sibling write tools (loopover_set_agent_paused/loopover_set_action_autonomy) — stricter than the
4177+
// audit tool's read gate — and calls the exact deleteLiveOverride the REST route already uses, returning the
4178+
// route's { repoFullName, cleared: true } shape. Branch-free: `confirm` is enforced by the input schema.
4179+
private async clearSelftuneOverride(input: z.infer<z.ZodObject<typeof clearSelftuneOverrideShape>>): Promise<ToolPayload> {
4180+
const fullName = `${input.owner}/${input.repo}`;
4181+
await this.requireRepoManageAccess(fullName);
4182+
await deleteLiveOverride(this.env as unknown as StorageEnv, fullName);
4183+
return {
4184+
summary: `Cleared the live self-tune gate override for ${fullName}.`,
4185+
data: { repoFullName: fullName, cleared: true },
4186+
};
4187+
}
4188+
41434189
// #5825 - repo-scope resolution for the skipped-PR audit tool. Mirrors skippedPrAuditRepoScope in
41444190
// src/api/routes.ts (same underlying loadControlPanelRoleSummary/loadControlPanelAccessScope calls,
41454191
// same maintainer/owner/operator role gate, same "no filter -> caller's own scoped repos" fallback),
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { describe, expect, it } from "vitest";
4+
import { LoopoverMcp } from "../../src/mcp/server";
5+
import { loadOverride, writeLiveOverride, type StorageEnv } from "../../src/review/auto-apply";
6+
import { createTestEnv } from "../helpers/d1";
7+
8+
const REPO = "owner/widgets";
9+
10+
async function connect(env: Env) {
11+
const server = new LoopoverMcp(env).createServer();
12+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
13+
await server.connect(serverTransport);
14+
const client = new Client({ name: "loopover-clear-selftune-override-test", version: "0.1.0" }, { capabilities: {} });
15+
await client.connect(clientTransport);
16+
return client;
17+
}
18+
19+
describe("MCP loopover_clear_selftune_override (#8660)", () => {
20+
it("clears a repo's live self-tune override for an authorized caller and the override is gone afterward", async () => {
21+
const env = createTestEnv();
22+
const storageEnv = env as unknown as StorageEnv;
23+
await writeLiveOverride(storageEnv, REPO, { confidenceFloor: 0.42, scopeCap: { files: 5, lines: 200 } });
24+
// Guard the precondition: the override really is live before the tool runs.
25+
expect(await loadOverride(storageEnv, REPO)).not.toBeNull();
26+
27+
const client = await connect(env);
28+
const result = await client.callTool({ name: "loopover_clear_selftune_override", arguments: { owner: "owner", repo: "widgets", confirm: true } });
29+
expect(result.isError).toBeFalsy();
30+
expect(result.structuredContent).toEqual({ repoFullName: REPO, cleared: true });
31+
expect(JSON.stringify(result.content)).toContain("Cleared the live self-tune gate override for owner/widgets");
32+
33+
// Deliverable (a): the override is verifiably gone via a direct store read.
34+
expect(await loadOverride(storageEnv, REPO)).toBeNull();
35+
});
36+
37+
it("rejects a non-maintainer caller when the repo is not in MCP_ACTUATION_REPO_ALLOWLIST", async () => {
38+
const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" });
39+
const storageEnv = env as unknown as StorageEnv;
40+
await writeLiveOverride(storageEnv, REPO, { confidenceFloor: 0.42 });
41+
42+
const client = await connect(env); // default identity: { kind: "static", actor: "mcp" }
43+
const result = await client.callTool({ name: "loopover_clear_selftune_override", arguments: { owner: "owner", repo: "widgets", confirm: true } });
44+
expect(result.isError).toBe(true);
45+
expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/);
46+
47+
// Deliverable (b): the rejected call must not have touched the override.
48+
expect(await loadOverride(storageEnv, REPO)).not.toBeNull();
49+
});
50+
});

0 commit comments

Comments
 (0)