Skip to content

Commit 03f9d9c

Browse files
feat(mcp): add remote + stdio surfaces for loopover_get_gate_config_effective (#7928)
Mirror loopover_get_pr_reviewability auth (mcp read allowlist) and the REST gate-config/effective shape on both remote MCP and local stdio. Cover empty / populated / forbidden remotely and instrument the bin via the #7764 in-process exported-server pattern so codecov/patch sees the new lines. Closes #7800 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1257c20 commit 03f9d9c

7 files changed

Lines changed: 204 additions & 6 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,12 @@ const STDIO_TOOL_DESCRIPTORS = [
998998
description:
999999
"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.",
10001000
},
1001+
{
1002+
name: "loopover_get_gate_config_effective",
1003+
category: "maintainer",
1004+
description:
1005+
"Return a repo's current effective self-tuned gate thresholds (confidenceFloor, scopeCap) plus whether a shadow override is soaking. Metadata-only; takes owner and repo.",
1006+
},
10011007
{
10021008
name: "loopover_preflight_pr",
10031009
category: "discovery",
@@ -1590,6 +1596,18 @@ registerStdioTool(
15901596
},
15911597
);
15921598

1599+
registerStdioTool(
1600+
"loopover_get_gate_config_effective",
1601+
{
1602+
description: stdioToolDescription("loopover_get_gate_config_effective"),
1603+
inputSchema: ownerRepoShape,
1604+
},
1605+
async ({ owner, repo }: any) => {
1606+
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
1607+
return toolResult("LoopOver effective gate config.", await apiGet(`${prefix}/gate-config/effective`));
1608+
},
1609+
);
1610+
15931611
registerStdioTool(
15941612
"loopover_get_issue_quality",
15951613
{

src/mcp/server.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,13 @@ const liveGateThresholdsOutputSchema = {
10061006
status: z.string().optional(),
10071007
};
10081008

1009+
const gateConfigEffectiveOutputSchema = {
1010+
repoFullName: z.string().optional(),
1011+
effective: z.unknown().optional(),
1012+
shadowPending: z.boolean().optional(),
1013+
status: z.string().optional(),
1014+
};
1015+
10091016
const maintainerMeasurementReportOutputSchema = {
10101017
repoFullName: z.string().optional(),
10111018
generatedAt: z.string().optional(),
@@ -1906,6 +1913,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
19061913
loopover_get_pr_reviewability: "review",
19071914
loopover_get_pr_maintainer_packet: "review",
19081915
loopover_get_live_gate_thresholds: "maintainer",
1916+
loopover_get_gate_config_effective: "maintainer",
19091917
loopover_validate_linked_issue: "discovery",
19101918
loopover_check_before_start: "discovery",
19111919
loopover_find_opportunities: "discovery",
@@ -2479,6 +2487,17 @@ export class LoopoverMcp {
24792487
async (input) => this.toolResult(await this.getLiveGateThresholds(input)),
24802488
);
24812489

2490+
register(
2491+
"loopover_get_gate_config_effective",
2492+
{
2493+
description:
2494+
"Return a repo's current effective self-tuned gate thresholds (confidenceFloor, scopeCap) plus whether a shadow override is soaking. Metadata-only, repo-scoped, no GitHub writes.",
2495+
inputSchema: ownerRepoShape,
2496+
outputSchema: gateConfigEffectiveOutputSchema,
2497+
},
2498+
async (input) => this.toolResult(await this.getGateConfigEffective(input)),
2499+
);
2500+
24822501
register(
24832502
"loopover_validate_linked_issue",
24842503
{
@@ -3497,6 +3516,35 @@ export class LoopoverMcp {
34973516
};
34983517
}
34993518

3519+
private async getGateConfigEffective(input: { owner: string; repo: string }): Promise<ToolPayload> {
3520+
// Mirrors GET /v1/repos/:owner/:repo/gate-config/effective: same mcp allowlist gate as reviewability,
3521+
// same loadOverride/loadShadowOverride projection, always returning the effective + shadowPending shape
3522+
// (nulls when no live override — never a not-found throw).
3523+
const fullName = `${input.owner}/${input.repo}`;
3524+
if (!(await this.canAccessRepo(fullName))) {
3525+
return {
3526+
summary: `Forbidden: session cannot access effective gate config for ${fullName}.`,
3527+
data: { status: "forbidden", repoFullName: fullName },
3528+
};
3529+
}
3530+
const storageEnv = this.env as unknown as StorageEnv;
3531+
const [override, shadow] = await Promise.all([loadOverride(storageEnv, fullName), loadShadowOverride(storageEnv, fullName)]);
3532+
return {
3533+
summary: `Effective gate config for ${fullName}.`,
3534+
data: {
3535+
repoFullName: fullName,
3536+
effective: {
3537+
confidenceFloor: override?.confidenceFloor ?? null,
3538+
scopeCap: {
3539+
files: override?.scopeCap?.files ?? null,
3540+
lines: override?.scopeCap?.lines ?? null,
3541+
},
3542+
},
3543+
shadowPending: shadow !== null,
3544+
},
3545+
};
3546+
}
3547+
35003548
private async validateLinkedIssue(input: {
35013549
owner: string;
35023550
repo: string;

test/integration/api.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5529,6 +5529,7 @@ describe("api routes", () => {
55295529
expect(toolNames).toContain("loopover_get_upstream_drift");
55305530
expect(toolNames).toContain("loopover_get_upstream_ruleset");
55315531
expect(toolNames).toContain("loopover_get_live_gate_thresholds");
5532+
expect(toolNames).toContain("loopover_get_gate_config_effective");
55325533
expect(toolNames).toContain("loopover_get_pr_maintainer_packet");
55335534
expect(toolNames).toContain("loopover_explain_review_risk");
55345535
expect(toolNames).toContain("loopover_compare_pr_variants");
@@ -5805,6 +5806,7 @@ describe("api routes", () => {
58055806
["loopover_get_upstream_drift", {}],
58065807
["loopover_get_upstream_ruleset", {}],
58075808
["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }],
5809+
["loopover_get_gate_config_effective", { owner: "entrius", repo: "allways-ui" }],
58085810
["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }],
58095811
[
58105812
"loopover_preview_local_pr_score",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// #7800: in-process coverage for the loopover_get_gate_config_effective stdio tool.
10+
// Same #7764 entrypoint-guard pattern as mcp-cli-live-gate-thresholds — import .ts, hold exported `server`,
11+
// connect InMemoryTransport so v8/Codecov attributes registerStdioTool.
12+
const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const;
13+
14+
type BinModule = {
15+
server: { connect: (transport: unknown) => Promise<void> };
16+
};
17+
18+
let tempDir = "";
19+
const capturedRequests: Array<{ url: string; method: string }> = [];
20+
const loaded = new Map<string, BinModule>();
21+
22+
beforeAll(async () => {
23+
tempDir = mkdtempSync(join(tmpdir(), "loopover-gate-config-effective-"));
24+
const apiUrl = await startFixtureServer({
25+
onApiRequest: (request) => {
26+
if (request.url && request.url.includes("/gate-config/effective")) {
27+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
28+
}
29+
},
30+
});
31+
process.env.LOOPOVER_API_URL = apiUrl;
32+
process.env.LOOPOVER_API_TOKEN = "in-process-token";
33+
process.env.LOOPOVER_API_TIMEOUT_MS = "2000";
34+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
35+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
36+
for (const specifier of MODULES) {
37+
loaded.set(specifier, (await import(specifier)) as unknown as BinModule);
38+
}
39+
}, 120_000);
40+
41+
afterAll(async () => {
42+
await closeFixtureServer();
43+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
44+
delete process.env.LOOPOVER_API_URL;
45+
delete process.env.LOOPOVER_API_TOKEN;
46+
delete process.env.LOOPOVER_CONFIG_DIR;
47+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
48+
});
49+
50+
describe("bin loopover_get_gate_config_effective stdio tool (in-process, #7800)", () => {
51+
it.each(MODULES)("registers and proxies GET .../gate-config/effective — %s", async (specifier) => {
52+
capturedRequests.length = 0;
53+
const mod = loaded.get(specifier)!;
54+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
55+
await mod.server.connect(serverTransport);
56+
const client = new Client({ name: "gate-config-effective-test", version: "0.1.0" }, { capabilities: {} });
57+
await client.connect(clientTransport);
58+
try {
59+
const { tools } = await client.listTools();
60+
const tool = tools.find((entry) => entry.name === "loopover_get_gate_config_effective");
61+
expect(tool).toBeDefined();
62+
expect(tool?.description).toMatch(/effective.*gate|gate thresholds/i);
63+
64+
const result = await client.callTool({
65+
name: "loopover_get_gate_config_effective",
66+
arguments: { owner: "owner", repo: "repo" },
67+
});
68+
expect(capturedRequests.length).toBe(1);
69+
const captured = capturedRequests[0]!;
70+
expect(captured.url).toContain("/v1/repos/owner/repo/gate-config/effective");
71+
expect(captured.method).toBe("GET");
72+
expect(result.isError).toBeFalsy();
73+
const text = JSON.stringify(result);
74+
expect(text).toContain("confidenceFloor");
75+
expect(text).toContain("shadowPending");
76+
} finally {
77+
await client.close().catch(() => undefined);
78+
}
79+
});
80+
});

test/unit/mcp-output-schemas.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
33
import { describe, expect, it, vi } from "vitest";
44
import { persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, updatePullRequestSlopAssessment, persistUpstreamRulesetSnapshot } from "../../src/db/repositories";
5-
import { writeLiveOverride, type StorageEnv } from "../../src/review/auto-apply";
5+
import { writeLiveOverride, writeShadowOverride, type StorageEnv } from "../../src/review/auto-apply";
66
import type { AuthIdentity } from "../../src/auth/security";
77
import { LoopoverMcp } from "../../src/mcp/server";
88
import { normalizeRegistryPayload } from "../../src/registry/normalize";
@@ -16,6 +16,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
1616
"loopover_get_maintainer_noise",
1717
"loopover_get_activation_preview",
1818
"loopover_get_live_gate_thresholds",
19+
"loopover_get_gate_config_effective",
1920
"loopover_get_label_audit",
2021
"loopover_get_maintainer_lane",
2122
"loopover_get_repo_onboarding_pack",
@@ -153,6 +154,10 @@ describe("MCP output schema discovery", () => {
153154
const liveGate = byName.get("loopover_get_live_gate_thresholds");
154155
const liveGateProps = Object.keys((liveGate?.outputSchema?.properties ?? {}) as Record<string, unknown>);
155156
expect(liveGateProps).toEqual(expect.arrayContaining(["repoFullName", "confidence_floor", "scope_cap_files", "scope_cap_lines", "error"]));
157+
158+
const gateConfig = byName.get("loopover_get_gate_config_effective");
159+
const gateConfigProps = Object.keys((gateConfig?.outputSchema?.properties ?? {}) as Record<string, unknown>);
160+
expect(gateConfigProps).toEqual(expect.arrayContaining(["repoFullName", "effective", "shadowPending"]));
156161
});
157162

158163
it("preserves the full tool inventory while adding output schemas", async () => {
@@ -346,6 +351,39 @@ describe("MCP tool calls return schema-valid structured content", () => {
346351
expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "octo/demo" });
347352
});
348353

354+
it("loopover_get_gate_config_effective returns nulls when no override exists (#7800)", async () => {
355+
const { client } = await connectTestClient(createTestEnv());
356+
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "octo", repo: "demo" } });
357+
expect(result.isError).toBeFalsy();
358+
expect(result.structuredContent).toEqual({
359+
repoFullName: "octo/demo",
360+
effective: { confidenceFloor: null, scopeCap: { files: null, lines: null } },
361+
shadowPending: false,
362+
});
363+
});
364+
365+
it("loopover_get_gate_config_effective returns live override + shadowPending (#7800)", async () => {
366+
const env = createTestEnv();
367+
await writeLiveOverride(env as unknown as StorageEnv, "octo/demo", { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } });
368+
await writeShadowOverride(env as unknown as StorageEnv, "octo/demo", { confidenceFloor: 0.4 }, "2099-01-01T00:00:00.000Z");
369+
const { client } = await connectTestClient(env);
370+
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "octo", repo: "demo" } });
371+
expect(result.isError).toBeFalsy();
372+
expect(result.structuredContent).toEqual({
373+
repoFullName: "octo/demo",
374+
effective: { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } },
375+
shadowPending: true,
376+
});
377+
});
378+
379+
it("loopover_get_gate_config_effective denies mcp callers outside the read allowlist (#7800)", async () => {
380+
const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" });
381+
const { client } = await connectTestClient(env);
382+
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "octo", repo: "demo" } });
383+
expect(result.isError).toBeFalsy();
384+
expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "octo/demo" });
385+
});
386+
349387
it("loopover_get_activation_preview denies cached member-only session access (#7799)", async () => {
350388
const env = createTestEnv();
351389
await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim-org/private-repo", private: true, owner: { login: "victim-org" }, default_branch: "main" });

test/unit/mcp-tool-rename-aliases.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
// (#7807 registered the loopover_get_upstream_ruleset remote+stdio tool, taking the count from 83 to 84.)
2929
// (#7801 registered the loopover_get_live_gate_thresholds remote+stdio tool, taking the count from 84 to 85.)
3030
// (#7802 registered the loopover_get_pr_maintainer_packet remote+stdio tool, taking the count from 85 to 86.)
31+
// (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.)
3132
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3233
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3334
import { mkdtempSync, rmSync } from "node:fs";
@@ -74,14 +75,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7475
});
7576
afterEach(disconnect);
7677

77-
it("lists exactly 86 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
78+
it("lists exactly 87 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
7879
const { tools } = await client.listTools();
7980
const names = tools.map((t) => t.name);
8081
const primary = names.filter((n) => n.startsWith("loopover_"));
8182
const legacy = names.filter((n) => n.startsWith("gittensory_"));
82-
expect(primary.length).toBe(86);
83+
expect(primary.length).toBe(87);
8384
expect(legacy.length).toBe(0);
84-
expect(names.length).toBe(86);
85+
expect(names.length).toBe(87);
8586
});
8687

8788
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -93,14 +94,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
9394
}
9495
});
9596

96-
it("`loopover-mcp tools --json` reports the same 86-tool count the live server registers", async () => {
97+
it("`loopover-mcp tools --json` reports the same 87-tool count the live server registers", async () => {
9798
const { tools } = await client.listTools();
9899
const payload = JSON.parse(run(["tools", "--json"])) as {
99100
count: number;
100101
tools: Array<{ name: string }>;
101102
};
102103
expect(payload.count).toBe(tools.length);
103-
expect(payload.count).toBe(86);
104+
expect(payload.count).toBe(87);
104105
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
105106
[...tools.map((t) => t.name)].sort(),
106107
);

test/unit/support/mcp-cli-harness.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,17 @@ export async function startFixtureServer(
583583
);
584584
return;
585585
}
586+
// #7800: effective self-tuned gate thresholds (camelCase effective + shadowPending).
587+
if (request.url === "/v1/repos/owner/repo/gate-config/effective" && request.method === "GET") {
588+
response.end(
589+
JSON.stringify({
590+
repoFullName: "owner/repo",
591+
effective: { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } },
592+
shadowPending: true,
593+
}),
594+
);
595+
return;
596+
}
586597
if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") {
587598
response.end(
588599
JSON.stringify({

0 commit comments

Comments
 (0)