diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 9c6a26fd35..6896445b78 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -970,6 +970,11 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Return the reviewability report for an open PR: how ready it is to review/merge, the blocking or advisory signals against it, and its lane/duplicate/linked-issue context. Metadata-only, no GitHub writes.", }, + { + name: "loopover_get_gate_config_effective", + category: "maintainer", + description: "Return a repo's CURRENT effective self-tuned gate thresholds (confidence floor + file/line scope cap) and whether a shadow recommendation is soaking. Resolved effective values only — no override audit history or queued shadow recommendation. Metadata-only, no GitHub writes.", + }, { name: "loopover_get_pr_ai_review_findings", category: "review", @@ -1495,6 +1500,20 @@ registerStdioTool( }, ); +// #7800: CLI mirror of the remote server's loopover_get_gate_config_effective. The route is the single source +// of truth; this tool proxies owner/repo to GET /v1/repos/:owner/:repo/gate-config/effective via apiGet. +registerStdioTool( + "loopover_get_gate_config_effective", + { + description: stdioToolDescription("loopover_get_gate_config_effective"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }: any) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver effective gate config.", await apiGet(`${prefix}/gate-config/effective`)); + }, +); + // #6619: CLI mirror of the remote server's loopover_get_pr_ai_review_findings. The route is the single source // of truth (it delegates to the same loadPrAiReviewFindings the MCP server uses); this tool only resolves the // author login and proxies. Self-scoped: the route's requireContributorAccess rejects another login's PR. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0d178b1b3f..a1c95ee445 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -189,6 +189,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 { loadOverride, loadShadowOverride, type StorageEnv } from "../review/auto-apply"; type AppContext = Context<{ Bindings: Env }>; type ToolPayload = { @@ -988,6 +989,21 @@ const freshnessResponseOutputSchema = { report: z.unknown().optional(), }; +// #7800 — read-only view of a repo's CURRENT effective self-tuned gate thresholds. Mirrors the shape the +// GET /v1/repos/:owner/:repo/gate-config/effective route returns: the resolved effective values only (never +// the raw override_audit history or the shadow's queued recommendation), plus a flag that a shadow is soaking. +const gateConfigEffectiveOutputSchema = { + status: z.string().optional(), + repoFullName: z.string().optional(), + effective: z + .object({ + confidenceFloor: z.number().nullable().optional(), + scopeCap: z.object({ files: z.number().nullable().optional(), lines: z.number().nullable().optional() }).optional(), + }) + .optional(), + shadowPending: z.boolean().optional(), +}; + const maintainerMeasurementReportOutputSchema = { repoFullName: z.string().optional(), generatedAt: z.string().optional(), @@ -1856,6 +1872,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_upstream_drift: "utility", loopover_get_issue_quality: "maintainer", loopover_get_pr_reviewability: "review", + loopover_get_gate_config_effective: "maintainer", loopover_validate_linked_issue: "discovery", loopover_check_before_start: "discovery", loopover_find_opportunities: "discovery", @@ -2386,6 +2403,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getPrReviewability(input)), ); + register( + "loopover_get_gate_config_effective", + { + description: + "Return a repo's CURRENT effective self-tuned gate thresholds (confidence floor + file/line scope cap) and whether a shadow recommendation is soaking. Resolved effective values only — never the override audit history or the queued shadow recommendation. Repo-scoped, metadata-only, no GitHub writes.", + inputSchema: ownerRepoShape, + outputSchema: gateConfigEffectiveOutputSchema, + }, + async (input) => this.toolResult(await this.getGateConfigEffective(input)), + ); + register( "loopover_validate_linked_issue", { @@ -3336,6 +3364,36 @@ export class LoopoverMcp { }; } + // #7800 — the MCP mirror of GET /v1/repos/:owner/:repo/gate-config/effective. Same repo-scoped read gating + // the reviewability tool uses (canAccessRepo scopes the shared static mcp identity to MCP_READ_REPO_ALLOWLIST); + // returns the identical resolved-effective shape the route does by calling the same loadOverride + + // loadShadowOverride, never the raw override audit history or the queued shadow recommendation. + private async getGateConfigEffective(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + if (!(await this.canAccessRepo(fullName))) { + return { + summary: `Forbidden: session cannot access gate config for ${fullName}.`, + data: { status: "forbidden", repoFullName: fullName }, + }; + } + const storageEnv = this.env as unknown as StorageEnv; + const [override, shadow] = await Promise.all([loadOverride(storageEnv, fullName), loadShadowOverride(storageEnv, fullName)]); + return { + summary: `LoopOver effective gate config for ${fullName}.`, + data: { + repoFullName: fullName, + effective: { + confidenceFloor: override?.confidenceFloor ?? null, + scopeCap: { + files: override?.scopeCap?.files ?? null, + lines: override?.scopeCap?.lines ?? null, + }, + }, + shadowPending: shadow !== null, + }, + }; + } + private async validateLinkedIssue(input: { owner: string; repo: string; diff --git a/test/unit/mcp-cli-gate-config-effective.test.ts b/test/unit/mcp-cli-gate-config-effective.test.ts new file mode 100644 index 0000000000..0d7ddc52a6 --- /dev/null +++ b/test/unit/mcp-cli-gate-config-effective.test.ts @@ -0,0 +1,100 @@ +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"; + +// (#7800) In-process coverage of the stdio loopover_get_gate_config_effective proxy. The bin ends with +// `await server.connect(new StdioServerTransport())` at module scope, so we mock StdioServerTransport to hand +// the imported module an in-memory transport we control, then drive its tool surface with a real MCP client. +// This is the only way to instrument bin/loopover-mcp.ts's new lines -- subprocess spawn (the sibling +// mcp-cli-pr-reviewability.test.ts) is functionally faithful but not coverage-instrumented. +const holder = vi.hoisted(() => ({ serverTransport: undefined as any })); +vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ + StdioServerTransport: class { + constructor() { + return holder.serverTransport as any; + } + }, +})); + +const FORBIDDEN_PUBLIC_TERMS = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +let client: Client; +let configDir: string; +let capturedRequests: Array<{ url: string; method: string }>; +const ENV_KEYS = ["LOOPOVER_CONFIG_DIR", "LOOPOVER_API_URL", "LOOPOVER_TOKEN", "LOOPOVER_API_TIMEOUT_MS"] as const; +const savedEnv: Record = {}; + +beforeAll(async () => { + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; + configDir = mkdtempSync(join(tmpdir(), "loopover-gate-config-effective-")); + capturedRequests = []; + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/gate-config/effective")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + process.env.LOOPOVER_CONFIG_DIR = configDir; + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_TOKEN = "session-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "5000"; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + holder.serverTransport = serverTransport; + + // cliArgs[0] === undefined skips the module's `if (cliArgs[0] && cliArgs[0] !== "--stdio")` CLI-dispatch + // guard (which would runCli + process.exit), so importing just registers the tools and connects our + // in-memory transport instead of a real stdio one. + const originalArgv = process.argv; + process.argv = [process.execPath, "loopover-mcp"]; + // Import the .ts source explicitly (not the .js): a committed/build-artifact .js on disk would otherwise be + // resolved and instrumented under its .js path, so codecov/patch would map the new lines to the wrong file. + // A non-literal specifier keeps tsc from rejecting the .ts extension (TS5097) while vitest still loads it. + const binTsModule = "../../packages/loopover-mcp/bin/loopover-mcp.ts"; + await import(/* @vite-ignore */ binTsModule); + process.argv = originalArgv; + + client = new Client({ name: "gate-config-effective-test", version: "0.0.1" }); + await client.connect(clientTransport); +}); + +afterAll(async () => { + await client?.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } +}); + +describe("loopover_get_gate_config_effective stdio proxy (#7800)", () => { + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_gate_config_effective"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/gate thresholds/i); + }); + + it("proxies owner/repo to /gate-config/effective via apiGet and returns the payload", async () => { + const result = await client.callTool({ + name: "loopover_get_gate_config_effective", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/gate-config/effective"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("owner/repo"); + expect(text).toContain("confidenceFloor"); + expect(text).toContain("shadowPending"); + }); +}); diff --git a/test/unit/mcp-gate-config-effective.test.ts b/test/unit/mcp-gate-config-effective.test.ts new file mode 100644 index 0000000000..890aa8774d --- /dev/null +++ b/test/unit/mcp-gate-config-effective.test.ts @@ -0,0 +1,71 @@ +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 { writeLiveOverride, writeShadowOverride, type StorageEnv } from "../../src/review/auto-apply"; +import type { AuthIdentity } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env, identity?: AuthIdentity) { + const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "loopover-gate-config-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +type GateConfigResponse = { + status?: string; + repoFullName?: string; + effective?: { confidenceFloor: number | null; scopeCap: { files: number | null; lines: number | null } }; + shadowPending?: boolean; +}; + +describe("MCP loopover_get_gate_config_effective (#7800)", () => { + 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_gate_config_effective", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as GateConfigResponse; + expect(data.status).toBe("forbidden"); + expect(data.repoFullName).toBe("owner/repo"); + }); + + it("returns null effective thresholds and shadowPending false when no override is soaking", async () => { + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as GateConfigResponse; + expect(data.repoFullName).toBe("owner/repo"); + expect(data.effective).toEqual({ confidenceFloor: null, scopeCap: { files: null, lines: null } }); + expect(data.shadowPending).toBe(false); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + + it("returns the resolved live override values and flags a soaking shadow", async () => { + const env = createTestEnv(); + await writeLiveOverride(env as unknown as StorageEnv, "owner/repo", { confidenceFloor: 0.85, scopeCap: { files: 20, lines: 400 } }); + await writeShadowOverride(env as unknown as StorageEnv, "owner/repo", { confidenceFloor: 0.9 }, "2999-01-01T00:00:00.000Z"); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as GateConfigResponse; + expect(data.effective).toEqual({ confidenceFloor: 0.85, scopeCap: { files: 20, lines: 400 } }); + expect(data.shadowPending).toBe(true); + // The queued shadow recommendation itself is never surfaced — only the boolean that one is soaking. + expect(JSON.stringify(data)).not.toContain("0.9"); + }); + + it("nulls the scope cap when the live override sets only a confidence floor", async () => { + const env = createTestEnv(); + await writeLiveOverride(env as unknown as StorageEnv, "owner/repo", { confidenceFloor: 0.7 }); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } }); + const data = result.structuredContent as GateConfigResponse; + expect(data.effective).toEqual({ confidenceFloor: 0.7, scopeCap: { files: null, lines: null } }); + expect(data.shadowPending).toBe(false); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index b1195bc93d..fb011db8a7 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -23,6 +23,7 @@ // (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.) // (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.) // (#7764 registered the loopover_plan_repo_issues stdio + CLI + REST tool, taking the count from 80 to 81.) +// (#7800 registered the loopover_get_gate_config_effective stdio tool, taking the count from 82 to 83.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -70,14 +71,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 81 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 83 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(81); + expect(primary.length).toBe(83); expect(legacy.length).toBe(0); - expect(names.length).toBe(81); + expect(names.length).toBe(83); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -89,14 +90,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 81-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 83-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(81); + expect(payload.count).toBe(83); 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 4578e7db4c..ef3c5640af 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -859,6 +859,17 @@ export async function startFixtureServer( response.end(JSON.stringify({ repoFullName: "acme/widgets", summary: "Ranked 2 scenarios.", scenarios: [{ id: "close_stale", rank: 1 }] })); return; } + // #7800: read-only effective self-tuned gate thresholds. Mirrors the route's resolved-effective shape. + if (request.url === "/v1/repos/owner/repo/gate-config/effective" && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + effective: { confidenceFloor: 0.85, scopeCap: { files: 20, lines: 400 } }, + shadowPending: false, + }), + ); + return; + } if (request.url === "/v1/repos/owner/repo/pulls/7/reviewability" && request.method === "GET") { response.end( JSON.stringify({