From e0f5d4148cdc2d7a610ce55a56ebd2d83491213a Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Tue, 21 Jul 2026 15:52:24 +0000 Subject: [PATCH] feat(mcp): mirror selftune override audit to remote/stdio tools and cli (#7798) --- packages/loopover-mcp/bin/loopover-mcp.ts | 53 +++++++++++++- src/mcp/server.ts | 44 ++++++++++++ test/unit/mcp-cli-basics.test.ts | 2 +- test/unit/mcp-cli-maintain-tools.test.ts | 22 ++++++ test/unit/mcp-cli-maintain.test.ts | 19 +++++ test/unit/mcp-output-schemas.test.ts | 1 + test/unit/mcp-selftune-override-audit.test.ts | 70 +++++++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 11 +-- test/unit/support/mcp-cli-harness.ts | 15 ++++ 9 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 test/unit/mcp-selftune-override-audit.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index d164dddd5e..e00b8ed6d6 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -107,7 +107,7 @@ const CLI_COMMAND_SPEC = { profile: ["list", "create", "switch", "remove"], cache: ["status", "clear", "list"], agent: ["plan", "status", "explain", "packet"], - maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts"], + maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "selftune-audit", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts"], }; const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"]; const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"]; @@ -915,6 +915,14 @@ const gatePrecisionShape = { windowDays: z.number().int().positive().optional(), }; +// #7798 self-tune override audit trail: owner/repo plus the optional `limit` the REST route's ?limit +// query param takes (a non-positive value omits the query, so the server applies its own default). +const selftuneOverrideAuditShape = { + owner: z.string().min(1), + repo: z.string().min(1), + limit: z.number().int().positive().optional(), +}; + // Single source of truth for stdio tool name + one-line description (#2233). // Registration and `loopover-mcp tools` both read this list. const STDIO_TOOL_DESCRIPTORS = [ @@ -1291,6 +1299,11 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "maintainer", 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.", }, + { + name: "loopover_get_selftune_override_audit", + category: "maintainer", + description: "Return the self-tune override audit trail for a repo: the events the self-tune loop recorded when it shadowed, promoted, or applied a live gate override, each with its event type, detail, and timestamp (newest first). Optionally bounded by limit. Same as `loopover-mcp maintain selftune-audit`. Maintainer-authenticated; read-only measurement.", + }, { name: "loopover_open_pr", category: "agent", @@ -2597,6 +2610,21 @@ registerStdioTool( return toolResult(`Gate precision for ${owner}/${repo}.`, payload); }, ); + +registerStdioTool( + "loopover_get_selftune_override_audit", + { + description: stdioToolDescription("loopover_get_selftune_override_audit"), + inputSchema: selftuneOverrideAuditShape, + }, + async ({ owner, repo, limit }: any) => { + // #7798: the schema already rejects a non-positive limit, so an omitted limit is the only way to the + // server's default page size -- matching the route's own behaviour when ?limit is absent. + const query = limit ? `?limit=${encodeURIComponent(limit)}` : ""; + const payload = await apiGet(`${toolRepoBase(owner, repo)}/selftune/overrides/audit${query}`); + return toolResult(`Self-tune override audit for ${owner}/${repo}: ${(payload.audit ?? []).length} event(s).`, payload); + }, +); // ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool // returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the // miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server. @@ -3191,6 +3219,7 @@ function printMaintainHelp() { ` actions: ${MAINTAIN_ACTION_CLASSES.join(", ")}`, ` levels: ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}`, " precision [--window-days N] Show gate false-positive telemetry (blocked-then-merged per gate type).", + " selftune-audit [--limit N] Show the self-tune override audit trail (shadow/promote/apply events).", " outcome-calibration Show slop-band merge rates and recommendation-outcome calibration.", " [--window-days N] Bound the recommendation window (default: full history).", " onboarding-pack [--refresh] Preview the repo's contributor onboarding pack.", @@ -3328,6 +3357,24 @@ async function maintainCli(args: any) { emit(payload, lines.join("\n")); return; } + if (subcommand === "selftune-audit") { + // #7798 self-tune override audit trail: read-only measurement of the override_audit events the self-tune + // loop recorded (shadow/promote/apply). The API enforces maintainer authorization; the CLI never decides + // locally. Optional --limit bounds the page the same way the route's ?limit query does (a non-positive + // value falls through to the server's default). + const limit = Number(options.limit); + const query = limit > 0 ? `?limit=${encodeURIComponent(limit)}` : ""; + const payload = await apiGet(`${repoBase}/selftune/overrides/audit${query}`); + const audit = payload.audit ?? []; + const lines = [ + `Self-tune override audit for ${repoFullName}: ${audit.length} event(s).`, + // `detail` is the one free-form field here; sanitized on the plain-text path like audit-feed's dump + // below (--json re-serializes `payload` untouched, so the JSON contract is unaffected). + ...audit.map((event: any) => sanitizePlainTextTerminalOutput([event.createdAt, event.eventType, event.detail].filter(Boolean).join(" "))), + ]; + emit(payload, lines.join("\n")); + return; + } if (subcommand === "outcome-calibration") { // #6735 outcome calibration: read-only measurement of whether higher-slop bands merge less often and how // agent recommendations panned out. Same --window-days handling the sibling precision command uses (a @@ -3440,7 +3487,7 @@ async function maintainCli(args: any) { return; } throw new Error( - `Unknown maintain subcommand: ${subcommand}. Use status | queue | propose | approve | reject | pause | resume | set-level | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`, + `Unknown maintain subcommand: ${subcommand}. Use status | queue | propose | approve | reject | pause | resume | set-level | precision | selftune-audit | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`, ); } @@ -4642,7 +4689,7 @@ function printHelp() { loopover-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json] loopover-mcp cache status|list|clear [--json] loopover-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json] - loopover-mcp maintain status|queue|approve|reject|pause|resume|set-level|precision|outcome-calibration|onboarding-pack|audit-feed|automation-state|refresh-docs|generate-issue-drafts --repo owner/repo [--json] (see \`loopover-mcp maintain --help\`) + loopover-mcp maintain status|queue|approve|reject|pause|resume|set-level|precision|selftune-audit|outcome-calibration|onboarding-pack|audit-feed|automation-state|refresh-docs|generate-issue-drafts --repo owner/repo [--json] (see \`loopover-mcp maintain --help\`) loopover-mcp decision-pack --login [--json] loopover-mcp repo-decision --login --repo owner/repo [--json] loopover-mcp contributor-profile [--login ] [--json] diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85d59a46ee..c1343b03ab 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -187,6 +187,7 @@ import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/fi import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy"; import { recordPredictedGateCall } from "../review/predicted-gate-calls"; import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger"; +import { listOverrideAudit, type StorageEnv } from "../review/auto-apply"; type AppContext = Context<{ Bindings: Env }>; type ToolPayload = { @@ -218,6 +219,15 @@ const ownerRepoWindowShape = { windowDays: z.number().int().positive().optional(), }; +// #7798 - self-tune override audit trail input. Scoped to owner/repo like ownerRepoShape, plus the +// optional `limit` that mirrors the REST route's ?limit query param (a non-positive value falls +// through to listOverrideAudit's own default server-side, exactly as the route does). +const selftuneOverrideAuditShape = { + owner: z.string().min(1), + repo: z.string().min(1), + limit: z.number().int().positive().optional(), +}; + const windowOnlyShape = { windowDays: z.number().int().positive().optional(), }; @@ -993,6 +1003,14 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +// #7798 - mirrors the GET .../selftune/overrides/audit route's response shape ({ repoFullName, audit }); +// each audit row is listOverrideAudit's public { eventType, detail, createdAt } object (z.unknown() here, +// with listOverrideAudit as the single source of truth for the row shape, matching the report-tool pattern). +const selftuneOverrideAuditOutputSchema = { + repoFullName: z.string().optional(), + audit: z.array(z.unknown()).optional(), +}; + // #5825 - maintainer-authenticated skipped-PR audit trail, mirroring GET /v1/app/skipped-pr-audit's // filters (all optional: a bare call returns the caller's own repo-scoped feed). No owner/repo shape // here on purpose: unlike ownerRepoShape tools this report can legitimately span every repo the caller @@ -1807,6 +1825,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_repo_outcome_patterns: "maintainer", loopover_get_outcome_calibration: "maintainer", loopover_get_gate_precision: "maintainer", + loopover_get_selftune_override_audit: "maintainer", loopover_get_skipped_pr_audit: "maintainer", loopover_get_fleet_analytics: "maintainer", loopover_get_recommendation_quality: "maintainer", @@ -2024,6 +2043,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGatePrecision(input)), ); + register( + "loopover_get_selftune_override_audit", + { + description: + "Return the self-tune override audit trail for a repo: the override_audit events the LOOPOVER_REVIEW_SELFTUNE loop recorded when it shadowed, promoted, or applied a live gate override, each with its event type, detail, and timestamp (newest first). Optionally bounded by limit. Maintainer-authenticated; read-only measurement.", + inputSchema: selftuneOverrideAuditShape, + outputSchema: selftuneOverrideAuditOutputSchema, + }, + async (input) => this.toolResult(await this.getSelftuneOverrideAudit(input)), + ); + register( "loopover_get_skipped_pr_audit", { @@ -3487,6 +3517,20 @@ export class LoopoverMcp { }; } + // #7798 - surface the existing self-tune override audit trail over MCP, mirroring getGatePrecision's shape. + // Same per-repo read gate (requireRepoAccess); listOverrideAudit is read-only and already scoped to the single + // repo, so nothing cross-repo is revealed. `limit` is forwarded verbatim (undefined falls through to + // listOverrideAudit's own default), exactly as the GET .../selftune/overrides/audit route does with ?limit. + private async getSelftuneOverrideAudit(input: { owner: string; repo: string; limit?: number | undefined }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoAccess(fullName); + const audit = await listOverrideAudit(this.env as unknown as StorageEnv, fullName, input.limit); + return { + summary: `LoopOver self-tune override audit for ${fullName}: ${audit.length} event(s).`, + data: { repoFullName: fullName, audit }, + }; + } + // #5825 - repo-scope resolution for the skipped-PR audit tool. Mirrors skippedPrAuditRepoScope in // src/api/routes.ts (same underlying loadControlPanelRoleSummary/loadControlPanelAccessScope calls, // same maintainer/owner/operator role gate, same "no filter -> caller's own scoped repos" fallback), diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index cf4fe1644b..f796f7f3c4 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -221,7 +221,7 @@ describe("loopover-mcp CLI — basics", () => { expect(ps).toContain("[System.Management.Automation.CompletionResult]::new"); expect(ps).toContain("$commands = @('login', 'logout'"); expect(ps).toContain( - "'maintain' = @('status', 'queue', 'propose', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts')", + "'maintain' = @('status', 'queue', 'propose', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'selftune-audit', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts')", ); }); diff --git a/test/unit/mcp-cli-maintain-tools.test.ts b/test/unit/mcp-cli-maintain-tools.test.ts index 7bb9450726..b95718e154 100644 --- a/test/unit/mcp-cli-maintain-tools.test.ts +++ b/test/unit/mcp-cli-maintain-tools.test.ts @@ -136,4 +136,26 @@ describe("loopover-mcp maintain stdio proxies (#6152)", () => { } expect(capturedRequests).toEqual([]); }); + + // #7798: the self-tune override audit trail's stdio mirror. Same proxy contract as the #6152 tools above, + // added in a later batch so it lives in its own case rather than the shared MAINTAIN_TOOLS loop. + it("loopover_get_selftune_override_audit proxies to the audit endpoint and forwards limit", async () => { + await connect(); + const names = (await client!.listTools()).tools.map((tool) => tool.name); + expect(names).toContain("loopover_get_selftune_override_audit"); + const result = await client!.callTool({ name: "loopover_get_selftune_override_audit", arguments: { ...REPO, limit: 5 } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { repoFullName: string; audit: Array<{ eventType: string }>; limit?: number }; + expect(data.repoFullName).toBe("owner/repo"); + expect(data.audit.map((row) => row.eventType)).toEqual(["override_promoted", "override_shadowed"]); + expect(data.limit).toBe(5); + expect(JSON.stringify(result.content)).toContain("2 event(s)"); + }); + + it("loopover_get_selftune_override_audit surfaces an API failure as a tool error", async () => { + await connect(); + const result = await client!.callTool({ name: "loopover_get_selftune_override_audit", arguments: { owner: "nobody", repo: "missing" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/404|not_found/); + }); }); diff --git a/test/unit/mcp-cli-maintain.test.ts b/test/unit/mcp-cli-maintain.test.ts index 1f06d989ae..6c339828fe 100644 --- a/test/unit/mcp-cli-maintain.test.ts +++ b/test/unit/mcp-cli-maintain.test.ts @@ -95,6 +95,25 @@ describe("loopover-mcp CLI — maintain (#784)", () => { expect(scoped).toMatch(/Gate precision for owner\/repo \(last 30d\)/); }); + it("selftune-audit reports the override audit trail (plain + json), passing the limit through (#7798)", async () => { + const e = await env(); + const out = await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo"], e); + expect(out).toMatch(/Self-tune override audit for owner\/repo: 2 event\(s\)/); + expect(out).toMatch(/override_promoted\s+\{"confidenceFloor":0\.9\}/); + // A null detail is filtered out of the plain-text line rather than printed as "null". + expect(out).toMatch(/override_shadowed$/m); + expect(out).not.toContain("null"); + const json = JSON.parse(await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo", "--json"], e)) as { + repoFullName: string; + audit: Array<{ eventType: string }>; + }; + expect(json.repoFullName).toBe("owner/repo"); + expect(json.audit.map((row) => row.eventType)).toEqual(["override_promoted", "override_shadowed"]); + // --limit bounds the page; the CLI forwards it as ?limit, which the fixture echoes back. + const scoped = JSON.parse(await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo", "--limit", "5", "--json"], e)) as { limit?: number }; + expect(scoped.limit).toBe(5); + }); + it("generate-issue-drafts dry-runs by default and never forwards create (#6757)", async () => { const bodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = []; const e = await env({ onIssueDraftRequest: (b) => bodies.push(b) }); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 55e958a34f..227e7d6583 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -40,6 +40,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_get_eligibility_plan", "loopover_simulate_open_pr_pressure", "loopover_get_gate_precision", + "loopover_get_selftune_override_audit", "loopover_get_skipped_pr_audit", ]; diff --git a/test/unit/mcp-selftune-override-audit.test.ts b/test/unit/mcp-selftune-override-audit.test.ts new file mode 100644 index 0000000000..377b9153c9 --- /dev/null +++ b/test/unit/mcp-selftune-override-audit.test.ts @@ -0,0 +1,70 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { recordOverrideAudit } from "../../src/review/auto-apply"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; + +async function connect(env: Env) { + const server = new LoopoverMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "loopover-selftune-audit-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +async function seedOverrideAudit(env: Env) { + await recordOverrideAudit(env as never, REPO, "override_shadowed", { confidenceFloor: 0.9 }); + await recordOverrideAudit(env as never, REPO, "override_promoted", { confidenceFloor: 0.9, validated: true }); +} + +describe("MCP loopover_get_selftune_override_audit (#7798)", () => { + it("returns the override audit trail newest-first for an authorized caller", async () => { + const env = createTestEnv(); + await seedOverrideAudit(env); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_selftune_override_audit", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { repoFullName: string; audit: Array<{ eventType: string; detail: string | null; createdAt: string }> }; + expect(data.repoFullName).toBe(REPO); + // Two writes land in the same CURRENT_TIMESTAMP second, so assert membership + mapping rather than order. + expect(data.audit.map((row) => row.eventType).sort()).toEqual(["override_promoted", "override_shadowed"]); + const promoted = data.audit.find((row) => row.eventType === "override_promoted"); + expect(promoted?.detail).toBe(JSON.stringify({ confidenceFloor: 0.9, validated: true })); + expect(typeof promoted?.createdAt).toBe("string"); + expect(JSON.stringify(result.content)).toContain("2 event(s)"); + }); + + it("forwards the limit to listOverrideAudit", async () => { + const env = createTestEnv(); + await seedOverrideAudit(env); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_selftune_override_audit", arguments: { owner: "owner", repo: "widgets", limit: 1 } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { audit: unknown[] }; + expect(data.audit).toHaveLength(1); + }); + + it("returns an empty trail when nothing has been recorded", async () => { + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_selftune_override_audit", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { repoFullName: string; audit: unknown[] }; + expect(data.repoFullName).toBe(REPO); + expect(data.audit).toEqual([]); + expect(JSON.stringify(result.content)).toContain("0 event(s)"); + }); + + it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }); + await seedOverrideAudit(env); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_selftune_override_audit", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeTruthy(); + expect(JSON.stringify(result.content)).toMatch(/cannot access this repository/i); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 6bc40a746b..c483957730 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -21,6 +21,7 @@ // (#6741 registered the loopover_draft_pr_body CLI mirror, taking the count from 76 to 77.) // (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.) // (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.) +// (#7798 registered the loopover_get_selftune_override_audit CLI mirror, taking the count from 79 to 80.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -68,14 +69,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 79 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 80 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(79); + expect(primary.length).toBe(80); expect(legacy.length).toBe(0); - expect(names.length).toBe(79); + expect(names.length).toBe(80); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -87,14 +88,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 79-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 80-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(79); + expect(payload.count).toBe(80); 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 2f26c988c5..e85d192d01 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -659,6 +659,21 @@ export async function startFixtureServer( ); return; } + // #7798 self-tune override audit trail (read-only). Echoes ?limit so the CLI/tool limit pass-through is testable. + if (request.url?.startsWith("/v1/repos/owner/repo/selftune/overrides/audit") && request.method === "GET") { + const limit = new URL(request.url, "http://localhost").searchParams.get("limit"); + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + audit: [ + { eventType: "override_promoted", detail: JSON.stringify({ confidenceFloor: 0.9 }), createdAt: "2026-05-30T00:00:00.000Z" }, + { eventType: "override_shadowed", detail: null, createdAt: "2026-05-29T00:00:00.000Z" }, + ], + ...(limit ? { limit: Number(limit) } : {}), + }), + ); + 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(