Skip to content
Merged
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
18 changes: 18 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
54 changes: 54 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1887,6 +1903,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -3388,6 +3416,32 @@ export class LoopoverMcp {
};
}

private async getLiveGateThresholds(input: { owner: string; repo: string }): Promise<ToolPayload> {
// 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<string, unknown>,
};
}

private async validateLinkedIssue(input: {
owner: string;
repo: string;
Expand Down
2 changes: 2 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
{
Expand Down
80 changes: 80 additions & 0 deletions test/unit/mcp-cli-live-gate-thresholds.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
};

let tempDir = "";
const capturedRequests: Array<{ url: string; method: string }> = [];
const loaded = new Map<string, BinModule>();

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);
}
});
});
36 changes: 36 additions & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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<string, unknown>);
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<string, unknown>);
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 () => {
Expand Down Expand Up @@ -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" });
Expand Down
11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down
12 changes: 12 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down