Skip to content

Commit 6e0aa6c

Browse files
feat(mcp): add REST + CLI mirrors for finding and enrichment taxonomies (#6620)
1 parent 0e313c8 commit 6e0aa6c

8 files changed

Lines changed: 261 additions & 2 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const npmRegistryUrl = (process.env.LOOPOVER_NPM_REGISTRY_URL ?? "https://regist
5050
const upgradeCommand = `npm install -g ${packageName}@latest`;
5151
const npxFallbackCommand = `npx ${packageName}@latest <command>`;
5252
const compatibilityPath = "/v1/mcp/compatibility";
53+
const findingTaxonomyPath = "/v1/mcp/finding-taxonomy";
54+
const enrichmentAnalyzersPath = "/v1/mcp/enrichment-analyzers";
5355
const currentApiVersion = "0.1.0";
5456
const decisionPackCacheSchemaVersion = 1;
5557
const decisionPackCacheMaxEntries = 25;
@@ -2137,14 +2139,52 @@ server.registerResource(
21372139
async () => {
21382140
let data;
21392141
try {
2140-
data = await apiGet(compatibilityPath);
2142+
data = await apiFetch(compatibilityPath, { method: "GET" }, { auth: false });
21412143
} catch {
21422144
data = { status: "unavailable", currentApiVersion, packageVersion };
21432145
}
21442146
return { contents: [{ uri: "loopover://compatibility", mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
21452147
},
21462148
);
21472149

2150+
server.registerResource(
2151+
"loopover_finding_taxonomy",
2152+
"loopover://finding-taxonomy",
2153+
{
2154+
title: "LoopOver Finding Taxonomy",
2155+
description: "Canonical AI review finding categories and severity levels for discovery without hard-coding.",
2156+
mimeType: "application/json",
2157+
},
2158+
async () => {
2159+
let data;
2160+
try {
2161+
data = await apiFetch(findingTaxonomyPath, { method: "GET" }, { auth: false });
2162+
} catch {
2163+
data = { status: "unavailable" };
2164+
}
2165+
return { contents: [{ uri: "loopover://finding-taxonomy", mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
2166+
},
2167+
);
2168+
2169+
server.registerResource(
2170+
"loopover_enrichment_analyzers",
2171+
"gittensory://enrichment-analyzers",
2172+
{
2173+
title: "LoopOver Enrichment Analyzers",
2174+
description: "REES enrichment analyzer taxonomy: names, categories, cost classes, and default profiles.",
2175+
mimeType: "application/json",
2176+
},
2177+
async () => {
2178+
let data;
2179+
try {
2180+
data = await apiFetch(enrichmentAnalyzersPath, { method: "GET" }, { auth: false });
2181+
} catch {
2182+
data = { status: "unavailable" };
2183+
}
2184+
return { contents: [{ uri: "gittensory://enrichment-analyzers", mimeType: "application/json", text: JSON.stringify(data, null, 2) }] };
2185+
},
2186+
);
2187+
21482188
server.registerResource(
21492189
"loopover_decision_pack",
21502190
new ResourceTemplate("loopover://decision-packs/{login}", { list: undefined }),

src/api/routes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ import { generateAndSendReviewRecap } from "../services/review-recap";
212212
import { loadOrComputeIssueQualityResponse } from "../services/issue-quality";
213213
import { loadMaintainerNoiseReport } from "../services/maintainer-noise";
214214
import { buildAmsMinerCohortComparison } from "../review/ams-miner-cohort";
215+
import { buildEnrichmentAnalyzersTaxonomyDocument } from "../review/enrichment-analyzers-taxonomy";
216+
import { buildFindingTaxonomyDocument } from "../review/finding-taxonomy";
215217
import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast";
216218
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
217219
import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns";
@@ -986,6 +988,8 @@ export function createApp() {
986988
}),
987989
);
988990
app.get("/v1/mcp/compatibility", (c) => c.json(buildMcpCompatibilityMetadata(nowIso())));
991+
app.get("/v1/mcp/finding-taxonomy", (c) => c.json(buildFindingTaxonomyDocument()));
992+
app.get("/v1/mcp/enrichment-analyzers", (c) => c.json(buildEnrichmentAnalyzersTaxonomyDocument()));
989993
app.get("/openapi.json", (c) => c.json(buildOpenApiSpec()));
990994
app.all("/mcp", handleMcpRequest);
991995

@@ -5988,6 +5992,8 @@ async function isAuthorizedAmsIngest(env: Env, token: string | undefined): Promi
59885992
function requiresApiToken(path: string): boolean {
59895993
if (path === "/health") return false;
59905994
if (path === "/v1/mcp/compatibility") return false;
5995+
if (path === "/v1/mcp/finding-taxonomy") return false;
5996+
if (path === "/v1/mcp/enrichment-analyzers") return false;
59915997
if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false;
59925998
if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/badge\.(svg|json)$/.test(path)) return false;
59935999
if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false;

src/auth/rate-limit.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,14 @@ const SESSION_AUTHENTICATED_AUTH_PATHS = new Set(["/v1/auth/github/token", "/v1/
233233

234234
function isPreAuthRateLimitPath(path: string): boolean {
235235
return (
236-
(path === "/health" || path === "/v1/mcp/compatibility" || path === "/openapi.json" || path === "/mcp" || path.startsWith("/v1/auth/") || path === "/v1/github/webhook") &&
236+
(path === "/health" ||
237+
path === "/v1/mcp/compatibility" ||
238+
path === "/v1/mcp/finding-taxonomy" ||
239+
path === "/v1/mcp/enrichment-analyzers" ||
240+
path === "/openapi.json" ||
241+
path === "/mcp" ||
242+
path.startsWith("/v1/auth/") ||
243+
path === "/v1/github/webhook") &&
237244
!SESSION_AUTHENTICATED_AUTH_PATHS.has(path)
238245
);
239246
}

test/integration/api.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ describe("api routes", () => {
133133
});
134134
expect(JSON.stringify(compatibilityPayload)).not.toMatch(/token|admin|wallet|hotkey|raw trust|scoreability|private repo|local-path/i);
135135

136+
const findingTaxonomy = await app.request("/v1/mcp/finding-taxonomy", {}, env);
137+
expect(findingTaxonomy.status).toBe(200);
138+
const findingTaxonomyPayload = await findingTaxonomy.json();
139+
expect(findingTaxonomyPayload).toMatchObject({
140+
categories: expect.any(Array),
141+
severities: expect.any(Array),
142+
});
143+
expect(findingTaxonomyPayload.categories.length).toBeGreaterThan(0);
144+
expect(findingTaxonomyPayload.severities.length).toBeGreaterThan(0);
145+
146+
const enrichmentAnalyzers = await app.request("/v1/mcp/enrichment-analyzers", {}, env);
147+
expect(enrichmentAnalyzers.status).toBe(200);
148+
const enrichmentAnalyzersPayload = await enrichmentAnalyzers.json();
149+
expect(enrichmentAnalyzersPayload).toMatchObject({
150+
defaultProfile: expect.any(String),
151+
analyzers: expect.any(Array),
152+
});
153+
expect(enrichmentAnalyzersPayload.analyzers.length).toBeGreaterThan(0);
154+
136155
const unauthenticatedSpec = await app.request("/openapi.json", {}, env);
137156
expect(unauthenticatedSpec.status).toBe(200);
138157
await expect(unauthenticatedSpec.json()).resolves.toMatchObject({ info: { title: "LoopOver API" } });

test/unit/mcp-discovery.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { tmpdir } from "node:os";
77
import { join } from "node:path";
88
import { pathToFileURL } from "node:url";
99
import { afterEach, beforeEach, describe, expect, it } from "vitest";
10+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
11+
import { ENRICHMENT_ANALYZERS_URI } from "../../src/review/enrichment-analyzers-taxonomy";
12+
import { FINDING_TAXONOMY_URI } from "../../src/review/finding-taxonomy";
1013

1114
const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
1215

@@ -115,6 +118,8 @@ describe("MCP resource discovery", () => {
115118
const uris = resources.map((r) => r.uri);
116119
expect(uris).toContain("loopover://changelog");
117120
expect(uris).toContain("loopover://compatibility");
121+
expect(uris).toContain(FINDING_TAXONOMY_URI);
122+
expect(uris).toContain(ENRICHMENT_ANALYZERS_URI);
118123
});
119124

120125
it("resource descriptions do not expose forbidden public terms", async () => {
@@ -151,13 +156,99 @@ describe("MCP resource discovery", () => {
151156
expect(() => JSON.parse(content.text ?? "")).not.toThrow();
152157
});
153158

159+
it("can read the finding-taxonomy resource and get structured JSON", async () => {
160+
const result = await client.readResource({ uri: FINDING_TAXONOMY_URI });
161+
expect(result.contents).toHaveLength(1);
162+
const content = result.contents[0];
163+
expect(content?.mimeType).toBe("application/json");
164+
if (!content || !("text" in content)) throw new Error("expected text content");
165+
expect(() => JSON.parse(content.text ?? "")).not.toThrow();
166+
});
167+
168+
it("can read the enrichment-analyzers resource and get structured JSON", async () => {
169+
const result = await client.readResource({ uri: ENRICHMENT_ANALYZERS_URI });
170+
expect(result.contents).toHaveLength(1);
171+
const content = result.contents[0];
172+
expect(content?.mimeType).toBe("application/json");
173+
if (!content || !("text" in content)) throw new Error("expected text content");
174+
expect(() => JSON.parse(content.text ?? "")).not.toThrow();
175+
});
176+
154177
it("decision-pack resource template is discoverable", async () => {
155178
const { resourceTemplates } = await client.listResourceTemplates();
156179
const names = resourceTemplates.map((t) => t.name);
157180
expect(names).toContain("loopover_decision_pack");
158181
});
159182
});
160183

184+
describe("MCP taxonomy resource mirrors (#6620)", () => {
185+
let fixtureUrl: string;
186+
let fixtureConfigDir: string;
187+
let fixtureClient: Client;
188+
let fixtureTransport: StdioClientTransport;
189+
190+
async function connectFixtureClient() {
191+
fixtureConfigDir = mkdtempSync(join(tmpdir(), "gittensory-taxonomy-mirror-"));
192+
fixtureTransport = new StdioClientTransport({
193+
command: "node",
194+
args: [bin, "--stdio"],
195+
env: {
196+
...process.env,
197+
LOOPOVER_API_URL: fixtureUrl,
198+
LOOPOVER_CONFIG_DIR: fixtureConfigDir,
199+
LOOPOVER_API_TIMEOUT_MS: "1000",
200+
},
201+
});
202+
fixtureClient = new Client({ name: "taxonomy-mirror-test", version: "0.0.1" });
203+
await fixtureClient.connect(fixtureTransport);
204+
}
205+
206+
afterEach(async () => {
207+
await fixtureClient?.close().catch(() => undefined);
208+
if (fixtureConfigDir) rmSync(fixtureConfigDir, { recursive: true, force: true });
209+
await closeFixtureServer();
210+
});
211+
212+
it("reads finding taxonomy from the API mirror when available", async () => {
213+
fixtureUrl = await startFixtureServer({});
214+
await connectFixtureClient();
215+
const result = await fixtureClient.readResource({ uri: FINDING_TAXONOMY_URI });
216+
const content = result.contents[0];
217+
if (!content || !("text" in content)) throw new Error("expected text content");
218+
const body = JSON.parse(content.text ?? "") as { categories: string[]; severities: string[] };
219+
expect(body.categories.length).toBeGreaterThan(0);
220+
expect(body.severities.length).toBeGreaterThan(0);
221+
});
222+
223+
it("falls back to unavailable when finding-taxonomy API fetch fails", async () => {
224+
fixtureUrl = await startFixtureServer({ findingTaxonomyStatus: 503 });
225+
await connectFixtureClient();
226+
const result = await fixtureClient.readResource({ uri: FINDING_TAXONOMY_URI });
227+
const content = result.contents[0];
228+
if (!content || !("text" in content)) throw new Error("expected text content");
229+
expect(JSON.parse(content.text ?? "")).toMatchObject({ status: "unavailable" });
230+
});
231+
232+
it("reads enrichment analyzers from the API mirror when available", async () => {
233+
fixtureUrl = await startFixtureServer({});
234+
await connectFixtureClient();
235+
const result = await fixtureClient.readResource({ uri: ENRICHMENT_ANALYZERS_URI });
236+
const content = result.contents[0];
237+
if (!content || !("text" in content)) throw new Error("expected text content");
238+
const body = JSON.parse(content.text ?? "") as { analyzers: Array<{ name: string }> };
239+
expect(body.analyzers.length).toBeGreaterThan(0);
240+
});
241+
242+
it("falls back to unavailable when enrichment-analyzers API fetch fails", async () => {
243+
fixtureUrl = await startFixtureServer({ enrichmentAnalyzersStatus: 503 });
244+
await connectFixtureClient();
245+
const result = await fixtureClient.readResource({ uri: ENRICHMENT_ANALYZERS_URI });
246+
const content = result.contents[0];
247+
if (!content || !("text" in content)) throw new Error("expected text content");
248+
expect(JSON.parse(content.text ?? "")).toMatchObject({ status: "unavailable" });
249+
});
250+
});
251+
161252
describe("MCP prompt discovery", () => {
162253
beforeEach(connect);
163254
afterEach(disconnect);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
import { createApp } from "../../src/api/routes";
5+
import { buildEnrichmentAnalyzersTaxonomyDocument } from "../../src/review/enrichment-analyzers-taxonomy";
6+
import { createTestEnv } from "../helpers/d1";
7+
8+
const metadataPath = join(process.cwd(), "review-enrichment/analyzer-metadata.json");
9+
10+
describe("GET /v1/mcp/enrichment-analyzers (#6620)", () => {
11+
it("serves the canonical enrichment analyzer taxonomy without authentication", async () => {
12+
const app = createApp();
13+
const env = createTestEnv();
14+
15+
const response = await app.request("/v1/mcp/enrichment-analyzers", {}, env);
16+
expect(response.status).toBe(200);
17+
await expect(response.json()).resolves.toEqual(buildEnrichmentAnalyzersTaxonomyDocument());
18+
});
19+
20+
it("projects analyzer-metadata.json into the REST taxonomy shape", async () => {
21+
const raw = JSON.parse(readFileSync(metadataPath, "utf8")) as {
22+
defaultProfile: string;
23+
analyzers: Array<{ name: string; category: string; cost: string; profiles: string[] }>;
24+
};
25+
const app = createApp();
26+
const env = createTestEnv();
27+
28+
const body = (await (await app.request("/v1/mcp/enrichment-analyzers", {}, env)).json()) as {
29+
defaultProfile: string;
30+
analyzers: Array<{ name: string; category: string; costClass: string; profiles: string[] }>;
31+
};
32+
expect(body.defaultProfile).toBe(raw.defaultProfile);
33+
expect(body.analyzers).toHaveLength(raw.analyzers.length);
34+
for (const expected of raw.analyzers) {
35+
const actual = body.analyzers.find((analyzer) => analyzer.name === expected.name);
36+
expect(actual).toMatchObject({
37+
category: expected.category,
38+
costClass: expected.cost,
39+
profiles: expected.profiles,
40+
});
41+
}
42+
});
43+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createApp } from "../../src/api/routes";
3+
import { FINDING_CATEGORIES } from "../../src/review/finding-category-classify";
4+
import { buildFindingTaxonomyDocument } from "../../src/review/finding-taxonomy";
5+
import { REVIEW_FINDING_SEVERITY_LADDER } from "../../src/signals/focus-manifest";
6+
import { createTestEnv } from "../helpers/d1";
7+
8+
describe("GET /v1/mcp/finding-taxonomy (#6620)", () => {
9+
it("serves the canonical finding taxonomy without authentication", async () => {
10+
const app = createApp();
11+
const env = createTestEnv();
12+
13+
const response = await app.request("/v1/mcp/finding-taxonomy", {}, env);
14+
expect(response.status).toBe(200);
15+
await expect(response.json()).resolves.toEqual(buildFindingTaxonomyDocument());
16+
});
17+
18+
it("returns categories and severities exactly once", async () => {
19+
const app = createApp();
20+
const env = createTestEnv();
21+
22+
const body = (await (await app.request("/v1/mcp/finding-taxonomy", {}, env)).json()) as {
23+
categories: string[];
24+
severities: string[];
25+
};
26+
expect(body.categories).toEqual([...FINDING_CATEGORIES]);
27+
expect(body.severities).toEqual([...REVIEW_FINDING_SEVERITY_LADDER]);
28+
expect(new Set(body.categories).size).toBe(FINDING_CATEGORIES.length);
29+
expect(new Set(body.severities).size).toBe(REVIEW_FINDING_SEVERITY_LADDER.length);
30+
});
31+
});

test/unit/support/mcp-cli-harness.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { expect } from "vitest";
7+
import { buildEnrichmentAnalyzersTaxonomyDocument } from "../../../src/review/enrichment-analyzers-taxonomy";
8+
import { buildFindingTaxonomyDocument } from "../../../src/review/finding-taxonomy";
79

810
export const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
911
let server: Server | null = null;
@@ -139,6 +141,8 @@ export async function startFixtureServer(
139141
latestRecommendedMcpVersion?: string;
140142
minMcpVersion?: string;
141143
compatibilityStatus?: number;
144+
findingTaxonomyStatus?: number;
145+
enrichmentAnalyzersStatus?: number;
142146
npmStatus?: number;
143147
decisionPackStatus?: number;
144148
decisionPackErrorBody?: string;
@@ -198,6 +202,24 @@ export async function startFixtureServer(
198202
);
199203
return;
200204
}
205+
if (request.url === "/v1/mcp/finding-taxonomy") {
206+
if (options.findingTaxonomyStatus && options.findingTaxonomyStatus >= 400) {
207+
response.statusCode = options.findingTaxonomyStatus;
208+
response.end(JSON.stringify({ error: "finding_taxonomy_unavailable" }));
209+
return;
210+
}
211+
response.end(JSON.stringify(buildFindingTaxonomyDocument()));
212+
return;
213+
}
214+
if (request.url === "/v1/mcp/enrichment-analyzers") {
215+
if (options.enrichmentAnalyzersStatus && options.enrichmentAnalyzersStatus >= 400) {
216+
response.statusCode = options.enrichmentAnalyzersStatus;
217+
response.end(JSON.stringify({ error: "enrichment_analyzers_unavailable" }));
218+
return;
219+
}
220+
response.end(JSON.stringify(buildEnrichmentAnalyzersTaxonomyDocument()));
221+
return;
222+
}
201223
if (request.url === "/health") {
202224
response.end(JSON.stringify({ status: "ok", service: "loopover-api", ...(options.minMcpVersion ? { minMcpVersion: options.minMcpVersion } : {}) }));
203225
return;

0 commit comments

Comments
 (0)