diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 3bad6ac30a..94acc58f9c 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1302,6 +1302,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_gate_config_effective", + category: "maintainer", + description: "Return a repo's current effective self-tuned gate thresholds (confidenceFloor, scopeCap) resolved from the live override, plus whether a shadow recommendation is soaking. Read-only, no GitHub writes.", + }, { name: "loopover_open_pr", category: "agent", @@ -2623,6 +2628,20 @@ registerStdioTool( return toolResult(`Gate precision for ${owner}/${repo}.`, payload); }, ); + +// #7800 - CLI mirror of the remote server's loopover_get_gate_config_effective. GET .../gate-config/effective +// is the single source of truth (auto-apply.ts's loadOverride/loadShadowOverride pair); this tool only proxies. +registerStdioTool( + "loopover_get_gate_config_effective", + { + description: stdioToolDescription("loopover_get_gate_config_effective"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }: any) => { + const payload = await apiGet(`${toolRepoBase(owner, repo)}/gate-config/effective`); + return toolResult(`Effective gate config for ${owner}/${repo}.`, 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. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85d59a46ee..6a2b5ab84e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -115,6 +115,7 @@ import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/mai import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse } from "../api/routes"; import { loadGatePrecisionReport } from "../services/gate-precision"; +import { loadOverride, loadShadowOverride, type StorageEnv } from "../review/auto-apply"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { applyMcpPlanningChoices, @@ -993,6 +994,16 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +// #7800 - mirrors the REST route's response shape (`GET /v1/repos/:owner/:repo/gate-config/effective`, +// src/api/routes.ts) exactly: no freshness/report wrapper, just the resolved effective thresholds plus a +// forbidden/status escape hatch for the canAccessRepo failure path (matching getIssueQuality/getPrReviewability). +const gateConfigEffectiveOutputSchema = { + status: z.string().optional(), + repoFullName: z.string().optional(), + effective: z.unknown().optional(), + shadowPending: z.boolean().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 @@ -1838,6 +1849,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", @@ -2357,6 +2369,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 (confidenceFloor, scopeCap) resolved from the live override, plus whether a shadow recommendation is soaking. Read-only, repo-scoped, no GitHub writes.", + inputSchema: ownerRepoShape, + outputSchema: gateConfigEffectiveOutputSchema, + }, + async (input) => this.toolResult(await this.getGateConfigEffective(input)), + ); + register( "loopover_validate_linked_issue", { @@ -3282,6 +3305,39 @@ export class LoopoverMcp { }; } + // #7800 - mirrors GET /v1/repos/:owner/:repo/gate-config/effective (src/api/routes.ts), which is itself + // "gated behind the same most-conservative repo-scoped read precedent the reviewability route (#6154) + // uses" per that route's own comment; canAccessRepo below is that identical precedent, already enforced + // by every other ownerRepoShape maintainer tool (getIssueQuality, getGatePrecision). Calls the same + // loadOverride/loadShadowOverride pair the route calls and returns the identical response shape - + // loadOverride/loadShadowOverride stay the single source of truth for both surfaces. + 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)]); + const shadowPending = shadow !== null; + return { + summary: `LoopOver effective gate config for ${fullName}: confidenceFloor=${override?.confidenceFloor ?? "n/a"}, shadowPending=${shadowPending}.`, + data: { + repoFullName: fullName, + effective: { + confidenceFloor: override?.confidenceFloor ?? null, + scopeCap: { + files: override?.scopeCap?.files ?? null, + lines: override?.scopeCap?.lines ?? null, + }, + }, + shadowPending, + } as unknown as Record, + }; + } + 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..ce94624958 --- /dev/null +++ b/test/unit/mcp-cli-gate-config-effective.test.ts @@ -0,0 +1,88 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// Matches the other raw-StdioClientTransport CLI tests (mcp-cli-pr-reviewability.test.ts, +// mcp-cli-maintain-tools.test.ts): declares its own .js-suffixed bin rather than the harness's +// strip-types-oriented export, relying on `npm run build:mcp` having already run (test:ci always runs +// it before test:coverage). +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +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 transport: StdioClientTransport; +let configDir: string; +let apiUrl: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect(gateConfigEffective?: Record) { + configDir = mkdtempSync(join(tmpdir(), "loopover-gate-config-effective-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + ...(gateConfigEffective ? { gateConfigEffective } : {}), + onApiRequest: (request) => { + if (request.url && request.url.includes("/gate-config/effective")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "gate-config-effective-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("loopover_get_gate_config_effective stdio proxy (#7800)", () => { + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + await connect(); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain("loopover_get_gate_config_effective"); + }); + + it("proxies owner/repo to /v1/repos/:owner/:repo/gate-config/effective via apiGet", async () => { + await connect(); + 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"); + }); + + it("surfaces a soaking shadow flag without leaking its queued recommendation", async () => { + await connect({ + repoFullName: "owner/repo", + effective: { confidenceFloor: null, scopeCap: { files: null, lines: null } }, + shadowPending: true, + }); + const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).toContain("shadowPending\":true"); + }); +}); 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..9ff8c1e6b2 --- /dev/null +++ b/test/unit/mcp-gate-config-effective.test.ts @@ -0,0 +1,88 @@ +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 { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; +const REPO_FULL_NAME = "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-gate-config-effective-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +type GateConfigEffectiveResponse = { + status?: string; + repoFullName?: string; + effective?: { confidenceFloor: number | null; scopeCap: { files: number | null; lines: number | null } }; + shadowPending?: boolean; +}; + +// #7800 - mirrors GET /v1/repos/:owner/:repo/gate-config/effective's own integration test (#6247), adapted +// to the MCP surface's canAccessRepo/forbidden-payload convention (matching loopover_get_pr_reviewability +// and loopover_get_issue_quality) rather than the REST route's 401/403 status codes. +describe("MCP loopover_get_gate_config_effective (#7800)", () => { + it("resolves all-null effective thresholds and no soaking shadow when nothing is overridden", async () => { + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as GateConfigEffectiveResponse; + expect(data).toEqual({ + repoFullName: REPO_FULL_NAME, + effective: { confidenceFloor: null, scopeCap: { files: null, lines: null } }, + shadowPending: false, + }); + // The nullish branch of the summary's confidenceFloor ?? fallback (no override at all). + expect(JSON.stringify(result.content)).toContain("confidenceFloor=n/a"); + }); + + it("resolves a live override plus a soaking shadow, never leaking the shadow's queued recommendation", async () => { + const env = createTestEnv(); + const storageEnv = env as unknown as StorageEnv; + await writeLiveOverride(storageEnv, REPO_FULL_NAME, { confidenceFloor: 0.9, scopeCap: { files: 12, lines: 400 } }); + await writeShadowOverride(storageEnv, REPO_FULL_NAME, { confidenceFloor: 0.8 }, "2099-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: "widgets" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as GateConfigEffectiveResponse; + expect(data).toEqual({ + repoFullName: REPO_FULL_NAME, + effective: { confidenceFloor: 0.9, scopeCap: { files: 12, lines: 400 } }, + shadowPending: true, + }); + expect(JSON.stringify(data)).not.toMatch(/0\.8/); + // Numeric branch of the summary's ?? fallback. + expect(JSON.stringify(result.content)).toContain("confidenceFloor=0.9"); + }); + + it("resolves both scopeCap fields to null when the live override only carries a confidence floor", async () => { + const env = createTestEnv(); + await writeLiveOverride(env as unknown as StorageEnv, REPO_FULL_NAME, { confidenceFloor: 0.5 }); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "widgets" } }); + const data = result.structuredContent as GateConfigEffectiveResponse; + expect(data).toEqual({ + repoFullName: REPO_FULL_NAME, + effective: { confidenceFloor: 0.5, scopeCap: { files: null, lines: null } }, + shadowPending: false, + }); + }); + + 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: "widgets" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as GateConfigEffectiveResponse; + expect(data.status).toBe("forbidden"); + expect(data.repoFullName).toBe(REPO); + expect(JSON.stringify(result.content)).toContain("Forbidden: session cannot access gate config for owner/widgets."); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 5088d983c7..22d7372a6b 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -22,6 +22,7 @@ // (#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.) // (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.) +// (#7800 registered the loopover_get_gate_config_effective stdio tool, taking the count from 80 to 81.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -69,14 +70,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 80 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 81 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(80); + expect(primary.length).toBe(81); expect(legacy.length).toBe(0); - expect(names.length).toBe(80); + expect(names.length).toBe(81); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -88,14 +89,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 80-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 81-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(80); + expect(payload.count).toBe(81); 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..8188138143 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -192,6 +192,8 @@ export async function startFixtureServer( validateConfigWarnings?: string[]; openPrMonitor?: Record; prOutcomes?: Record; + /** #7800: overrides GET /v1/repos/owner/repo/gate-config/effective's default fixture response. */ + gateConfigEffective?: Record; /** #6980: overrides POST /v1/preflight/review-risk and captures the request body. */ reviewRisk?: Record; onReviewRiskRequest?: (body: unknown) => void; @@ -659,6 +661,20 @@ export async function startFixtureServer( ); return; } + // #7800 gate-config/effective (read-only). Mirrors the route's response shape (repoFullName/effective/ + // shadowPending); a test can override via options.gateConfigEffective to exercise the override branches. + if (request.url === "/v1/repos/owner/repo/gate-config/effective" && request.method === "GET") { + response.end( + JSON.stringify( + options.gateConfigEffective ?? { + repoFullName: "owner/repo", + effective: { confidenceFloor: 0.85, scopeCap: { files: 10, lines: 300 } }, + shadowPending: false, + }, + ), + ); + 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(