diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 45a6614e33..ffe1a652d6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -150,6 +150,7 @@ import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENA import { loadUpstreamStatus } from "../upstream/ruleset"; import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/open-pr-pressure-scenarios"; import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/finding-taxonomy"; +import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy"; type AppContext = Context<{ Bindings: Env }>; type ToolPayload = { @@ -2028,6 +2029,26 @@ export class GittensoryMcp { }), ); + // #2226 — read-only REES enrichment analyzer taxonomy for MCP discovery. + server.registerResource( + "gittensory_enrichment_analyzers", + ENRICHMENT_ANALYZERS_URI, + { + title: "Gittensory Enrichment Analyzers", + description: "REES enrichment analyzer taxonomy: names, categories, cost classes, and default profiles.", + mimeType: "application/json", + }, + async () => ({ + contents: [ + { + uri: ENRICHMENT_ANALYZERS_URI, + mimeType: "application/json", + text: JSON.stringify(buildEnrichmentAnalyzersTaxonomyDocument(), null, 2), + }, + ], + }), + ); + return server; } diff --git a/src/review/enrichment-analyzers-taxonomy.ts b/src/review/enrichment-analyzers-taxonomy.ts new file mode 100644 index 0000000000..0ac0aacff3 --- /dev/null +++ b/src/review/enrichment-analyzers-taxonomy.ts @@ -0,0 +1,40 @@ +import analyzerMetadata from "../../review-enrichment/analyzer-metadata.json"; + +/** MCP resource URI for the REES enrichment analyzer taxonomy (#2226). */ +export const ENRICHMENT_ANALYZERS_URI = "gittensory://enrichment-analyzers" as const; + +type AnalyzerMetadataFile = { + defaultProfile: string; + analyzers: Array<{ + name: string; + category: string; + cost: string; + profiles: string[]; + }>; +}; + +export interface EnrichmentAnalyzerTaxonomyEntry { + name: string; + category: string; + costClass: string; + profiles: readonly string[]; +} + +export interface EnrichmentAnalyzersTaxonomyDocument { + defaultProfile: string; + analyzers: readonly EnrichmentAnalyzerTaxonomyEntry[]; +} + +/** Static taxonomy for REES enrichment analyzers — sourced from committed analyzer-metadata.json. */ +export function buildEnrichmentAnalyzersTaxonomyDocument(): EnrichmentAnalyzersTaxonomyDocument { + const metadata = analyzerMetadata as AnalyzerMetadataFile; + return { + defaultProfile: metadata.defaultProfile, + analyzers: metadata.analyzers.map((analyzer) => ({ + name: analyzer.name, + category: analyzer.category, + costClass: analyzer.cost, + profiles: [...analyzer.profiles], + })), + }; +} diff --git a/test/unit/enrichment-analyzers-taxonomy.test.ts b/test/unit/enrichment-analyzers-taxonomy.test.ts new file mode 100644 index 0000000000..2f191415ff --- /dev/null +++ b/test/unit/enrichment-analyzers-taxonomy.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildEnrichmentAnalyzersTaxonomyDocument, + ENRICHMENT_ANALYZERS_URI, +} from "../../src/review/enrichment-analyzers-taxonomy"; + +const metadataPath = join(process.cwd(), "review-enrichment/analyzer-metadata.json"); + +describe("enrichment analyzers taxonomy document", () => { + it("projects analyzer-metadata.json into the MCP taxonomy shape", () => { + const raw = JSON.parse(readFileSync(metadataPath, "utf8")) as { + defaultProfile: string; + analyzers: Array<{ name: string; category: string; cost: string; profiles: string[] }>; + }; + const doc = buildEnrichmentAnalyzersTaxonomyDocument(); + expect(doc.defaultProfile).toBe(raw.defaultProfile); + expect(doc.analyzers).toHaveLength(raw.analyzers.length); + expect(doc.analyzers.map((a) => a.name)).toEqual(raw.analyzers.map((a) => a.name)); + for (const [index, analyzer] of raw.analyzers.entries()) { + expect(doc.analyzers[index]).toEqual({ + name: analyzer.name, + category: analyzer.category, + costClass: analyzer.cost, + profiles: [...analyzer.profiles], + }); + } + }); + + it("includes the canonical REES analyzer categories", () => { + const doc = buildEnrichmentAnalyzersTaxonomyDocument(); + const categories = new Set(doc.analyzers.map((analyzer) => analyzer.category)); + for (const category of ["supply-chain", "security", "performance", "ownership"]) { + expect(categories).toContain(category); + } + for (const analyzer of doc.analyzers) { + expect(analyzer.category.length).toBeGreaterThan(0); + expect(analyzer.costClass.length).toBeGreaterThan(0); + expect(analyzer.profiles.length).toBeGreaterThan(0); + } + }); + + it("uses the stable MCP resource URI", () => { + expect(ENRICHMENT_ANALYZERS_URI).toBe("gittensory://enrichment-analyzers"); + }); +}); diff --git a/test/unit/mcp-enrichment-analyzers.test.ts b/test/unit/mcp-enrichment-analyzers.test.ts new file mode 100644 index 0000000000..25d7c35e5a --- /dev/null +++ b/test/unit/mcp-enrichment-analyzers.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { ENRICHMENT_ANALYZERS_URI } from "../../src/review/enrichment-analyzers-taxonomy"; +import { createTestEnv } from "../helpers/d1"; + +const metadataPath = join(process.cwd(), "review-enrichment/analyzer-metadata.json"); + +async function connectTestClient() { + const mcpServer = new GittensoryMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverTransport); + const client = new Client({ name: "gittensory-enrichment-analyzers-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return { client, mcpServer }; +} + +describe("MCP enrichment-analyzers resource (#2226)", () => { + it("discovers the enrichment-analyzers resource", async () => { + const { client } = await connectTestClient(); + const { resources } = await client.listResources(); + expect(resources.map((r) => r.uri)).toContain(ENRICHMENT_ANALYZERS_URI); + }); + + it("returns analyzers with categories, cost classes, and profiles as JSON", async () => { + const raw = JSON.parse(readFileSync(metadataPath, "utf8")) as { + defaultProfile: string; + analyzers: Array<{ name: string; category: string; cost: string; profiles: string[] }>; + }; + const { client } = await connectTestClient(); + const result = await client.readResource({ uri: ENRICHMENT_ANALYZERS_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"); + const body = JSON.parse(content.text ?? "") as { + defaultProfile: string; + analyzers: Array<{ name: string; category: string; costClass: string; profiles: string[] }>; + }; + expect(body.defaultProfile).toBe(raw.defaultProfile); + expect(body.analyzers).toHaveLength(raw.analyzers.length); + for (const expected of raw.analyzers) { + const actual = body.analyzers.find((analyzer) => analyzer.name === expected.name); + expect(actual).toMatchObject({ + category: expected.category, + costClass: expected.cost, + profiles: expected.profiles, + }); + } + for (const category of ["supply-chain", "security", "performance", "ownership"]) { + expect(body.analyzers.some((analyzer) => analyzer.category === category)).toBe(true); + } + }); +});