From 85adc681e1b2df76dad8f8e187482c626d88891b Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 22 Jul 2026 02:03:38 +0800 Subject: [PATCH] feat(mcp): add remote + stdio surfaces for loopover_get_gate_config_effective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #7800: mirror REST gate-config/effective on remote MCP and local stdio with canAccessRepo/allowlist auth, declare outputSchema (#550), and pin the stdio tool count 84→85. Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.ts | 21 ++++ src/mcp/server.ts | 55 +++++++++++ test/integration/api.test.ts | 2 + .../mcp-cli-gate-config-effective.test.ts | 98 +++++++++++++++++++ test/unit/mcp-gate-config-effective.test.ts | 92 +++++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 11 ++- test/unit/support/mcp-cli-harness.ts | 13 +++ 7 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 test/unit/mcp-cli-gate-config-effective.test.ts create mode 100644 test/unit/mcp-gate-config-effective.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 0d52338e0..74f3f0fd1 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -986,6 +986,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "maintainer", description: "Return the repo's maintainer activation preview: a deterministic run of the advisory engine over recent PRs (evaluated/with-findings counts, distinct finding codes, per-PR samples, current review-check mode, and the single recommended next action). Maintainer-authenticated; advisory only.", }, + { + name: "loopover_get_gate_config_effective", + category: "maintainer", + description: + "Return a repo's current effective self-tuned gate thresholds (confidenceFloor, scopeCap files/lines) plus whether a shadow override is still soaking. Read-only.", + }, { name: "loopover_preflight_pr", category: "discovery", @@ -1554,6 +1560,21 @@ registerStdioTool( }, ); +// (#7800) CLI stdio mirror of the remote loopover_get_gate_config_effective — thin GET proxy of the already +// allowlist-scoped /v1/repos/:owner/:repo/gate-config/effective route (same ownerRepoShape + apiGet pattern +// as activation_preview). No human CLI verb. +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`)); + }, +); + registerStdioTool( "loopover_get_issue_quality", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 86a132aca..4865e9c40 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -173,6 +173,7 @@ import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVerdict } from "../rules/predicted-gate"; export { buildGateDispositions, type GateDisposition } from "../rules/predicted-gate"; +import { loadOverride, loadShadowOverride, type StorageEnv } from "../review/auto-apply"; import { buildIssueSlopAssessment } from "../signals/issue-slop"; import { buildSlopAssessment } from "../signals/slop"; import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; @@ -1011,6 +1012,15 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +// #7800 - effective self-tuned gate thresholds (REST gate-config/effective mirror). Fields optional so +// the forbidden branch ({ status, repoFullName }) and the success shape both validate. +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 @@ -1854,6 +1864,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_gate_config_effective: "maintainer", loopover_get_skipped_pr_audit: "maintainer", loopover_get_fleet_analytics: "maintainer", loopover_get_recommendation_quality: "maintainer", @@ -2084,6 +2095,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGatePrecision(input)), ); + register( + "loopover_get_gate_config_effective", + { + description: + "Return a repo's current effective self-tuned gate thresholds (confidenceFloor, scopeCap files/lines) plus whether a shadow override is still soaking. Read-only; same auth boundary as the REST gate-config/effective route.", + inputSchema: ownerRepoShape, + outputSchema: gateConfigEffectiveOutputSchema, + }, + async (input) => this.toolResult(await this.getGateConfigEffective(input)), + ); + register( "loopover_get_skipped_pr_audit", { @@ -3388,6 +3410,39 @@ export class LoopoverMcp { }; } + // #7800 - thin MCP surface over GET /v1/repos/:owner/:repo/gate-config/effective. Same canAccessRepo / + // isMcpReadRepoAllowed boundary as getPrReviewability; same loadOverride/loadShadowOverride pair the REST + // route already uses — no new persistence or auth path. + 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 effective 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 data = { + repoFullName: fullName, + effective: { + confidenceFloor: override?.confidenceFloor ?? null, + scopeCap: { + files: override?.scopeCap?.files ?? null, + lines: override?.scopeCap?.lines ?? null, + }, + }, + shadowPending: shadow !== null, + }; + return { + summary: `LoopOver effective gate config for ${fullName}: confidenceFloor=${data.effective.confidenceFloor ?? "unset"}, shadowPending=${data.shadowPending}.`, + data: data as unknown as Record, + }; + } + private async validateLinkedIssue(input: { owner: string; repo: string; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 36632b4be..5995c42ef 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5526,6 +5526,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_get_outcome_calibration"); expect(toolNames).toContain("loopover_get_registry_changes"); expect(toolNames).toContain("loopover_get_registry_snapshot"); + expect(toolNames).toContain("loopover_get_gate_config_effective"); expect(toolNames).toContain("loopover_get_upstream_drift"); expect(toolNames).toContain("loopover_get_upstream_ruleset"); expect(toolNames).toContain("loopover_explain_review_risk"); @@ -5800,6 +5801,7 @@ describe("api routes", () => { ], ["loopover_get_registry_changes", {}], ["loopover_get_registry_snapshot", {}], + ["loopover_get_gate_config_effective", { owner: "entrius", repo: "allways-ui" }], ["loopover_get_upstream_drift", {}], ["loopover_get_upstream_ruleset", {}], [ 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 000000000..57ff8323e --- /dev/null +++ b/test/unit/mcp-cli-gate-config-effective.test.ts @@ -0,0 +1,98 @@ +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 } from "vitest"; +import { + closeFixtureServer, + startFixtureServer, +} from "./support/mcp-cli-harness"; + +// #7800: in-process coverage for the loopover_get_gate_config_effective stdio tool. +// Same #7764 entrypoint-guard pattern as mcp-cli-registry-snapshot.test.ts / mcp-cli-activation-preview.test.ts. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + 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-gate-config-effective-")); + 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_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; +}); + +describe("bin loopover_get_gate_config_effective stdio tool (in-process, #7800)", () => { + it.each(MODULES)( + "registers and proxies GET .../gate-config/effective — %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: "gate-config-effective-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_gate_config_effective", + ); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/effective.*gate|gate.*threshold/i); + + const result = await client.callTool({ + name: "loopover_get_gate_config_effective", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(result.isError).toBeFalsy(); + expect(capturedRequests).toEqual([ + { url: "/v1/repos/owner/repo/gate-config/effective", method: "GET" }, + ]); + expect(result.structuredContent).toMatchObject({ + repoFullName: "owner/repo", + effective: { + confidenceFloor: 0.9, + scopeCap: { files: 12, lines: 400 }, + }, + shadowPending: true, + }); + } finally { + await client.close().catch(() => undefined); + } + }, + ); +}); 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 000000000..091a36f36 --- /dev/null +++ b/test/unit/mcp-gate-config-effective.test.ts @@ -0,0 +1,92 @@ +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"; + +type GateConfigResponse = { + status?: string; + repoFullName?: string; + effective?: { + confidenceFloor: number | null; + scopeCap: { files: number | null; lines: number | null }; + }; + shadowPending?: boolean; +}; + +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-test", version: "0.1.0" }, + { capabilities: {} }, + ); + await client.connect(clientTransport); + return client; +} + +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(); + expect((result.structuredContent as GateConfigResponse).status).toBe( + "forbidden", + ); + }); + + it("returns empty effective thresholds when no override is stored", 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(); + expect(result.structuredContent).toMatchObject({ + repoFullName: "owner/repo", + effective: { + confidenceFloor: null, + scopeCap: { files: null, lines: null }, + }, + shadowPending: false, + }); + }); + + it("returns live override values and shadowPending when a shadow is soaking", async () => { + const env = createTestEnv(); + const storageEnv = env as unknown as StorageEnv; + await writeLiveOverride(storageEnv, "owner/repo", { + confidenceFloor: 0.9, + scopeCap: { files: 12, lines: 400 }, + }); + await writeShadowOverride( + storageEnv, + "owner/repo", + { 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: "repo" }, + }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ + repoFullName: "owner/repo", + effective: { confidenceFloor: 0.9, scopeCap: { files: 12, lines: 400 } }, + shadowPending: true, + }); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index e2d926ea3..6db25963c 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -26,6 +26,7 @@ // (#7887 registered loopover_get_activation_preview without bumping this pin — live count became 82.) // (#7803 registered the loopover_get_registry_snapshot remote+stdio tool, taking the count from 82 to 83.) // (#7807 registered the loopover_get_upstream_ruleset remote+stdio tool, taking the count from 83 to 84.) +// (#7800 registered loopover_get_gate_config_effective remote+stdio, taking the count from 84 to 85.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -72,14 +73,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 84 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 85 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(84); + expect(primary.length).toBe(85); expect(legacy.length).toBe(0); - expect(names.length).toBe(84); + expect(names.length).toBe(85); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -91,14 +92,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 84-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 85-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(85); expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(84); 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 30b9df81d..8a5072164 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -571,6 +571,19 @@ export async function startFixtureServer( ); return; } + if (request.url === "/v1/repos/owner/repo/gate-config/effective" && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + effective: { + confidenceFloor: 0.9, + scopeCap: { files: 12, lines: 400 }, + }, + shadowPending: true, + }), + ); + return; + } if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") { response.end( JSON.stringify({