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
19 changes: 19 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,11 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.",
},
{
name: "loopover_get_gate_config_effective",
category: "maintainer",
description: "Return a repo's current effective self-tuned gate thresholds (confidenceFloor, scopeCap) resolved from the live override, plus whether a shadow recommendation is soaking. Read-only, no GitHub writes.",
},
{
name: "loopover_open_pr",
category: "agent",
Expand Down Expand Up @@ -2623,6 +2628,20 @@ registerStdioTool(
return toolResult(`Gate precision for ${owner}/${repo}.`, payload);
},
);

// #7800 - CLI mirror of the remote server's loopover_get_gate_config_effective. GET .../gate-config/effective
// is the single source of truth (auto-apply.ts's loadOverride/loadShadowOverride pair); this tool only proxies.
registerStdioTool(
"loopover_get_gate_config_effective",
{
description: stdioToolDescription("loopover_get_gate_config_effective"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }: any) => {
const payload = await apiGet(`${toolRepoBase(owner, repo)}/gate-config/effective`);
return toolResult(`Effective gate config for ${owner}/${repo}.`, payload);
},
);
// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
Expand Down
56 changes: 56 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/mai
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
import { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse } from "../api/routes";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { loadOverride, loadShadowOverride, type StorageEnv } from "../review/auto-apply";
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
import {
applyMcpPlanningChoices,
Expand Down Expand Up @@ -993,6 +994,16 @@ const gatePrecisionOutputSchema = {
signals: z.array(z.string()).optional(),
};

// #7800 - mirrors the REST route's response shape (`GET /v1/repos/:owner/:repo/gate-config/effective`,
// src/api/routes.ts) exactly: no freshness/report wrapper, just the resolved effective thresholds plus a
// forbidden/status escape hatch for the canAccessRepo failure path (matching getIssueQuality/getPrReviewability).
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 @@ -1838,6 +1849,7 @@ export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
loopover_get_upstream_drift: "utility",
loopover_get_issue_quality: "maintainer",
loopover_get_pr_reviewability: "review",
loopover_get_gate_config_effective: "maintainer",
loopover_validate_linked_issue: "discovery",
loopover_check_before_start: "discovery",
loopover_find_opportunities: "discovery",
Expand Down Expand Up @@ -2357,6 +2369,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getPrReviewability(input)),
);

register(
"loopover_get_gate_config_effective",
{
description:
"Return a repo's current effective self-tuned gate thresholds (confidenceFloor, scopeCap) resolved from the live override, plus whether a shadow recommendation is soaking. Read-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 @@ -3282,6 +3305,39 @@ export class LoopoverMcp {
};
}

// #7800 - mirrors GET /v1/repos/:owner/:repo/gate-config/effective (src/api/routes.ts), which is itself
// "gated behind the same most-conservative repo-scoped read precedent the reviewability route (#6154)
// uses" per that route's own comment; canAccessRepo below is that identical precedent, already enforced
// by every other ownerRepoShape maintainer tool (getIssueQuality, getGatePrecision). Calls the same
// loadOverride/loadShadowOverride pair the route calls and returns the identical response shape -
// loadOverride/loadShadowOverride stay the single source of truth for both surfaces.
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 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 shadowPending = shadow !== null;
return {
summary: `LoopOver effective gate config for ${fullName}: confidenceFloor=${override?.confidenceFloor ?? "n/a"}, shadowPending=${shadowPending}.`,
data: {
repoFullName: fullName,
effective: {
confidenceFloor: override?.confidenceFloor ?? null,
scopeCap: {
files: override?.scopeCap?.files ?? null,
lines: override?.scopeCap?.lines ?? null,
},
},
shadowPending,
} as unknown as Record<string, unknown>,
};
}

private async validateLinkedIssue(input: {
owner: string;
repo: string;
Expand Down
88 changes: 88 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,88 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// Matches the other raw-StdioClientTransport CLI tests (mcp-cli-pr-reviewability.test.ts,
// mcp-cli-maintain-tools.test.ts): declares its own .js-suffixed bin rather than the harness's
// strip-types-oriented export, relying on `npm run build:mcp` having already run (test:ci always runs
// it before test:coverage).
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
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;

let client: Client;
let transport: StdioClientTransport;
let configDir: string;
let apiUrl: string;
let capturedRequests: Array<{ url: string; method: string }>;

async function connect(gateConfigEffective?: Record<string, unknown>) {
configDir = mkdtempSync(join(tmpdir(), "loopover-gate-config-effective-"));
capturedRequests = [];
apiUrl = await startFixtureServer({
...(gateConfigEffective ? { gateConfigEffective } : {}),
onApiRequest: (request) => {
if (request.url && request.url.includes("/gate-config/effective")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
env: {
...process.env,
LOOPOVER_CONFIG_DIR: configDir,
LOOPOVER_API_URL: apiUrl,
LOOPOVER_TOKEN: "session-token",
LOOPOVER_API_TIMEOUT_MS: "5000",
},
});
client = new Client({ name: "gate-config-effective-test", version: "0.0.1" });
await client.connect(transport);
}

async function disconnect() {
await client.close().catch(() => undefined);
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
}

describe("loopover_get_gate_config_effective stdio proxy (#7800)", () => {
afterEach(disconnect);

it("registers the tool in the stdio server tool list", async () => {
await connect();
const { tools } = await client.listTools();
expect(tools.map((t) => t.name)).toContain("loopover_get_gate_config_effective");
});

it("proxies owner/repo to /v1/repos/:owner/:repo/gate-config/effective via apiGet", async () => {
await connect();
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).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
expect(text).toContain("owner/repo");
expect(text).toContain("confidenceFloor");
});

it("surfaces a soaking shadow flag without leaking its queued recommendation", async () => {
await connect({
repoFullName: "owner/repo",
effective: { confidenceFloor: null, scopeCap: { files: null, lines: null } },
shadowPending: true,
});
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "repo" } });
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).toContain("shadowPending\":true");
});
});
88 changes: 88 additions & 0 deletions test/unit/mcp-gate-config-effective.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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";

const REPO = "owner/widgets";
const REPO_FULL_NAME = "owner/widgets";

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-effective-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
return client;
}

type GateConfigEffectiveResponse = {
status?: string;
repoFullName?: string;
effective?: { confidenceFloor: number | null; scopeCap: { files: number | null; lines: number | null } };
shadowPending?: boolean;
};

// #7800 - mirrors GET /v1/repos/:owner/:repo/gate-config/effective's own integration test (#6247), adapted
// to the MCP surface's canAccessRepo/forbidden-payload convention (matching loopover_get_pr_reviewability
// and loopover_get_issue_quality) rather than the REST route's 401/403 status codes.
describe("MCP loopover_get_gate_config_effective (#7800)", () => {
it("resolves all-null effective thresholds and no soaking shadow when nothing is overridden", async () => {
const env = createTestEnv();
const client = await connect(env);
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "widgets" } });
expect(result.isError).toBeFalsy();
const data = result.structuredContent as GateConfigEffectiveResponse;
expect(data).toEqual({
repoFullName: REPO_FULL_NAME,
effective: { confidenceFloor: null, scopeCap: { files: null, lines: null } },
shadowPending: false,
});
// The nullish branch of the summary's confidenceFloor ?? fallback (no override at all).
expect(JSON.stringify(result.content)).toContain("confidenceFloor=n/a");
});

it("resolves a live override plus a soaking shadow, never leaking the shadow's queued recommendation", async () => {
const env = createTestEnv();
const storageEnv = env as unknown as StorageEnv;
await writeLiveOverride(storageEnv, REPO_FULL_NAME, { confidenceFloor: 0.9, scopeCap: { files: 12, lines: 400 } });
await writeShadowOverride(storageEnv, REPO_FULL_NAME, { 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: "widgets" } });
expect(result.isError).toBeFalsy();
const data = result.structuredContent as GateConfigEffectiveResponse;
expect(data).toEqual({
repoFullName: REPO_FULL_NAME,
effective: { confidenceFloor: 0.9, scopeCap: { files: 12, lines: 400 } },
shadowPending: true,
});
expect(JSON.stringify(data)).not.toMatch(/0\.8/);
// Numeric branch of the summary's ?? fallback.
expect(JSON.stringify(result.content)).toContain("confidenceFloor=0.9");
});

it("resolves both scopeCap fields to null when the live override only carries a confidence floor", async () => {
const env = createTestEnv();
await writeLiveOverride(env as unknown as StorageEnv, REPO_FULL_NAME, { confidenceFloor: 0.5 });
const client = await connect(env);
const result = await client.callTool({ name: "loopover_get_gate_config_effective", arguments: { owner: "owner", repo: "widgets" } });
const data = result.structuredContent as GateConfigEffectiveResponse;
expect(data).toEqual({
repoFullName: REPO_FULL_NAME,
effective: { confidenceFloor: 0.5, scopeCap: { files: null, lines: null } },
shadowPending: false,
});
});

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: "widgets" } });
expect(result.isError).toBeFalsy();
const data = result.structuredContent as GateConfigEffectiveResponse;
expect(data.status).toBe("forbidden");
expect(data.repoFullName).toBe(REPO);
expect(JSON.stringify(result.content)).toContain("Forbidden: session cannot access gate config for owner/widgets.");
});
});
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 @@ -22,6 +22,7 @@
// (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.)
// (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.)
// (#7758 registered the loopover_get_outcome_calibration stdio tool, taking the count from 79 to 80.)
// (#7800 registered the loopover_get_gate_config_effective stdio tool, taking the count from 80 to 81.)
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 @@ -69,14 +70,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

it("`loopover-mcp tools --json` reports the same 80-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 81-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(80);
expect(payload.count).toBe(81);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down
16 changes: 16 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ export async function startFixtureServer(
validateConfigWarnings?: string[];
openPrMonitor?: Record<string, unknown>;
prOutcomes?: Record<string, unknown>;
/** #7800: overrides GET /v1/repos/owner/repo/gate-config/effective's default fixture response. */
gateConfigEffective?: Record<string, unknown>;
/** #6980: overrides POST /v1/preflight/review-risk and captures the request body. */
reviewRisk?: Record<string, unknown>;
onReviewRiskRequest?: (body: unknown) => void;
Expand Down Expand Up @@ -659,6 +661,20 @@ export async function startFixtureServer(
);
return;
}
// #7800 gate-config/effective (read-only). Mirrors the route's response shape (repoFullName/effective/
// shadowPending); a test can override via options.gateConfigEffective to exercise the override branches.
if (request.url === "/v1/repos/owner/repo/gate-config/effective" && request.method === "GET") {
response.end(
JSON.stringify(
options.gateConfigEffective ?? {
repoFullName: "owner/repo",
effective: { confidenceFloor: 0.85, scopeCap: { files: 10, lines: 300 } },
shadowPending: false,
},
),
);
return;
}
if (request.url?.startsWith("/v1/repos/owner/repo/outcome-calibration") && request.method === "GET") {
const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays");
response.end(
Expand Down