From 1b4bbbf3207c3a7678b71bcbbe1af6b46426f301 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Wed, 22 Jul 2026 02:19:08 +0800 Subject: [PATCH] feat(mcp): add remote + stdio surfaces for loopover_get_live_gate_thresholds Mirror loopover_get_pr_reviewability auth (mcp read allowlist) and the REST live-gate-thresholds projection on both remote MCP and local stdio. Cover found / not-found / forbidden remotely and instrument the bin via the #7764 in-process exported-server pattern so codecov/patch sees the new lines. Closes #7801 Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.ts | 18 +++++ src/mcp/server.ts | 54 +++++++++++++ test/integration/api.test.ts | 2 + .../unit/mcp-cli-live-gate-thresholds.test.ts | 80 +++++++++++++++++++ test/unit/mcp-output-schemas.test.ts | 36 +++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 11 +-- test/unit/support/mcp-cli-harness.ts | 12 +++ 7 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 test/unit/mcp-cli-live-gate-thresholds.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 0d52338e0..e4b917072 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_live_gate_thresholds", + category: "maintainer", + description: + "Return the currently-authoritative live gate thresholds for a repo (confidence floor and scope caps) as a field-limited snake_case AMS probe. Live override wins; soaking shadow fills in only when live is absent. Metadata-only; takes owner and repo.", + }, { name: "loopover_preflight_pr", category: "discovery", @@ -1554,6 +1560,18 @@ registerStdioTool( }, ); +registerStdioTool( + "loopover_get_live_gate_thresholds", + { + description: stdioToolDescription("loopover_get_live_gate_thresholds"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }: any) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver live gate thresholds.", await apiGet(`${prefix}/live-gate-thresholds`)); + }, +); + registerStdioTool( "loopover_get_issue_quality", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 86a132aca..e73384aad 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -185,6 +185,13 @@ import { buildRepoDataQuality } from "../signals/data-quality"; import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { loadUpstreamStatus } from "../upstream/ruleset"; +import { + authoritativeGateOverride, + loadOverride, + loadShadowOverride, + toLiveGateThresholdFields, + type StorageEnv, +} from "../review/auto-apply"; import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/open-pr-pressure-scenarios"; import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/finding-taxonomy"; import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy"; @@ -989,6 +996,15 @@ const freshnessResponseOutputSchema = { report: z.unknown().optional(), }; +const liveGateThresholdsOutputSchema = { + repoFullName: z.string().optional(), + confidence_floor: z.number().nullable().optional(), + scope_cap_files: z.number().nullable().optional(), + scope_cap_lines: z.number().nullable().optional(), + error: z.string().optional(), + status: z.string().optional(), +}; + const maintainerMeasurementReportOutputSchema = { repoFullName: z.string().optional(), generatedAt: z.string().optional(), @@ -1887,6 +1903,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_upstream_ruleset: "utility", loopover_get_issue_quality: "maintainer", loopover_get_pr_reviewability: "review", + loopover_get_live_gate_thresholds: "maintainer", loopover_validate_linked_issue: "discovery", loopover_check_before_start: "discovery", loopover_find_opportunities: "discovery", @@ -2438,6 +2455,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getPrReviewability(input)), ); + register( + "loopover_get_live_gate_thresholds", + { + description: + "Return the currently-authoritative live gate thresholds for a repo (confidence floor and scope caps) as a field-limited snake_case AMS probe. Live override wins; soaking shadow fills in only when live is absent. Metadata-only, repo-scoped, no GitHub writes.", + inputSchema: ownerRepoShape, + outputSchema: liveGateThresholdsOutputSchema, + }, + async (input) => this.toolResult(await this.getLiveGateThresholds(input)), + ); + register( "loopover_validate_linked_issue", { @@ -3388,6 +3416,32 @@ export class LoopoverMcp { }; } + private async getLiveGateThresholds(input: { owner: string; repo: string }): Promise { + // Mirrors GET /v1/repos/:owner/:repo/live-gate-thresholds: same mcp allowlist gate as reviewability, + // same authoritative live/shadow projection, and a normal not-found result (never throw) when neither + // override is active — same error code the REST route uses. + const fullName = `${input.owner}/${input.repo}`; + if (!(await this.canAccessRepo(fullName))) { + return { + summary: `Forbidden: session cannot access live gate thresholds for ${fullName}.`, + data: { status: "forbidden", repoFullName: fullName }, + }; + } + const storageEnv = this.env as unknown as StorageEnv; + const [live, shadow] = await Promise.all([loadOverride(storageEnv, fullName), loadShadowOverride(storageEnv, fullName)]); + const fields = toLiveGateThresholdFields(authoritativeGateOverride(live, shadow)); + if (!fields) { + return { + summary: `No live gate thresholds are active for ${fullName}.`, + data: { error: "live_gate_thresholds_not_found", repoFullName: fullName }, + }; + } + return { + summary: `Live gate thresholds for ${fullName}.`, + data: { repoFullName: fullName, ...fields } 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..e35711f2e 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5528,6 +5528,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_get_registry_snapshot"); expect(toolNames).toContain("loopover_get_upstream_drift"); expect(toolNames).toContain("loopover_get_upstream_ruleset"); + expect(toolNames).toContain("loopover_get_live_gate_thresholds"); expect(toolNames).toContain("loopover_explain_review_risk"); expect(toolNames).toContain("loopover_compare_pr_variants"); expect(toolNames).toContain("loopover_local_status"); @@ -5802,6 +5803,7 @@ describe("api routes", () => { ["loopover_get_registry_snapshot", {}], ["loopover_get_upstream_drift", {}], ["loopover_get_upstream_ruleset", {}], + ["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }], [ "loopover_preview_local_pr_score", { diff --git a/test/unit/mcp-cli-live-gate-thresholds.test.ts b/test/unit/mcp-cli-live-gate-thresholds.test.ts new file mode 100644 index 000000000..0c6c06302 --- /dev/null +++ b/test/unit/mcp-cli-live-gate-thresholds.test.ts @@ -0,0 +1,80 @@ +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"; + +// #7801: in-process coverage for the loopover_get_live_gate_thresholds stdio tool. +// Same #7764 entrypoint-guard pattern as mcp-cli-registry-snapshot / upstream-ruleset — import the .ts +// source, hold the exported `server`, connect InMemoryTransport so v8/Codecov attributes registerStdioTool. +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-live-gate-thresholds-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/live-gate-thresholds")) { + 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_live_gate_thresholds stdio tool (in-process, #7801)", () => { + it.each(MODULES)("registers and proxies GET .../live-gate-thresholds — %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: "live-gate-thresholds-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_live_gate_thresholds"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/live gate thresholds/i); + + const result = await client.callTool({ + name: "loopover_get_live_gate_thresholds", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/live-gate-thresholds"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).toContain("confidence_floor"); + expect(text).toContain("0.91"); + } 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 28a40fe52..34bde749c 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it, vi } from "vitest"; import { persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, updatePullRequestSlopAssessment, persistUpstreamRulesetSnapshot } from "../../src/db/repositories"; +import { writeLiveOverride, type StorageEnv } from "../../src/review/auto-apply"; import type { AuthIdentity } from "../../src/auth/security"; import { LoopoverMcp } from "../../src/mcp/server"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; @@ -14,6 +15,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_get_repo_context", "loopover_get_maintainer_noise", "loopover_get_activation_preview", + "loopover_get_live_gate_thresholds", "loopover_get_label_audit", "loopover_get_maintainer_lane", "loopover_get_repo_onboarding_pack", @@ -147,6 +149,10 @@ describe("MCP output schema discovery", () => { const upstreamRuleset = byName.get("loopover_get_upstream_ruleset"); const upstreamRulesetProps = Object.keys((upstreamRuleset?.outputSchema?.properties ?? {}) as Record); expect(upstreamRulesetProps).toEqual(expect.arrayContaining(["id", "activeModel", "registryRepoCount", "payload", "error"])); + + const liveGate = byName.get("loopover_get_live_gate_thresholds"); + const liveGateProps = Object.keys((liveGate?.outputSchema?.properties ?? {}) as Record); + expect(liveGateProps).toEqual(expect.arrayContaining(["repoFullName", "confidence_floor", "scope_cap_files", "scope_cap_lines", "error"])); }); it("preserves the full tool inventory while adding output schemas", async () => { @@ -310,6 +316,36 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); }); + it("loopover_get_live_gate_thresholds returns authoritative thresholds when a live override exists (#7801)", async () => { + const env = createTestEnv(); + await writeLiveOverride(env as unknown as StorageEnv, "octo/demo", { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ name: "loopover_get_live_gate_thresholds", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ + repoFullName: "octo/demo", + confidence_floor: 0.91, + scope_cap_files: 8, + scope_cap_lines: 250, + }); + }); + + it("loopover_get_live_gate_thresholds returns a normal not-found result when empty (#7801)", async () => { + const { client } = await connectTestClient(createTestEnv()); + const result = await client.callTool({ name: "loopover_get_live_gate_thresholds", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ error: "live_gate_thresholds_not_found", repoFullName: "octo/demo" }); + }); + + it("loopover_get_live_gate_thresholds denies mcp callers outside the read allowlist (#7801)", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }); + await writeLiveOverride(env as unknown as StorageEnv, "octo/demo", { confidenceFloor: 0.9 }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ name: "loopover_get_live_gate_thresholds", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "octo/demo" }); + }); + it("loopover_get_activation_preview denies cached member-only session access (#7799)", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim-org/private-repo", private: true, owner: { login: "victim-org" }, default_branch: "main" }); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index e2d926ea3..5b009c732 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.) +// (#7801 registered the loopover_get_live_gate_thresholds remote+stdio tool, 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(tools.length); - expect(payload.count).toBe(84); + expect(payload.count).toBe(85); 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..9a360fa6b 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -571,6 +571,18 @@ export async function startFixtureServer( ); return; } + // #7801: AMS probe for live gate thresholds (snake_case fields). Auth required in prod; fixture is open. + if (request.url === "/v1/repos/owner/repo/live-gate-thresholds" && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + confidence_floor: 0.91, + scope_cap_files: 8, + scope_cap_lines: 250, + }), + ); + return; + } if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") { response.end( JSON.stringify({