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
20 changes: 20 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,11 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "maintainer",
description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.",
},
{
name: "loopover_get_activation_preview",
category: "maintainer",
description: "Return the repo's maintainer activation preview: a deterministic run of the advisory engine over recent PRs (evaluated/with-findings counts, distinct finding codes, per-PR samples, current review-check mode, and the single recommended next action). Maintainer-authenticated; advisory only.",
},
{
name: "loopover_preflight_pr",
category: "discovery",
Expand Down Expand Up @@ -1467,6 +1472,21 @@ registerStdioTool(
},
);

// (#7799) CLI stdio mirror of the remote loopover_get_activation_preview — thin GET proxy of the already
// maintainer-scoped /v1/repos/:owner/:repo/activation-preview route (same ownerRepoShape + apiGet pattern
// as maintainer_noise).
registerStdioTool(
"loopover_get_activation_preview",
{
description: stdioToolDescription("loopover_get_activation_preview"),
inputSchema: ownerRepoShape,
},
async ({ owner, repo }: any) => {
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
return toolResult("LoopOver activation preview.", await apiGet(`${prefix}/activation-preview`));
},
);

registerStdioTool(
"loopover_get_issue_quality",
{
Expand Down
54 changes: 54 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../servi
import { buildRecommendationQualityReport } from "../services/recommendation-quality-report";
import { computeFleetAnalytics } from "../orb/analytics";
import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/maintainer-noise";
import { buildMaintainerActivationPreview } from "../services/maintainer-activation";
import { loadLabelAudit, labelAuditSummary } from "../services/label-audit";
import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane";
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
Expand Down Expand Up @@ -166,6 +167,7 @@ import { buildFocusManifestValidation } from "../services/focus-manifest-validat
import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution";
import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode";
import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVerdict } from "../rules/predicted-gate";
Expand Down Expand Up @@ -894,6 +896,21 @@ const maintainerNoiseOutputSchema = {
summary: z.string().optional(),
};

// (#7799) Repo-specific "here's what LoopOver would have surfaced" activation preview over recent PRs.
// Mirrors buildMaintainerActivationPreview's shape; deterministic, maintainer-authenticated, advisory only.
const activationPreviewOutputSchema = {
repoFullName: z.string().optional(),
generatedAt: z.string().optional(),
currentReviewCheckMode: z.string().optional(),
aiReviewConfigured: z.boolean().optional(),
evaluatedCount: z.number().optional(),
withFindingsCount: z.number().optional(),
findingCodeCounts: z.array(z.unknown()).optional(),
samples: z.array(z.unknown()).optional(),
recommendedAction: z.string().nullable().optional(),
summary: z.string().optional(),
};

const labelAuditOutputSchema = {
repoFullName: z.string().optional(),
generatedAt: z.string().optional(),
Expand Down Expand Up @@ -1798,6 +1815,7 @@ export const MCP_TOOL_CATEGORY_IDS: readonly McpToolCategory[] = ["discovery", "
export const MCP_TOOL_CATEGORIES: Record<string, McpToolCategory> = {
loopover_get_repo_context: "maintainer",
loopover_get_maintainer_noise: "maintainer",
loopover_get_activation_preview: "maintainer",
loopover_get_label_audit: "maintainer",
loopover_get_maintainer_lane: "maintainer",
loopover_get_repo_onboarding_pack: "maintainer",
Expand Down Expand Up @@ -1929,6 +1947,17 @@ export class LoopoverMcp {
async (input) => this.toolResult(await this.getMaintainerNoise(input)),
);

register(
"loopover_get_activation_preview",
{
description:
"Return the repo's maintainer activation preview: a deterministic \"here's what LoopOver would have surfaced\" run of the advisory engine over recent PRs (evaluated/with-findings counts, distinct finding codes, per-PR samples, current review-check mode, and the single recommended next action). Maintainer-authenticated; advisory only, never runs AI.",
inputSchema: ownerRepoShape,
outputSchema: activationPreviewOutputSchema,
},
async (input) => this.toolResult(await this.getActivationPreview(input)),
);

register(
"loopover_get_label_audit",
{
Expand Down Expand Up @@ -3114,6 +3143,31 @@ export class LoopoverMcp {
};
}

// (#7799) MCP surface for GET /v1/repos/:owner/:repo/activation-preview. Assembles the same inputs the REST
// route does (getRepository + resolveRepositorySettings + listPullRequests) and defers to the guarded
// buildMaintainerActivationPreview service. Deterministic and advisory-only -- never runs AI.
private async getActivationPreview(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
await this.requireRepoApprovalQueueAccess(fullName);
const [repo, settings, pullRequests] = await Promise.all([
getRepository(this.env, fullName),
resolveRepositorySettings(this.env, fullName),
listPullRequests(this.env, fullName),
]);
const report = buildMaintainerActivationPreview({
repoFullName: fullName,
repo,
settings,
pullRequests,
generatedAt: nowIso(),
duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(this.env), settings.duplicateWinnerMode),
});
return {
summary: report.summary,
data: report as unknown as Record<string, unknown>,
};
}

private async getLabelAudit(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(fullName);
Expand Down
100 changes: 100 additions & 0 deletions test/unit/mcp-cli-activation-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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, vi } from "vitest";
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";

// (#7799) In-process coverage of the stdio loopover_get_activation_preview proxy. The bin ends with
// `await server.connect(new StdioServerTransport())` at module scope, so we mock StdioServerTransport to hand
// the imported module an in-memory transport we control, then drive its tool surface with a real MCP client.
// This is the only way to instrument bin/loopover-mcp.ts's new lines -- subprocess spawn (the sibling
// mcp-cli-maintainer-noise.test.ts) is functionally faithful but not coverage-instrumented.
const holder = vi.hoisted(() => ({ serverTransport: undefined as any }));
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
StdioServerTransport: class {
constructor() {
return holder.serverTransport as any;
}
},
}));

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 configDir: string;
let capturedRequests: Array<{ url: string; method: string }>;
const ENV_KEYS = ["LOOPOVER_CONFIG_DIR", "LOOPOVER_API_URL", "LOOPOVER_TOKEN", "LOOPOVER_API_TIMEOUT_MS"] as const;
const savedEnv: Record<string, string | undefined> = {};

beforeAll(async () => {
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
configDir = mkdtempSync(join(tmpdir(), "loopover-activation-preview-"));
capturedRequests = [];
const apiUrl = await startFixtureServer({
onApiRequest: (request) => {
if (request.url && request.url.includes("/activation-preview")) {
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
}
},
});
process.env.LOOPOVER_CONFIG_DIR = configDir;
process.env.LOOPOVER_API_URL = apiUrl;
process.env.LOOPOVER_TOKEN = "session-token";
process.env.LOOPOVER_API_TIMEOUT_MS = "5000";

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
holder.serverTransport = serverTransport;

// cliArgs[0] === undefined skips the module's `if (cliArgs[0] && cliArgs[0] !== "--stdio")` CLI-dispatch
// guard (which would runCli + process.exit), so importing just registers the tools and connects our
// in-memory transport instead of a real stdio one.
const originalArgv = process.argv;
process.argv = [process.execPath, "loopover-mcp"];
// Import the .ts source explicitly (not the .js): a committed/build-artifact .js on disk would otherwise be
// resolved and instrumented under its .js path, so codecov/patch would map the new lines to the wrong file.
// A non-literal specifier keeps tsc from rejecting the .ts extension (TS5097) while vitest still loads it.
const binTsModule = "../../packages/loopover-mcp/bin/loopover-mcp.ts";
await import(/* @vite-ignore */ binTsModule);
process.argv = originalArgv;

client = new Client({ name: "activation-preview-test", version: "0.0.1" });
await client.connect(clientTransport);
});

afterAll(async () => {
await client?.close().catch(() => undefined);
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
});

describe("loopover_get_activation_preview stdio proxy (#7799)", () => {
it("registers the tool in the stdio server tool list", async () => {
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "loopover_get_activation_preview");
expect(tool).toBeDefined();
expect(tool?.description).toMatch(/activation preview/i);
});

it("proxies the call to /activation-preview via apiGet and returns the payload", async () => {
const result = await client.callTool({
name: "loopover_get_activation_preview",
arguments: { owner: "owner", repo: "repo" },
});
expect(capturedRequests.length).toBe(1);
const captured = capturedRequests[0]!;
expect(captured.url).toContain("/v1/repos/owner/repo/activation-preview");
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("evaluatedCount");
expect(text).toContain("enable_advisory");
});
});
42 changes: 42 additions & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createTestEnv } from "../helpers/d1";
const TOOLS_WITH_OUTPUT_SCHEMA = [
"loopover_get_repo_context",
"loopover_get_maintainer_noise",
"loopover_get_activation_preview",
"loopover_get_label_audit",
"loopover_get_maintainer_lane",
"loopover_get_repo_onboarding_pack",
Expand Down Expand Up @@ -240,6 +241,47 @@ describe("MCP tool calls return schema-valid structured content", () => {
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
});

it("loopover_get_activation_preview returns a structured activation preview for a repo (#7799)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
await upsertPullRequestFromGitHub(env, "octo/demo", { number: 1, title: "misc cleanup and various refactors", state: "open", user: { login: "alice" }, body: "" });
const { client } = await connectTestClient(env);
const result = await client.callTool({ name: "loopover_get_activation_preview", arguments: { owner: "octo", repo: "demo" } });
expect(result.isError).toBeFalsy();
const data = result.structuredContent as Record<string, unknown>;
expect(data.repoFullName).toBe("octo/demo");
expect(typeof data.evaluatedCount).toBe("number");
expect(typeof data.aiReviewConfigured).toBe("boolean");
expect(Array.isArray(data.samples)).toBe(true);
expect(Array.isArray(data.findingCodeCounts)).toBe(true);
expect(typeof data.summary).toBe("string");
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
});

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" });
const { client } = await connectTestClient(env, {
kind: "session",
actor: "read-only-member",
session: {
id: "session-read-only-member",
tokenHash: "hash",
login: "read-only-member",
scopes: [],
expiresAt: "2999-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
metadata: {},
},
});

const result = await client.callTool({ name: "loopover_get_activation_preview", arguments: { owner: "victim-org", repo: "private-repo" } });

expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain("maintainer access is required");
expect(result.structuredContent).toBeUndefined();
});

it("loopover_get_maintainer_noise denies cached member-only session access", 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
10 changes: 5 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

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

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

it("`loopover-mcp tools --json` reports the same 79-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 80-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(79);
expect(payload.count).toBe(80);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual(
[...tools.map((t) => t.name)].sort(),
);
Expand Down
17 changes: 17 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,23 @@ export async function startFixtureServer(
);
return;
}
if (request.url === "/v1/repos/owner/repo/activation-preview" && request.method === "GET") {
response.end(
JSON.stringify({
repoFullName: "owner/repo",
generatedAt: "2026-06-01T00:00:00.000Z",
currentReviewCheckMode: "off",
aiReviewConfigured: false,
evaluatedCount: 3,
withFindingsCount: 2,
findingCodeCounts: [{ code: "missing_linked_issue", count: 2 }],
samples: [{ number: 7, title: "misc cleanup", severity: "advisory", findingCount: 1, findings: [{ code: "missing_linked_issue", severity: "advisory", title: "No linked issue" }] }],
recommendedAction: "enable_advisory",
summary: "LoopOver activation preview for owner/repo: evaluated 3 recent PR(s), 2 with findings.",
}),
);
return;
}
if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") {
response.end(
JSON.stringify({
Expand Down