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
43 changes: 43 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>`;
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;
Expand Down Expand Up @@ -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 }),
Expand Down
9 changes: 9 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 17 additions & 0 deletions test/unit/mcp-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down