diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 77b5c718fb..abde927f0a 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -987,6 +987,12 @@ 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_repo_focus_manifest", + category: "maintainer", + description: + "Return a repo's own persisted focus manifest (.loopover.yml policy) plus its compiled policy. Read-only; maintainer/owner/operator authenticated. Distinct from loopover_validate_config (ad-hoc string validation).", + }, { name: "loopover_get_activation_preview", category: "maintainer", @@ -1569,6 +1575,21 @@ registerStdioTool( }, ); +// (#7808) CLI stdio mirror of the remote loopover_get_repo_focus_manifest — thin GET proxy of the +// requireAppRole-gated /v1/repos/:owner/:repo/focus-manifest route (same ownerRepoShape + apiGet pattern +// as maintainer_noise). No human CLI verb. +registerStdioTool( + "loopover_get_repo_focus_manifest", + { + description: stdioToolDescription("loopover_get_repo_focus_manifest"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }: any) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("LoopOver focus manifest.", await apiGet(`${prefix}/focus-manifest`)); + }, +); + // (#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). diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c03e2a1f3c..36bca915ac 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -170,7 +170,7 @@ import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadi 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 { compileFocusManifestPolicy, 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"; export { buildGateDispositions, type GateDisposition } from "../rules/predicted-gate"; @@ -905,6 +905,13 @@ const maintainerNoiseOutputSchema = { summary: z.string().optional(), }; +// #7808 - repo's own persisted focus manifest + compiled policy (REST GET focus-manifest mirror). +const repoFocusManifestOutputSchema = { + repoFullName: z.string().optional(), + manifest: z.unknown().optional(), + policy: z.unknown().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 = { @@ -1868,6 +1875,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_repo_focus_manifest: "maintainer", loopover_get_activation_preview: "maintainer", loopover_get_label_audit: "maintainer", loopover_get_maintainer_lane: "maintainer", @@ -2005,6 +2013,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getMaintainerNoise(input)), ); + register( + "loopover_get_repo_focus_manifest", + { + description: + "Return a repo's own persisted focus manifest (.loopover.yml policy) plus its compiled policy. Read-only; maintainer/owner/operator authenticated — same auth boundary as GET /v1/repos/:owner/:repo/focus-manifest. Distinct from loopover_validate_config (ad-hoc string validation with no repo lookup).", + inputSchema: ownerRepoShape, + outputSchema: repoFocusManifestOutputSchema, + }, + async (input) => this.toolResult(await this.getRepoFocusManifest(input)), + ); + register( "loopover_get_activation_preview", { @@ -3138,6 +3157,22 @@ export class LoopoverMcp { await this.requireRepoAccess(repoFullName); } + // #7808 - mirror GET /v1/repos/:owner/:repo/focus-manifest auth: requireAppRole([maintainer,owner,operator]) + // plus session requireSessionRepoAccess. Static `mcp` is never trusted (insufficient_role); api/internal are. + private async requireFocusManifestReadAccess(repoFullName: string): Promise { + if (this.identity.kind === "static") { + if (this.identity.actor === "mcp") { + throw new Error("Forbidden: focus-manifest requires a maintainer, owner, or operator session (insufficient_role)."); + } + return; + } + const summary = await loadControlPanelRoleSummary(this.env, this.identity.actor); + if (!summary.roles.some((role) => role === "maintainer" || role === "owner" || role === "operator")) { + throw new Error("Forbidden: maintainer, owner, or operator role is required for focus-manifest (insufficient_role)."); + } + await this.requireRepoAccess(repoFullName); + } + // Stricter than requireRepoAccess (read): a maintainer-MANAGE gate for write actions (#784 propose-action). // A session must own/maintain the repo (or be an operator); api/internal static identities are trusted (they // are operator-only Worker secrets, never handed to end users). The static `mcp` identity is NOT trusted here: @@ -3255,6 +3290,19 @@ export class LoopoverMcp { }; } + // #7808 - thin MCP surface over GET /v1/repos/:owner/:repo/focus-manifest. Same requireAppRole + + // session-repo-access boundary as the REST route; same loadRepoFocusManifest + compileFocusManifestPolicy pair. + private async getRepoFocusManifest(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireFocusManifestReadAccess(fullName); + const manifest = await loadRepoFocusManifest(this.env, fullName); + const policy = compileFocusManifestPolicy(manifest); + return { + summary: `LoopOver focus manifest for ${fullName}.`, + data: { repoFullName: fullName, manifest, policy } as unknown as Record, + }; + } + // (#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. diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 126029f0e7..eea9e534ec 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5530,6 +5530,7 @@ describe("api routes", () => { 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_repo_focus_manifest"); expect(toolNames).toContain("loopover_get_pr_maintainer_packet"); expect(toolNames).toContain("loopover_explain_review_risk"); expect(toolNames).toContain("loopover_compare_pr_variants"); @@ -5807,6 +5808,7 @@ describe("api routes", () => { ["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_repo_focus_manifest", { owner: "entrius", repo: "allways-ui" }], ["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }], [ "loopover_preview_local_pr_score", diff --git a/test/unit/mcp-cli-repo-focus-manifest.test.ts b/test/unit/mcp-cli-repo-focus-manifest.test.ts new file mode 100644 index 0000000000..525bfe8828 --- /dev/null +++ b/test/unit/mcp-cli-repo-focus-manifest.test.ts @@ -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"; + +// #7808: in-process coverage for the loopover_get_repo_focus_manifest stdio tool. +// Same #7764 entrypoint-guard pattern as mcp-cli-gate-config-effective — 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 }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-repo-focus-manifest-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/focus-manifest")) { + 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_repo_focus_manifest stdio tool (in-process, #7808)", () => { + it.each(MODULES)("registers and proxies GET .../focus-manifest — %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: "repo-focus-manifest-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_repo_focus_manifest"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/focus manifest|compiled policy/i); + + const result = await client.callTool({ + name: "loopover_get_repo_focus_manifest", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/focus-manifest"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).toContain("manifest"); + expect(text).toContain("policy"); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index d0a2235ef3..49b49274df 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -17,6 +17,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_get_activation_preview", "loopover_get_live_gate_thresholds", "loopover_get_gate_config_effective", + "loopover_get_repo_focus_manifest", "loopover_get_label_audit", "loopover_get_maintainer_lane", "loopover_get_repo_onboarding_pack", @@ -158,6 +159,10 @@ describe("MCP output schema discovery", () => { const gateConfig = byName.get("loopover_get_gate_config_effective"); const gateConfigProps = Object.keys((gateConfig?.outputSchema?.properties ?? {}) as Record); expect(gateConfigProps).toEqual(expect.arrayContaining(["repoFullName", "effective", "shadowPending"])); + + const focusManifest = byName.get("loopover_get_repo_focus_manifest"); + const focusManifestProps = Object.keys((focusManifest?.outputSchema?.properties ?? {}) as Record); + expect(focusManifestProps).toEqual(expect.arrayContaining(["repoFullName", "manifest", "policy"])); }); it("preserves the full tool inventory while adding output schemas", async () => { @@ -384,6 +389,74 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(result.structuredContent).toEqual({ status: "forbidden", repoFullName: "octo/demo" }); }); + it("loopover_get_repo_focus_manifest returns persisted manifest + policy for trusted api identity (#7808)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + const { client } = await connectTestClient(env, { kind: "static", actor: "api" }); + const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ + repoFullName: "octo/demo", + manifest: expect.anything(), + policy: expect.anything(), + }); + }); + + it("loopover_get_repo_focus_manifest rejects the shared static mcp identity (#7808)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/insufficient_role|maintainer, owner, or operator/i); + }); + + it("loopover_get_repo_focus_manifest rejects sessions without maintainer/owner/operator role (#7808)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + const { client } = await connectTestClient(env, { + kind: "session", + actor: "read-only-member", + session: { + id: "session-read-only-member-focus", + 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_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/insufficient_role|maintainer, owner, or operator/i); + }); + + it("loopover_get_repo_focus_manifest allows an operator session (#7808)", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "focus-operator" }); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + const { client } = await connectTestClient(env, { + kind: "session", + actor: "focus-operator", + session: { + id: "session-focus-operator", + tokenHash: "hash", + login: "focus-operator", + scopes: [], + expiresAt: "2999-01-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + metadata: {}, + }, + }); + const result = await client.callTool({ name: "loopover_get_repo_focus_manifest", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ + repoFullName: "octo/demo", + manifest: expect.anything(), + policy: expect.anything(), + }); + }); + 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" }); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 773a45a5e8..9bf9e3acf0 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -29,6 +29,7 @@ // (#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.) +// (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 87 to 88.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -75,14 +76,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 87 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 88 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(87); + expect(primary.length).toBe(88); expect(legacy.length).toBe(0); - expect(names.length).toBe(87); + expect(names.length).toBe(88); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -94,14 +95,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 87-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 88-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(87); + expect(payload.count).toBe(88); 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 0d354bcc9f..48793e19fd 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -594,6 +594,17 @@ export async function startFixtureServer( ); return; } + // #7808: repo's own persisted focus manifest + compiled policy. + if (request.url === "/v1/repos/owner/repo/focus-manifest" && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + manifest: { version: 1, lanes: { lane: "bug" } }, + policy: { lane: "bug", generatedAt: "2026-06-01T00:00:00.000Z" }, + }), + ); + return; + } if (request.url === "/v1/repos/owner/repo/outcome-patterns" && request.method === "GET") { response.end( JSON.stringify({