diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index d164dddd5e..8aa4c34d16 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -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", @@ -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", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85d59a46ee..0d178b1b3f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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"; @@ -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"; @@ -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(), @@ -1798,6 +1815,7 @@ export const MCP_TOOL_CATEGORY_IDS: readonly McpToolCategory[] = ["discovery", " export const MCP_TOOL_CATEGORIES: Record = { 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", @@ -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", { @@ -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 { + 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, + }; + } + private async getLabelAudit(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(fullName); diff --git a/test/unit/mcp-cli-activation-preview.test.ts b/test/unit/mcp-cli-activation-preview.test.ts new file mode 100644 index 0000000000..1c0e5fc76e --- /dev/null +++ b/test/unit/mcp-cli-activation-preview.test.ts @@ -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 = {}; + +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"); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 55e958a34f..21caa3a624 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -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", @@ -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; + 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" }); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 6bc40a746b..7d4558d49e 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -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 () => { @@ -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(), ); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 2f26c988c5..10f7633c9b 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -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({