Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -921,6 +921,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 = [
Expand Down Expand Up @@ -1302,6 +1310,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",
Expand Down Expand Up @@ -2623,6 +2636,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.
Expand Down Expand Up @@ -3217,6 +3245,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.",
Expand Down Expand Up @@ -3354,6 +3383,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
Expand Down Expand Up @@ -3466,7 +3513,7 @@ async function maintainCli(args: any) {
return;
}
throw new Error(
`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`,
`Unknown maintain subcommand: ${subcommand}. Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | selftune-audit | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.`,
);
}

Expand Down Expand Up @@ -4668,7 +4715,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 <github-login> [--json]
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
loopover-mcp contributor-profile [--login <github-login>] [--json]
Expand Down
44 changes: 44 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1807,6 +1825,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<ToolPayload> {
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),
Expand Down
2 changes: 1 addition & 1 deletion test/unit/mcp-cli-basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')",
);
});

Expand Down
22 changes: 22 additions & 0 deletions test/unit/mcp-cli-maintain-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,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/);
});
});
19 changes: 19 additions & 0 deletions test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
1 change: 1 addition & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];

Expand Down
70 changes: 70 additions & 0 deletions test/unit/mcp-selftune-override-audit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading