diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7acf59b43d..6b47efa240 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1994,6 +1994,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_maintainer_noise: "maintainer", loopover_get_ams_miner_cohort: "maintainer", loopover_get_repo_focus_manifest: "maintainer", + loopover_refresh_repo_focus_manifest: "maintainer", loopover_get_activation_preview: "maintainer", loopover_get_label_audit: "maintainer", loopover_get_maintainer_lane: "maintainer", @@ -2166,6 +2167,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getRepoFocusManifest(input)), ); + register( + "loopover_refresh_repo_focus_manifest", + { + description: + "Force an immediate refresh of a repo's cached focus manifest (.loopover.yml policy) from GitHub, then return the reloaded manifest plus its compiled policy. Write access required -- same requireRepoWriteAccess boundary as POST /v1/repos/:owner/:repo/focus-manifest/refresh, stricter than the read-only loopover_get_repo_focus_manifest. Bypasses the manifest cache (refresh: true), matching loopover_refresh_repo_docs's force-a-fresh-artifact shape.", + inputSchema: ownerRepoShape, + outputSchema: repoFocusManifestOutputSchema, + }, + async (input) => this.toolResult(await this.refreshRepoFocusManifest(input)), + ); + register( "loopover_get_activation_preview", { @@ -3537,6 +3549,23 @@ export class LoopoverMcp { }; } + // #9299 - thin MCP surface over POST /v1/repos/:owner/:repo/focus-manifest/refresh, the refresh COUNTERPART to + // getRepoFocusManifest above (#7808). Forces a live reload of the cached .loopover.yml manifest from GitHub via the + // SAME loadRepoFocusManifest(..., { refresh: true }) + compileFocusManifestPolicy pair the REST route uses, returning + // the identical { repoFullName, manifest, policy } shape. Because it forces a live refresh it takes the write-access + // boundary (requireRepoManageAccess, mirroring the route's requireRepoWriteAccess) -- stricter than the read tool's + // requireFocusManifestReadAccess, matching loopover_refresh_repo_docs's refresh-action auth. + private async refreshRepoFocusManifest(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const manifest = await loadRepoFocusManifest(this.env, fullName, { refresh: true }); + const policy = compileFocusManifestPolicy(manifest); + return { + summary: `Refreshed the LoopOver focus manifest for ${fullName} from GitHub.`, + data: { repoFullName: fullName, manifest, policy } as unknown as Record, + }; + } + // (#7799/#8338) MCP surface for GET /v1/repos/:owner/:repo/activation-preview. Same requireRepoAccess gate // as sibling read-only maintainer reports (REST requireRepoMaintainer). Assembles the same inputs the REST // route does (getRepository + resolveRepositorySettings + listPullRequests) and defers to the guarded diff --git a/test/unit/mcp-refresh-repo-focus-manifest.test.ts b/test/unit/mcp-refresh-repo-focus-manifest.test.ts new file mode 100644 index 0000000000..d7e8275301 --- /dev/null +++ b/test/unit/mcp-refresh-repo-focus-manifest.test.ts @@ -0,0 +1,88 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { createSessionForGitHubUser, type AuthIdentity } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; + +async function connect(env: Env, identity?: AuthIdentity): Promise { + const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "loopover-refresh-repo-focus-manifest-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +// #9299 — refresh COUNTERPART to the read-only loopover_get_repo_focus_manifest (#7808). Mirrors POST +// /v1/repos/:owner/:repo/focus-manifest/refresh: same loadRepoFocusManifest(..., { refresh: true }) + +// compileFocusManifestPolicy pair, same { repoFullName, manifest, policy } shape, and the write-access boundary +// the REST route enforces (requireRepoManageAccess ≈ requireRepoWriteAccess) — stricter than the read tool. +describe("MCP loopover_refresh_repo_focus_manifest (#9299)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("registers under the maintainer category, paired with its read sibling", async () => { + const client = await connect(createTestEnv(), { kind: "static", actor: "api" } as AuthIdentity); + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "loopover_refresh_repo_focus_manifest"); + expect(tool).toBeDefined(); + expect(tool?.description ?? "").toMatch(/refresh/i); + expect((tool?._meta as { category?: string } | undefined)?.category).toBe("maintainer"); + await client.close(); + }); + + it("forces a fresh GitHub load (refresh: true) that bypasses the cache and returns manifest + policy", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + // Seed a stale api_record a NON-refresh read WOULD return (wantedPaths cached/**), so the assertion below + // proves the tool ignored the cache and served the live file instead. + await upsertRepoFocusManifest(env, REPO, { wantedPaths: ["cached/**"] }); + const fetched: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("raw.githubusercontent.com") && url.endsWith("/.loopover.yml")) { + fetched.push(url); + return new Response(JSON.stringify({ wantedPaths: ["live/**"] }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const client = await connect(env, { kind: "static", actor: "api" } as AuthIdentity); + const result = await client.callTool({ name: "loopover_refresh_repo_focus_manifest", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + // The live raw file WAS fetched — a cached (refresh: false) read would never have hit GitHub. + expect(fetched.length).toBeGreaterThan(0); + const data = result.structuredContent as { repoFullName: string; manifest: { wantedPaths: string[] }; policy: unknown }; + expect(data.repoFullName).toBe(REPO); + // ...and the LIVE content ("live/**"), not the stale cache ("cached/**"), is what came back. + expect(data.manifest.wantedPaths).toEqual(["live/**"]); + expect(data.policy).toBeDefined(); + await client.close(); + }); + + it("denies a static MCP-token caller when the repo is not in MCP_ACTUATION_REPO_ALLOWLIST", async () => { + const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + const client = await connect(env); // default identity: { kind: "static", actor: "mcp" } + const result = await client.callTool({ name: "loopover_refresh_repo_focus_manifest", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/); + await client.close(); + }); + + it("rejects a read-only session without write access — distinct from the read tool's weaker boundary", async () => { + const env = createTestEnv(); + const { session } = await createSessionForGitHubUser(env, { login: "reader1", id: 7 }); + const client = await connect(env, { kind: "session", actor: "reader1", session }); + const result = await client.callTool({ name: "loopover_refresh_repo_focus_manifest", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/write access is required/i); + await client.close(); + }); +});