Files
rea/scripts/generate-mcp-tool-catalog.mjs

108 lines
4.0 KiB
JavaScript

import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { McpServer } from "@modelcontextprotocol/server";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../dist/contracts/managed/managedWorkflowToolContracts.js";
import { toolRegistrationOptions } from "../dist/server/toolRegistrationOptions.js";
import { ensureGeneratedFile } from "./lib/generated-file.mjs";
const arguments_ = new Set(process.argv.slice(2));
for (const argument of arguments_)
if (argument !== "--check")
throw new Error(`Unknown MCP tool catalog option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
// This generated metadata supports schema conformance tests;
// production MCP tools are registered from their canonical contracts directly.
// Keep the test metadata portable and regenerate it with `npm run mcp-catalog:generate`.
const sessionToolNames = new Set([
...TOOL_CONTRACTS.filter(({ kind }) => kind === "session").map(
({ name }) => name,
),
...MANAGED_WORKFLOW_TOOL_CONTRACTS.map(({ name }) => name),
]);
const advertisedTools = await sdkToolCatalog();
const ajv = new Ajv2020({ strict: false, validateFormats: false });
for (const tool of advertisedTools.values()) {
for (const kind of ["inputSchema", "outputSchema"]) {
const schema = tool[kind];
if (schema !== undefined && !ajv.validateSchema(schema))
throw new Error(
`Invalid JSON Schema for ${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`,
);
}
}
const catalog = TOOL_CONTRACTS.map((contract) => {
const advertised = advertisedTools.get(contract.name);
if (advertised === undefined)
throw new Error(`MCP SDK omitted registered tool ${contract.name}`);
return {
name: contract.name,
analysisOperation: [
"official-proxy",
"enhanced",
"native-provider",
"artifact-provider",
"managed-provider",
].includes(contract.kind)
? contract.name
: null,
title: advertised.title,
description: advertised.description,
kind: contract.kind,
requiresSession: sessionToolNames.has(contract.name),
inputSchema: advertised.inputSchema,
outputSchema: advertised.outputSchema,
annotations: advertised.annotations,
effects: contract.effects,
};
});
async function sdkToolCatalog() {
const server = new McpServer({ name: "rea-catalog-generator", version: "0" });
for (const contract of TOOL_CONTRACTS)
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [{ type: "text", text: "Catalog-only handler" }],
isError: true,
}),
);
const client = new Client({ name: "rea-catalog-generator", version: "0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const { tools } = await client.listTools();
return new Map(tools.map((tool) => [tool.name, tool]));
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
}
const canonicalizeJson = (value) => {
if (Array.isArray(value)) return value.map(canonicalizeJson);
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, entry]) => [key, canonicalizeJson(entry)]),
);
};
const payloadJson = JSON.stringify(canonicalizeJson({ catalog }), null, 2);
const outputPath = join(root, ".cache/mcp-tool-catalog.json");
if (!arguments_.has("--check"))
await mkdir(dirname(outputPath), { recursive: true });
await ensureGeneratedFile({
path: outputPath,
source: `${payloadJson}\n`,
check: arguments_.has("--check"),
generateCommand: "npm run mcp-catalog:generate",
});