Skip to content

Commit dd1caef

Browse files
committed
feat(mcp): add remote + stdio tool surfaces for loopover_get_gate_config_effective
GET /v1/repos/:owner/:repo/gate-config/effective had a REST route but no remote MCP tool and no local stdio MCP tool, unlike its pr-reviewability sibling (same requireStaticProtectedApiToken + mcp-read-repo allowlist gate). Register loopover_get_gate_config_effective as a remote MCP tool (category maintainer, ownerRepoShape, calling the same loadOverride/loadShadowOverride pair and returning the identical effective-thresholds shape) and as a local stdio tool proxying GET the existing route. No REST route, CLI verb, service, or UI change. Closes #7800
1 parent cf0cc6f commit dd1caef

6 files changed

Lines changed: 265 additions & 5 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,11 @@ const STDIO_TOOL_DESCRIPTORS = [
970970
category: "review",
971971
description: "Return the reviewability report for an open PR: how ready it is to review/merge, the blocking or advisory signals against it, and its lane/duplicate/linked-issue context. Metadata-only, no GitHub writes.",
972972
},
973+
{
974+
name: "loopover_get_gate_config_effective",
975+
category: "maintainer",
976+
description: "Return a repo's CURRENT effective self-tuned gate thresholds (confidence floor + file/line scope cap) and whether a shadow recommendation is soaking. Resolved effective values only — no override audit history or queued shadow recommendation. Metadata-only, no GitHub writes.",
977+
},
973978
{
974979
name: "loopover_get_pr_ai_review_findings",
975980
category: "review",
@@ -1495,6 +1500,20 @@ registerStdioTool(
14951500
},
14961501
);
14971502

1503+
// #7800: CLI mirror of the remote server's loopover_get_gate_config_effective. The route is the single source
1504+
// of truth; this tool proxies owner/repo to GET /v1/repos/:owner/:repo/gate-config/effective via apiGet.
1505+
registerStdioTool(
1506+
"loopover_get_gate_config_effective",
1507+
{
1508+
description: stdioToolDescription("loopover_get_gate_config_effective"),
1509+
inputSchema: ownerRepoShape,
1510+
},
1511+
async ({ owner, repo }: any) => {
1512+
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
1513+
return toolResult("LoopOver effective gate config.", await apiGet(`${prefix}/gate-config/effective`));
1514+
},
1515+
);
1516+
14981517
// #6619: CLI mirror of the remote server's loopover_get_pr_ai_review_findings. The route is the single source
14991518
// of truth (it delegates to the same loadPrAiReviewFindings the MCP server uses); this tool only resolves the
15001519
// author login and proxies. Self-scoped: the route's requireContributorAccess rejects another login's PR.

src/mcp/server.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/fi
189189
import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy";
190190
import { recordPredictedGateCall } from "../review/predicted-gate-calls";
191191
import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger";
192+
import { loadOverride, loadShadowOverride, type StorageEnv } from "../review/auto-apply";
192193

193194
type AppContext = Context<{ Bindings: Env }>;
194195
type ToolPayload = {
@@ -988,6 +989,21 @@ const freshnessResponseOutputSchema = {
988989
report: z.unknown().optional(),
989990
};
990991

992+
// #7800 — read-only view of a repo's CURRENT effective self-tuned gate thresholds. Mirrors the shape the
993+
// GET /v1/repos/:owner/:repo/gate-config/effective route returns: the resolved effective values only (never
994+
// the raw override_audit history or the shadow's queued recommendation), plus a flag that a shadow is soaking.
995+
const gateConfigEffectiveOutputSchema = {
996+
status: z.string().optional(),
997+
repoFullName: z.string().optional(),
998+
effective: z
999+
.object({
1000+
confidenceFloor: z.number().nullable().optional(),
1001+
scopeCap: z.object({ files: z.number().nullable().optional(), lines: z.number().nullable().optional() }).optional(),
1002+
})
1003+
.optional(),
1004+
shadowPending: z.boolean().optional(),
1005+
};
1006+
9911007
const maintainerMeasurementReportOutputSchema = {
9921008
repoFullName: z.string().optional(),
9931009
generatedAt: z.string().optional(),
@@ -1856,6 +1872,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
18561872
loopover_get_upstream_drift: "utility",
18571873
loopover_get_issue_quality: "maintainer",
18581874
loopover_get_pr_reviewability: "review",
1875+
loopover_get_gate_config_effective: "maintainer",
18591876
loopover_validate_linked_issue: "discovery",
18601877
loopover_check_before_start: "discovery",
18611878
loopover_find_opportunities: "discovery",
@@ -2386,6 +2403,17 @@ export class LoopoverMcp {
23862403
async (input) => this.toolResult(await this.getPrReviewability(input)),
23872404
);
23882405

2406+
register(
2407+
"loopover_get_gate_config_effective",
2408+
{
2409+
description:
2410+
"Return a repo's CURRENT effective self-tuned gate thresholds (confidence floor + file/line scope cap) and whether a shadow recommendation is soaking. Resolved effective values only — never the override audit history or the queued shadow recommendation. Repo-scoped, metadata-only, no GitHub writes.",
2411+
inputSchema: ownerRepoShape,
2412+
outputSchema: gateConfigEffectiveOutputSchema,
2413+
},
2414+
async (input) => this.toolResult(await this.getGateConfigEffective(input)),
2415+
);
2416+
23892417
register(
23902418
"loopover_validate_linked_issue",
23912419
{
@@ -3336,6 +3364,36 @@ export class LoopoverMcp {
33363364
};
33373365
}
33383366

3367+
// #7800 — the MCP mirror of GET /v1/repos/:owner/:repo/gate-config/effective. Same repo-scoped read gating
3368+
// the reviewability tool uses (canAccessRepo scopes the shared static mcp identity to MCP_READ_REPO_ALLOWLIST);
3369+
// returns the identical resolved-effective shape the route does by calling the same loadOverride +
3370+
// loadShadowOverride, never the raw override audit history or the queued shadow recommendation.
3371+
private async getGateConfigEffective(input: { owner: string; repo: string }): Promise<ToolPayload> {
3372+
const fullName = `${input.owner}/${input.repo}`;
3373+
if (!(await this.canAccessRepo(fullName))) {
3374+
return {
3375+
summary: `Forbidden: session cannot access gate config for ${fullName}.`,
3376+
data: { status: "forbidden", repoFullName: fullName },
3377+
};
3378+
}
3379+
const storageEnv = this.env as unknown as StorageEnv;
3380+
const [override, shadow] = await Promise.all([loadOverride(storageEnv, fullName), loadShadowOverride(storageEnv, fullName)]);
3381+
return {
3382+
summary: `LoopOver effective gate config for ${fullName}.`,
3383+
data: {
3384+
repoFullName: fullName,
3385+
effective: {
3386+
confidenceFloor: override?.confidenceFloor ?? null,
3387+
scopeCap: {
3388+
files: override?.scopeCap?.files ?? null,
3389+
lines: override?.scopeCap?.lines ?? null,
3390+
},
3391+
},
3392+
shadowPending: shadow !== null,
3393+
},
3394+
};
3395+
}
3396+
33393397
private async validateLinkedIssue(input: {
33403398
owner: string;
33413399
repo: string;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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, vi } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
// (#7800) In-process coverage of the stdio loopover_get_gate_config_effective proxy. The bin ends with
10+
// `await server.connect(new StdioServerTransport())` at module scope, so we mock StdioServerTransport to hand
11+
// the imported module an in-memory transport we control, then drive its tool surface with a real MCP client.
12+
// This is the only way to instrument bin/loopover-mcp.ts's new lines -- subprocess spawn (the sibling
13+
// mcp-cli-pr-reviewability.test.ts) is functionally faithful but not coverage-instrumented.
14+
const holder = vi.hoisted(() => ({ serverTransport: undefined as any }));
15+
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
16+
StdioServerTransport: class {
17+
constructor() {
18+
return holder.serverTransport as any;
19+
}
20+
},
21+
}));
22+
23+
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;
24+
25+
let client: Client;
26+
let configDir: string;
27+
let capturedRequests: Array<{ url: string; method: string }>;
28+
const ENV_KEYS = ["LOOPOVER_CONFIG_DIR", "LOOPOVER_API_URL", "LOOPOVER_TOKEN", "LOOPOVER_API_TIMEOUT_MS"] as const;
29+
const savedEnv: Record<string, string | undefined> = {};
30+
31+
beforeAll(async () => {
32+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
33+
configDir = mkdtempSync(join(tmpdir(), "loopover-gate-config-effective-"));
34+
capturedRequests = [];
35+
const apiUrl = await startFixtureServer({
36+
onApiRequest: (request) => {
37+
if (request.url && request.url.includes("/gate-config/effective")) {
38+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
39+
}
40+
},
41+
});
42+
process.env.LOOPOVER_CONFIG_DIR = configDir;
43+
process.env.LOOPOVER_API_URL = apiUrl;
44+
process.env.LOOPOVER_TOKEN = "session-token";
45+
process.env.LOOPOVER_API_TIMEOUT_MS = "5000";
46+
47+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
48+
holder.serverTransport = serverTransport;
49+
50+
// cliArgs[0] === undefined skips the module's `if (cliArgs[0] && cliArgs[0] !== "--stdio")` CLI-dispatch
51+
// guard (which would runCli + process.exit), so importing just registers the tools and connects our
52+
// in-memory transport instead of a real stdio one.
53+
const originalArgv = process.argv;
54+
process.argv = [process.execPath, "loopover-mcp"];
55+
// Import the .ts source explicitly (not the .js): a committed/build-artifact .js on disk would otherwise be
56+
// resolved and instrumented under its .js path, so codecov/patch would map the new lines to the wrong file.
57+
// A non-literal specifier keeps tsc from rejecting the .ts extension (TS5097) while vitest still loads it.
58+
const binTsModule = "../../packages/loopover-mcp/bin/loopover-mcp.ts";
59+
await import(/* @vite-ignore */ binTsModule);
60+
process.argv = originalArgv;
61+
62+
client = new Client({ name: "gate-config-effective-test", version: "0.0.1" });
63+
await client.connect(clientTransport);
64+
});
65+
66+
afterAll(async () => {
67+
await client?.close().catch(() => undefined);
68+
await closeFixtureServer();
69+
if (configDir) rmSync(configDir, { recursive: true, force: true });
70+
for (const key of ENV_KEYS) {
71+
if (savedEnv[key] === undefined) delete process.env[key];
72+
else process.env[key] = savedEnv[key];
73+
}
74+
});
75+
76+
describe("loopover_get_gate_config_effective stdio proxy (#7800)", () => {
77+
it("registers the tool in the stdio server tool list", async () => {
78+
const { tools } = await client.listTools();
79+
const tool = tools.find((entry) => entry.name === "loopover_get_gate_config_effective");
80+
expect(tool).toBeDefined();
81+
expect(tool?.description).toMatch(/gate thresholds/i);
82+
});
83+
84+
it("proxies owner/repo to /gate-config/effective via apiGet and returns the payload", async () => {
85+
const result = await client.callTool({
86+
name: "loopover_get_gate_config_effective",
87+
arguments: { owner: "owner", repo: "repo" },
88+
});
89+
expect(capturedRequests.length).toBe(1);
90+
const captured = capturedRequests[0]!;
91+
expect(captured.url).toContain("/v1/repos/owner/repo/gate-config/effective");
92+
expect(captured.method).toBe("GET");
93+
expect(result.isError).toBeFalsy();
94+
const text = JSON.stringify(result);
95+
expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
96+
expect(text).toContain("owner/repo");
97+
expect(text).toContain("confidenceFloor");
98+
expect(text).toContain("shadowPending");
99+
});
100+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { describe, expect, it } from "vitest";
4+
import { LoopoverMcp } from "../../src/mcp/server";
5+
import { writeLiveOverride, writeShadowOverride, type StorageEnv } from "../../src/review/auto-apply";
6+
import type { AuthIdentity } from "../../src/auth/security";
7+
import { createTestEnv } from "../helpers/d1";
8+
9+
async function connect(env: Env, identity?: AuthIdentity) {
10+
const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer();
11+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
12+
await server.connect(serverTransport);
13+
const client = new Client({ name: "loopover-gate-config-test", version: "0.1.0" }, { capabilities: {} });
14+
await client.connect(clientTransport);
15+
return client;
16+
}
17+
18+
type GateConfigResponse = {
19+
status?: string;
20+
repoFullName?: string;
21+
effective?: { confidenceFloor: number | null; scopeCap: { files: number | null; lines: number | null } };
22+
shadowPending?: boolean;
23+
};
24+
25+
describe("MCP loopover_get_gate_config_effective (#7800)", () => {
26+
it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => {
27+
const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" });
28+
const client = await connect(env);
29+
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } });
30+
expect(result.isError).toBeFalsy();
31+
const data = result.structuredContent as GateConfigResponse;
32+
expect(data.status).toBe("forbidden");
33+
expect(data.repoFullName).toBe("owner/repo");
34+
});
35+
36+
it("returns null effective thresholds and shadowPending false when no override is soaking", async () => {
37+
const env = createTestEnv();
38+
const client = await connect(env);
39+
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } });
40+
expect(result.isError).toBeFalsy();
41+
const data = result.structuredContent as GateConfigResponse;
42+
expect(data.repoFullName).toBe("owner/repo");
43+
expect(data.effective).toEqual({ confidenceFloor: null, scopeCap: { files: null, lines: null } });
44+
expect(data.shadowPending).toBe(false);
45+
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
46+
});
47+
48+
it("returns the resolved live override values and flags a soaking shadow", async () => {
49+
const env = createTestEnv();
50+
await writeLiveOverride(env as unknown as StorageEnv, "owner/repo", { confidenceFloor: 0.85, scopeCap: { files: 20, lines: 400 } });
51+
await writeShadowOverride(env as unknown as StorageEnv, "owner/repo", { confidenceFloor: 0.9 }, "2999-01-01T00:00:00.000Z");
52+
const client = await connect(env);
53+
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } });
54+
expect(result.isError).toBeFalsy();
55+
const data = result.structuredContent as GateConfigResponse;
56+
expect(data.effective).toEqual({ confidenceFloor: 0.85, scopeCap: { files: 20, lines: 400 } });
57+
expect(data.shadowPending).toBe(true);
58+
// The queued shadow recommendation itself is never surfaced — only the boolean that one is soaking.
59+
expect(JSON.stringify(data)).not.toContain("0.9");
60+
});
61+
62+
it("nulls the scope cap when the live override sets only a confidence floor", async () => {
63+
const env = createTestEnv();
64+
await writeLiveOverride(env as unknown as StorageEnv, "owner/repo", { confidenceFloor: 0.7 });
65+
const client = await connect(env);
66+
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } });
67+
const data = result.structuredContent as GateConfigResponse;
68+
expect(data.effective).toEqual({ confidenceFloor: 0.7, scopeCap: { files: null, lines: null } });
69+
expect(data.shadowPending).toBe(false);
70+
});
71+
});

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
// (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.)
2424
// (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.)
2525
// (#7764 registered the loopover_plan_repo_issues stdio + CLI + REST tool, taking the count from 80 to 81.)
26+
// (#7800 registered the loopover_get_gate_config_effective stdio tool, taking the count from 82 to 83.)
2627
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2728
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2829
import { mkdtempSync, rmSync } from "node:fs";
@@ -70,14 +71,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
7071
});
7172
afterEach(disconnect);
7273

73-
it("lists exactly 81 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
74+
it("lists exactly 83 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
7475
const { tools } = await client.listTools();
7576
const names = tools.map((t) => t.name);
7677
const primary = names.filter((n) => n.startsWith("loopover_"));
7778
const legacy = names.filter((n) => n.startsWith("gittensory_"));
78-
expect(primary.length).toBe(81);
79+
expect(primary.length).toBe(83);
7980
expect(legacy.length).toBe(0);
80-
expect(names.length).toBe(81);
81+
expect(names.length).toBe(83);
8182
});
8283

8384
it("no loopover_ tool's description carries a stale deprecation notice", async () => {
@@ -89,14 +90,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
8990
}
9091
});
9192

92-
it("`loopover-mcp tools --json` reports the same 81-tool count the live server registers", async () => {
93+
it("`loopover-mcp tools --json` reports the same 83-tool count the live server registers", async () => {
9394
const { tools } = await client.listTools();
9495
const payload = JSON.parse(run(["tools", "--json"])) as {
9596
count: number;
9697
tools: Array<{ name: string }>;
9798
};
9899
expect(payload.count).toBe(tools.length);
99-
expect(payload.count).toBe(81);
100+
expect(payload.count).toBe(83);
100101
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
101102
[...tools.map((t) => t.name)].sort(),
102103
);

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,17 @@ export async function startFixtureServer(
859859
response.end(JSON.stringify({ repoFullName: "acme/widgets", summary: "Ranked 2 scenarios.", scenarios: [{ id: "close_stale", rank: 1 }] }));
860860
return;
861861
}
862+
// #7800: read-only effective self-tuned gate thresholds. Mirrors the route's resolved-effective shape.
863+
if (request.url === "/v1/repos/owner/repo/gate-config/effective" && request.method === "GET") {
864+
response.end(
865+
JSON.stringify({
866+
repoFullName: "owner/repo",
867+
effective: { confidenceFloor: 0.85, scopeCap: { files: 20, lines: 400 } },
868+
shadowPending: false,
869+
}),
870+
);
871+
return;
872+
}
862873
if (request.url === "/v1/repos/owner/repo/pulls/7/reviewability" && request.method === "GET") {
863874
response.end(
864875
JSON.stringify({

0 commit comments

Comments
 (0)