diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index f51cce044..768eb2688 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -129,7 +129,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", "plan-issues"], + 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", "plan-issues"], }; const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"]; const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"]; @@ -1031,6 +1031,13 @@ const gatePrecisionShape = { windowDays: z.number().int().positive().optional(), }; +// #7798: owner/repo plus the optional row cap the audit route's ?limit query accepts. +const selftuneOverrideAuditShape = { + owner: z.string().min(1), + repo: z.string().min(1), + limit: z.number().int().positive().optional(), +}; + // #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 @@ -1538,6 +1545,12 @@ 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 — 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_get_automation_state", category: "agent", @@ -3124,6 +3137,23 @@ registerStdioTool( }, ); +// #7798: read-only mirror of GET {repoBase}/selftune/overrides/audit — the same trail the +// `maintain selftune-audit` CLI verb prints. The API enforces maintainer authorization. +registerStdioTool( + "loopover_get_selftune_override_audit", + { + description: stdioToolDescription("loopover_get_selftune_override_audit"), + inputSchema: selftuneOverrideAuditShape, + }, + async ({ owner, repo, limit }: any) => { + // The schema already rejects a non-positive limit, so an omitted limit is the only way to the server's + // default cap -- 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); + }, +); + // #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 @@ -3773,6 +3803,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 (why an override promoted/cleared).", " 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.", @@ -3913,6 +3944,22 @@ export async function maintainCli(args: any) { emit(payload, lines.join("\n")); return; } + if (subcommand === "selftune-audit") { + // #7798 self-tune override audit: read-only mirror of GET {repoBase}/selftune/overrides/audit (the same + // trail the remote loopover_get_selftune_override_audit tool returns). The API enforces maintainer + // authorization; the CLI never decides locally. Optional --limit caps the rows the same way the route's + // ?limit query does (a non-positive value falls through to the server 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).`, + ...audit.map((event: any) => `- ${event.createdAt} ${event.eventType}${event.detail ? ` ${event.detail}` : ""}`), + ]; + 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 @@ -4049,7 +4096,7 @@ export 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 | plan-issues.`, + `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 | plan-issues.`, ); } @@ -5265,7 +5312,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 074e74948..a8979c891 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -189,6 +189,7 @@ import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENA import { loadUpstreamStatus } from "../upstream/ruleset"; import { authoritativeGateOverride, + listOverrideAudit, loadOverride, loadShadowOverride, toLiveGateThresholdFields, @@ -230,6 +231,13 @@ const ownerRepoWindowShape = { windowDays: z.number().int().positive().optional(), }; +// (#7798) owner/repo plus the optional row cap the audit route's ?limit query accepts. +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(), }; @@ -1052,6 +1060,13 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +// (#7798) self-tune override audit surfaced over MCP. Rows stay z.unknown(): listOverrideAudit is the +// single source of truth for the event fields, matching gatePrecisionOutputSchema's sub-report 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 @@ -1897,6 +1912,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", @@ -2152,6 +2168,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 — why the self-tune loop promoted, shadowed, or cleared a live gate override, newest first, optionally capped 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", { @@ -3834,6 +3861,20 @@ export class LoopoverMcp { }; } + // (#7798) MCP surface for GET /v1/repos/:owner/:repo/selftune/overrides/audit. Same per-repo read gate as + // getGatePrecision (requireRepoAccess); listOverrideAudit is read-only, already repo-scoped, and returns [] + // on any storage error, so the tool mirrors the route's { repoFullName, audit } shape exactly. The summary is + // deliberately branch-free. + 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/integration/api.test.ts b/test/integration/api.test.ts index dbc26aa2d..c8b219e90 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5524,6 +5524,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_preview_local_pr_score"); expect(toolNames).toContain("loopover_explain_score_breakdown"); expect(toolNames).toContain("loopover_get_outcome_calibration"); + expect(toolNames).toContain("loopover_get_selftune_override_audit"); expect(toolNames).toContain("loopover_get_registry_changes"); expect(toolNames).toContain("loopover_get_registry_snapshot"); expect(toolNames).toContain("loopover_get_upstream_drift"); diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 49dfa93de..db78195ab 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', 'plan-issues')", + "'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', 'plan-issues')", ); }); diff --git a/test/unit/mcp-cli-selftune-audit.test.ts b/test/unit/mcp-cli-selftune-audit.test.ts new file mode 100644 index 000000000..459485857 --- /dev/null +++ b/test/unit/mcp-cli-selftune-audit.test.ts @@ -0,0 +1,153 @@ +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, vi } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7798: in-process coverage for the `maintain selftune-audit` CLI dispatcher AND the +// loopover_get_selftune_override_audit stdio tool in packages/loopover-mcp/bin/loopover-mcp.ts. Same #7764 +// entrypoint-guard pattern as mcp-cli-plan-issues — import the committed .ts, call the exported maintainCli / +// hold the exported `server`, so v8/Codecov attributes the new dispatcher and registerStdioTool lines. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + maintainCli: (args: string[]) => Promise; + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-selftune-audit-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/selftune/overrides/audit")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + // The bin reads LOOPOVER_API_URL at module load, so set the env BEFORE importing (hence the dynamic import). + 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; +}); + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return chunks.join(""); +} + +describe("bin maintain selftune-audit CLI (in-process, #7798)", () => { + it.each(MODULES)("lists the audit trail newest first, rendering detail-bearing and detail-less events — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const out = await captureStdout(() => mod.maintainCli(["selftune-audit", "--repo", "owner/repo"])); + expect(capturedRequests[0]!.url).toBe("/v1/repos/owner/repo/selftune/overrides/audit"); + expect(out).toMatch(/Self-tune override audit for owner\/repo: 2 event\(s\)\./); + // A detail-bearing event renders its payload; a detail-less event renders without a trailing blob. + expect(out).toMatch(/- 2026-06-02T00:00:00\.000Z promoted \{"confidenceFloor":0\.91\}/); + expect(out).toMatch(/- 2026-06-01T00:00:00\.000Z shadow_written$/m); + }); + + it.each(MODULES)("--limit forwards as ?limit and caps the rows — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const out = await captureStdout(() => mod.maintainCli(["selftune-audit", "--repo", "owner/repo", "--limit", "1"])); + expect(capturedRequests[0]!.url).toBe("/v1/repos/owner/repo/selftune/overrides/audit?limit=1"); + expect(out).toMatch(/Self-tune override audit for owner\/repo: 1 event\(s\)\./); + }); + + it.each(MODULES)("emits machine-readable JSON with --json — %s", async (specifier) => { + const mod = loaded.get(specifier)!; + const out = await captureStdout(() => mod.maintainCli(["selftune-audit", "--repo", "owner/repo", "--json"])); + const payload = JSON.parse(out) as { repoFullName: string; audit: Array<{ eventType: string }> }; + expect(payload.repoFullName).toBe("owner/repo"); + expect(payload.audit.map((event) => event.eventType)).toEqual(["promoted", "shadow_written"]); + }); + + it.each(MODULES)("renders zero events for a payload without audit rows — %s", async (specifier) => { + // owner/bare's fixture responds without an `audit` key, exercising the CLI's defensive fallback the same + // way a real disabled/empty deployment would. + const mod = loaded.get(specifier)!; + const out = await captureStdout(() => mod.maintainCli(["selftune-audit", "--repo", "owner/bare"])); + expect(out).toMatch(/Self-tune override audit for owner\/bare: 0 event\(s\)\./); + expect(out).not.toMatch(/^- /m); + }); + + it.each(MODULES)("falls through past selftune-audit to the unknown-subcommand error — %s", async (specifier) => { + const mod = loaded.get(specifier)!; + // Exercises the false side of `subcommand === "selftune-audit"` and the updated unknown-subcommand throw. + await expect(mod.maintainCli(["not-a-real-subcommand", "--repo", "owner/repo"])).rejects.toThrow(/Unknown maintain subcommand.*selftune-audit/); + }); + + it.each(MODULES)("documents selftune-audit in the maintain --help output — %s", async (specifier) => { + const mod = loaded.get(specifier)!; + const out = await captureStdout(() => mod.maintainCli(["--help"])); + expect(out).toContain("selftune-audit [--limit N]"); + expect(out).toContain("self-tune override audit trail"); + }); +}); + +describe("bin loopover_get_selftune_override_audit stdio tool (in-process, #7798)", () => { + it.each(MODULES)("registers and proxies GET .../selftune/overrides/audit with and without limit — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "selftune-audit-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_selftune_override_audit"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/override audit trail/i); + + const result = await client.callTool({ + name: "loopover_get_selftune_override_audit", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests[0]!).toEqual({ url: "/v1/repos/owner/repo/selftune/overrides/audit", method: "GET" }); + expect(JSON.stringify(result)).toContain("shadow_written"); + + const capped = await client.callTool({ + name: "loopover_get_selftune_override_audit", + arguments: { owner: "owner", repo: "repo", limit: 1 }, + }); + expect(capped.isError).toBeFalsy(); + expect(capturedRequests[1]!.url).toBe("/v1/repos/owner/repo/selftune/overrides/audit?limit=1"); + const cappedText = JSON.stringify(capped); + expect(cappedText).toContain("promoted"); + expect(cappedText).not.toContain("shadow_written"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index c7aaf7cd9..611144aed 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -48,6 +48,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 000000000..47936b10c --- /dev/null +++ b/test/unit/mcp-selftune-override-audit.test.ts @@ -0,0 +1,59 @@ +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, type StorageEnv } 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-override-audit-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP loopover_get_selftune_override_audit (#7798)", () => { + it("returns the repo-scoped audit trail for an authorized caller and caps rows via limit", async () => { + const env = createTestEnv(); + const storageEnv = env as unknown as StorageEnv; + await recordOverrideAudit(storageEnv, REPO, "shadow_written", { confidenceFloor: 0.4 }); + await recordOverrideAudit(storageEnv, REPO, "promoted", { confidenceFloor: 0.91 }); + // A sibling repo's event must never leak into this repo's trail. + await recordOverrideAudit(storageEnv, "owner/other", "promoted", { confidenceFloor: 0.5 }); + 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); + expect(data.audit).toHaveLength(2); + expect(data.audit.map((event) => event.eventType).sort()).toEqual(["promoted", "shadow_written"]); + expect(JSON.stringify(result.content)).toContain("2 event(s)"); + + // The optional limit passes through to listOverrideAudit the same way the route's ?limit query does. + const capped = await client.callTool({ name: "loopover_get_selftune_override_audit", arguments: { owner: "owner", repo: "widgets", limit: 1 } }); + expect(capped.isError).toBeFalsy(); + expect((capped.structuredContent as { audit: unknown[] }).audit).toHaveLength(1); + }); + + it("returns an empty audit trail when no override events are 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(); + expect(result.structuredContent).toEqual({ repoFullName: REPO, audit: [] }); + 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: "" }); + 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 34265262f..1db26b689 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -42,6 +42,7 @@ // (#7756 registered the loopover_get_repo_onboarding_pack stdio tool, taking the count from 97 to 98.) // (#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.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -88,14 +89,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 100 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 101 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(100); + expect(primary.length).toBe(101); expect(legacy.length).toBe(0); - expect(names.length).toBe(100); + expect(names.length).toBe(101); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -107,14 +108,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 100-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 101-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(100); + expect(payload.count).toBe(101); 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 1e5549dab..25d1150f7 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -725,6 +725,21 @@ export async function startFixtureServer( ); return; } + // #7798: self-tune override audit trail (why an override was promoted/shadowed/cleared). + 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"); + const events = [ + { eventType: "promoted", detail: '{"confidenceFloor":0.91}', createdAt: "2026-06-02T00:00:00.000Z" }, + { eventType: "shadow_written", detail: null, createdAt: "2026-06-01T00:00:00.000Z" }, + ]; + response.end(JSON.stringify({ repoFullName: "owner/repo", audit: limit ? events.slice(0, Number(limit)) : events })); + return; + } + // #7798: audit-less variant — a payload without rows, for the CLI's defensive audit fallback. + if (request.url?.startsWith("/v1/repos/owner/bare/selftune/overrides/audit") && request.method === "GET") { + response.end(JSON.stringify({ repoFullName: "owner/bare" })); + 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(