Skip to content
Closed
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
21 changes: 21 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,12 @@ const STDIO_TOOL_DESCRIPTORS = [
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_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",
Expand Down Expand Up @@ -1578,6 +1584,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_live_gate_thresholds",
{
Expand Down
54 changes: 54 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,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
Expand Down Expand Up @@ -1871,6 +1880,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
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",
Expand Down Expand Up @@ -2103,6 +2113,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",
{
Expand Down Expand Up @@ -3497,6 +3518,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<ToolPayload> {
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<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 @@ -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_get_live_gate_thresholds");
Expand Down Expand Up @@ -5802,6 +5803,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", {}],
["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }],
Expand Down
98 changes: 98 additions & 0 deletions test/unit/mcp-cli-gate-config-effective.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };
};

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

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);
}
},
);
});
92 changes: 92 additions & 0 deletions test/unit/mcp-gate-config-effective.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading