From d66f74384ef56a4575e5d150dffc1028eb81b563 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:58:53 +0200 Subject: [PATCH] feat(mcp): REST routes + CLI resource mirrors for finding-taxonomy and enrichment-analyzers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finding-taxonomy and enrichment-analyzers documents were exposed only as remote MCP resources, with no REST route or local stdio CLI mirror — unlike loopover_compatibility, which already has both. This adds them, following that resource's established pattern exactly. - Two unauthenticated GET routes in src/api/routes.ts alongside /v1/mcp/compatibility: /v1/mcp/finding-taxonomy returns buildFindingTaxonomyDocument(), /v1/mcp/enrichment-analyzers returns buildEnrichmentAnalyzersTaxonomyDocument() — reusing the builders as-is. Both are added to requiresApiToken's exclusion list, so they are public the same way /v1/mcp/compatibility is (the documents carry only committed public enums/analyzer metadata, no DB/env/private data). - Two server.registerResource blocks in packages/loopover-mcp/bin/loopover-mcp.js after loopover_compatibility, reusing the exact URIs the remote server registers (loopover://finding-taxonomy and the legacy gittensory://enrichment-analyzers), each proxying its new route via apiGet with the same { status: "unavailable" } try/catch fallback. Tests: unauthenticated route-level assertions of each document's shape (api.test.ts), and stdio-server discovery + read assertions for both new resources (mcp-discovery.test.ts). Closes #6620 --- packages/loopover-mcp/bin/loopover-mcp.js | 43 +++++++++++++++++++++++ src/api/routes.ts | 9 +++++ test/integration/api.test.ts | 17 +++++++++ test/unit/mcp-discovery.test.ts | 17 +++++++++ 4 files changed, 86 insertions(+) diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 2f8e61d3e8..57153d64aa 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -50,6 +50,8 @@ const npmRegistryUrl = (process.env.LOOPOVER_NPM_REGISTRY_URL ?? "https://regist const upgradeCommand = `npm install -g ${packageName}@latest`; const npxFallbackCommand = `npx ${packageName}@latest `; const compatibilityPath = "/v1/mcp/compatibility"; +const findingTaxonomyPath = "/v1/mcp/finding-taxonomy"; +const enrichmentAnalyzersPath = "/v1/mcp/enrichment-analyzers"; const currentApiVersion = "0.1.0"; const decisionPackCacheSchemaVersion = 1; const decisionPackCacheMaxEntries = 25; @@ -2205,6 +2207,47 @@ server.registerResource( }, ); +// #6620: mirror the two remote static-document MCP resources over the local stdio server, proxying the new +// unauthenticated REST routes the same way loopover_compatibility proxies /v1/mcp/compatibility. Reuse the exact +// URIs the remote server registers (enrichment-analyzers keeps its legacy gittensory:// URI on purpose). +server.registerResource( + "loopover_finding_taxonomy", + "loopover://finding-taxonomy", + { + title: "LoopOver Finding Taxonomy", + description: "Static taxonomy of AI-review finding categories and the severity ladder.", + mimeType: "application/json", + }, + async () => { + let data; + try { + data = await apiGet(findingTaxonomyPath); + } catch { + data = { status: "unavailable" }; + } + return { contents: [{ uri: "loopover://finding-taxonomy", mimeType: "application/json", text: JSON.stringify(data, null, 2) }] }; + }, +); + +server.registerResource( + "loopover_enrichment_analyzers", + "gittensory://enrichment-analyzers", + { + title: "LoopOver Enrichment Analyzers", + description: "Static taxonomy of REES enrichment analyzers: names, categories, and cost classes.", + mimeType: "application/json", + }, + async () => { + let data; + try { + data = await apiGet(enrichmentAnalyzersPath); + } catch { + data = { status: "unavailable" }; + } + return { contents: [{ uri: "gittensory://enrichment-analyzers", mimeType: "application/json", text: JSON.stringify(data, null, 2) }] }; + }, +); + server.registerResource( "loopover_decision_pack", new ResourceTemplate("loopover://decision-packs/{login}", { list: undefined }), diff --git a/src/api/routes.ts b/src/api/routes.ts index 9c8991e9f3..c1bf5f88d7 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -7,6 +7,8 @@ import { completeGitHubWebOAuth, createSessionFromGitHubToken, getLiveSessionGit import { enforceRateLimit, routeClassForPath } from "../auth/rate-limit"; import { handleShot } from "../review/visual/shot"; import { isScreenshotsEnabled } from "../review/visual-wire"; +import { buildFindingTaxonomyDocument } from "../review/finding-taxonomy"; +import { buildEnrichmentAnalyzersTaxonomyDocument } from "../review/enrichment-analyzers-taxonomy"; import { BROWSER_SESSION_COOKIE, GITHUB_OAUTH_STATE_COOKIE, @@ -989,6 +991,11 @@ export function createApp() { }), ); app.get("/v1/mcp/compatibility", (c) => c.json(buildMcpCompatibilityMetadata(nowIso()))); + // #6620: unauthenticated static-document routes mirroring the two remote MCP resources, so the local CLI + // can proxy them the same way it proxies /v1/mcp/compatibility. Both documents carry only committed public + // enums/analyzer metadata (no DB/env/private data); excluded from requiresApiToken below. + app.get("/v1/mcp/finding-taxonomy", (c) => c.json(buildFindingTaxonomyDocument())); + app.get("/v1/mcp/enrichment-analyzers", (c) => c.json(buildEnrichmentAnalyzersTaxonomyDocument())); app.get("/openapi.json", (c) => c.json(buildOpenApiSpec())); app.all("/mcp", handleMcpRequest); @@ -6031,6 +6038,8 @@ async function isAuthorizedAmsIngest(env: Env, token: string | undefined): Promi function requiresApiToken(path: string): boolean { if (path === "/health") return false; if (path === "/v1/mcp/compatibility") return false; + if (path === "/v1/mcp/finding-taxonomy") return false; + if (path === "/v1/mcp/enrichment-analyzers") return false; if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false; if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/badge\.(svg|json)$/.test(path)) return false; if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 866ad4923d..27354a6268 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -136,6 +136,23 @@ describe("api routes", () => { const unauthenticatedSpec = await app.request("/openapi.json", {}, env); expect(unauthenticatedSpec.status).toBe(200); await expect(unauthenticatedSpec.json()).resolves.toMatchObject({ info: { title: "LoopOver API" } }); + + // #6620: the two static-document MCP routes are served UNAUTHENTICATED (empty init = no api token), the + // same public treatment as /v1/mcp/compatibility, and return exactly what their builders produce. + const findingTaxonomy = await app.request("/v1/mcp/finding-taxonomy", {}, env); + expect(findingTaxonomy.status).toBe(200); + const findingTaxonomyPayload = (await findingTaxonomy.json()) as { categories: unknown[]; severities: unknown[] }; + expect(Array.isArray(findingTaxonomyPayload.categories)).toBe(true); + expect(Array.isArray(findingTaxonomyPayload.severities)).toBe(true); + expect(findingTaxonomyPayload.categories.length).toBeGreaterThan(0); + expect(findingTaxonomyPayload.severities.length).toBeGreaterThan(0); + + const enrichmentAnalyzers = await app.request("/v1/mcp/enrichment-analyzers", {}, env); + expect(enrichmentAnalyzers.status).toBe(200); + const enrichmentAnalyzersPayload = (await enrichmentAnalyzers.json()) as { analyzers: Array<{ name: string; costClass: string }> }; + expect(Array.isArray(enrichmentAnalyzersPayload.analyzers)).toBe(true); + expect(enrichmentAnalyzersPayload.analyzers.length).toBeGreaterThan(0); + expect(enrichmentAnalyzersPayload.analyzers[0]).toMatchObject({ name: expect.any(String), costClass: expect.any(String) }); }); it("serves public GitHub repo stats without relying on browser GitHub quota", async () => { diff --git a/test/unit/mcp-discovery.test.ts b/test/unit/mcp-discovery.test.ts index abb677a377..bf151aa33c 100644 --- a/test/unit/mcp-discovery.test.ts +++ b/test/unit/mcp-discovery.test.ts @@ -115,6 +115,9 @@ describe("MCP resource discovery", () => { const uris = resources.map((r) => r.uri); expect(uris).toContain("loopover://changelog"); expect(uris).toContain("loopover://compatibility"); + // #6620: the two static-document mirrors, using the same URIs the remote server registers. + expect(uris).toContain("loopover://finding-taxonomy"); + expect(uris).toContain("gittensory://enrichment-analyzers"); }); it("resource descriptions do not expose forbidden public terms", async () => { @@ -151,6 +154,20 @@ describe("MCP resource discovery", () => { expect(() => JSON.parse(content.text ?? "")).not.toThrow(); }); + it.each(["loopover://finding-taxonomy", "gittensory://enrichment-analyzers"])( + "can read the %s resource and get structured JSON (#6620)", + async (uri) => { + const result = await client.readResource({ uri }); + expect(result.contents).toHaveLength(1); + const content = result.contents[0]; + expect(content?.mimeType).toBe("application/json"); + if (!content || !("text" in content)) throw new Error("expected text content"); + // Parseable JSON either way: the real document over the proxied route, or the + // { status: "unavailable" } fallback when the fixture server doesn't serve the path. + expect(() => JSON.parse(content.text ?? "")).not.toThrow(); + }, + ); + it("decision-pack resource template is discoverable", async () => { const { resourceTemplates } = await client.listResourceTemplates(); const names = resourceTemplates.map((t) => t.name);