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 @@ -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) plus whether a shadow override is soaking. Metadata-only; takes owner and repo.",
},
{
name: "loopover_preflight_pr",
category: "discovery",
Expand Down Expand Up @@ -1590,6 +1596,18 @@ registerStdioTool(
},
);

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_issue_quality",
{
Expand Down
48 changes: 48 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,13 @@ const liveGateThresholdsOutputSchema = {
status: z.string().optional(),
};

const gateConfigEffectiveOutputSchema = {
repoFullName: z.string().optional(),
effective: z.unknown().optional(),
shadowPending: z.boolean().optional(),
status: z.string().optional(),
};

const maintainerMeasurementReportOutputSchema = {
repoFullName: z.string().optional(),
generatedAt: z.string().optional(),
Expand Down Expand Up @@ -1906,6 +1913,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
loopover_get_pr_reviewability: "review",
loopover_get_pr_maintainer_packet: "review",
loopover_get_live_gate_thresholds: "maintainer",
loopover_get_gate_config_effective: "maintainer",
loopover_validate_linked_issue: "discovery",
loopover_check_before_start: "discovery",
loopover_find_opportunities: "discovery",
Expand Down Expand Up @@ -2479,6 +2487,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getLiveGateThresholds(input)),
);

register(
"loopover_get_gate_config_effective",
{
description:
"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.",
inputSchema: ownerRepoShape,
outputSchema: gateConfigEffectiveOutputSchema,
},
async (input) => this.toolResult(await this.getGateConfigEffective(input)),
);

register(
"loopover_validate_linked_issue",
{
Expand Down Expand Up @@ -3497,6 +3516,35 @@ export class LoopoverMcp {
};
}

private async getGateConfigEffective(input: { owner: string; repo: string }): Promise<ToolPayload> {
// Mirrors GET /v1/repos/:owner/:repo/gate-config/effective: same mcp allowlist gate as reviewability,
// same loadOverride/loadShadowOverride projection, always returning the effective + shadowPending shape
// (nulls when no live override — never a not-found throw).
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)]);
return {
summary: `Effective gate config for ${fullName}.`,
data: {
repoFullName: fullName,
effective: {
confidenceFloor: override?.confidenceFloor ?? null,
scopeCap: {
files: override?.scopeCap?.files ?? null,
lines: override?.scopeCap?.lines ?? null,
},
},
shadowPending: shadow !== null,
},
};
}

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 @@ -5529,6 +5529,7 @@ describe("api routes", () => {
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_get_gate_config_effective");
expect(toolNames).toContain("loopover_get_pr_maintainer_packet");
expect(toolNames).toContain("loopover_explain_review_risk");
expect(toolNames).toContain("loopover_compare_pr_variants");
Expand Down Expand Up @@ -5805,6 +5806,7 @@ describe("api routes", () => {
["loopover_get_upstream_drift", {}],
["loopover_get_upstream_ruleset", {}],
["loopover_get_live_gate_thresholds", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_gate_config_effective", { owner: "entrius", repo: "allways-ui" }],
["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }],
[
"loopover_preview_local_pr_score",
Expand Down
80 changes: 80 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,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";

// #7800: in-process coverage for the loopover_get_gate_config_effective stdio tool.
// Same #7764 entrypoint-guard pattern as mcp-cli-live-gate-thresholds — import .ts, hold 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-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 thresholds/i);

const result = await client.callTool({
name: "loopover_get_gate_config_effective",
arguments: { owner: "owner", repo: "repo" },
});
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/repos/owner/repo/gate-config/effective");
expect(captured.method).toBe("GET");
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).toContain("confidenceFloor");
expect(text).toContain("shadowPending");
} finally {
await client.close().catch(() => undefined);
}
});
});
40 changes: 39 additions & 1 deletion test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +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 { writeLiveOverride, writeShadowOverride, 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 @@ -16,6 +16,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
"loopover_get_maintainer_noise",
"loopover_get_activation_preview",
"loopover_get_live_gate_thresholds",
"loopover_get_gate_config_effective",
"loopover_get_label_audit",
"loopover_get_maintainer_lane",
"loopover_get_repo_onboarding_pack",
Expand Down Expand Up @@ -153,6 +154,10 @@ describe("MCP output schema discovery", () => {
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"]));

const gateConfig = byName.get("loopover_get_gate_config_effective");
const gateConfigProps = Object.keys((gateConfig?.outputSchema?.properties ?? {}) as Record<string, unknown>);
expect(gateConfigProps).toEqual(expect.arrayContaining(["repoFullName", "effective", "shadowPending"]));
});

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

it("loopover_get_gate_config_effective returns nulls when no override exists (#7800)", async () => {
const { client } = await connectTestClient(createTestEnv());
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toEqual({
repoFullName: "octo/demo",
effective: { confidenceFloor: null, scopeCap: { files: null, lines: null } },
shadowPending: false,
});
});

it("loopover_get_gate_config_effective returns live override + shadowPending (#7800)", async () => {
const env = createTestEnv();
await writeLiveOverride(env as unknown as StorageEnv, "octo/demo", { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } });
await writeShadowOverride(env as unknown as StorageEnv, "octo/demo", { confidenceFloor: 0.4 }, "2099-01-01T00:00:00.000Z");
const { client } = await connectTestClient(env);
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toEqual({
repoFullName: "octo/demo",
effective: { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } },
shadowPending: true,
});
});

it("loopover_get_gate_config_effective denies mcp callers outside the read allowlist (#7800)", async () => {
const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" });
const { client } = await connectTestClient(env);
const result = await client.callTool({ name: "loopover_get_gate_config_effective", 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 @@ -28,6 +28,7 @@
// (#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.)
// (#7802 registered the loopover_get_pr_maintainer_packet remote+stdio tool, taking the count from 85 to 86.)
// (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.)
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 @@ -74,14 +75,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 86 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 87 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(86);
expect(primary.length).toBe(87);
expect(legacy.length).toBe(0);
expect(names.length).toBe(86);
expect(names.length).toBe(87);
});

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

it("`loopover-mcp tools --json` reports the same 86-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 87-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(86);
expect(payload.count).toBe(87);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down
11 changes: 11 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,17 @@ export async function startFixtureServer(
);
return;
}
// #7800: effective self-tuned gate thresholds (camelCase effective + shadowPending).
if (request.url === "/v1/repos/owner/repo/gate-config/effective" && request.method === "GET") {
response.end(
JSON.stringify({
repoFullName: "owner/repo",
effective: { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } },
shadowPending: true,
}),
);
return;
}
if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") {
response.end(
JSON.stringify({
Expand Down