From 688664ee1387a6dd14f254105ccdaf9667079369 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Tue, 4 Aug 2026 02:05:52 -0700 Subject: [PATCH] Add multi-spec serving: repeatable --spec with deterministic name prefixes --spec now accepts [name=]path and can be given multiple times. With one spec, behavior is unchanged (bare operationIds). With several, each tool is prefixed with the spec's explicit name or file basename, so names are deterministic and allow/deny patterns keep working; residual collisions get a numeric suffix. Compile caches are per spec (cachePath.name), --watch-spec watches every file, --server-url and generate require a single spec. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqBsGC7xLvihtBxhCdWKz5 --- README.md | 15 ++++- src/compiler.ts | 2 +- src/server.ts | 134 +++++++++++++++++++++++++++++----------- test/multi-spec.test.ts | 112 +++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 39 deletions(-) create mode 100644 test/multi-spec.test.ts diff --git a/README.md b/README.md index 609df9e..64c0cbb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,17 @@ npx -y github:evalops/mcp-openapi --spec ./openapi.yaml --transport streamable-h # MCP endpoint: http://127.0.0.1:3000/mcp ``` +## Multiple specs + +`--spec` is repeatable. With more than one spec, every tool name is prefixed with the spec's name — given explicitly as `--spec name=path` or derived from the file's basename — so names stay deterministic and `--allow-tools`/`--deny-tools` patterns keep working. Remaining collisions get a numeric suffix. With a single spec, tool names are the bare operationIds, unchanged. + +```bash +mcp-openapi --spec github=./github.yaml --spec linear=./linear.yaml +# tools: github_listIssues, linear_createIssue, ... +``` + +`--server-url` is only valid with a single spec; with multiple specs each upstream URL comes from that spec's `servers[]`. + ## How operations map to tools - One MCP tool per OpenAPI operation. Tool name defaults to `operationId`; missing IDs fall back to `method_path`. Collisions get a numeric suffix. @@ -104,8 +115,8 @@ mcp-openapi generate --spec [--out-dir ./generated] | Flag | Default | Purpose | |---|---|---| -| `--spec ` | required | OpenAPI 3.x file, YAML or JSON | -| `--server-url ` | spec `servers[0]` | Override upstream base URL | +| `--spec [name=]` | required, repeatable | OpenAPI 3.x file, YAML or JSON; multiple specs prefix tool names | +| `--server-url ` | spec `servers[0]` | Override upstream base URL (single spec only) | | `--transport ` | `stdio` | `stdio`, `streamable-http`, or `sse` | | `--port ` | `3000` | Web transport port | | `--host ` | `127.0.0.1` | Web transport bind address | diff --git a/src/compiler.ts b/src/compiler.ts index 78910a7..91fa4af 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -290,7 +290,7 @@ function getOperationId( return id; } -function normalizeToolName(name: string, separator: string = "_"): string { +export function normalizeToolName(name: string, separator: string = "_"): string { const allowedSep = separator === "." ? "." : "_"; const pattern = allowedSep === "." ? /[^a-zA-Z0-9_.-]+/g : /[^a-zA-Z0-9_-]+/g; const cleaned = name.replace(pattern, "_").replace(/^[_.-]+|[_.-]+$/g, ""); diff --git a/src/server.ts b/src/server.ts index 8962ef1..361855e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -37,6 +37,7 @@ import type { CompileOptions, OperationModel, RuntimeOptions } from "./types.js" import { zodFromJsonSchema } from "./zod-schema.js"; import { compileDocumentWithCache } from "./compile-cache.js"; import { isToolAllowed } from "./policy.js"; +import { normalizeToolName } from "./compiler.js"; import { lintOpenApiDocument } from "./lint.js"; import { loadOpenApiDocument } from "./openapi.js"; import yaml from "js-yaml"; @@ -59,11 +60,16 @@ interface RuntimeState { validators: CompiledValidators; } +interface SpecRef { + name?: string; + path: string; +} + interface CliOptions { command: "run" | "init" | "generate"; initDir?: string; generateDir?: string; - specPath: string; + specs: SpecRef[]; serverUrl?: string; cachePath: string; compile: CompileOptions; @@ -119,22 +125,28 @@ async function main(): Promise { } if (cli.command === "generate") { - const specPath = resolve(cli.specPath); - const state = await loadRuntimeState(specPath, cli.serverUrl, cli.cachePath, cli.compile); + if (cli.specs.length !== 1) { + throw new Error("generate requires exactly one --spec"); + } + const specPath = resolve(cli.specs[0].path); + const state = await loadRuntimeState(cli.specs, cli.serverUrl, cli.cachePath, cli.compile); await generateProjectFromSpec(cli.generateDir ?? resolve(process.cwd(), "generated-mcp-server"), specPath, state.operations, cli); process.stderr.write(`Generated project in ${resolve(cli.generateDir ?? resolve(process.cwd(), "generated-mcp-server"))}\n`); return; } - const specPath = resolve(cli.specPath); - const state: RuntimeState = await loadRuntimeState(specPath, cli.serverUrl, cli.cachePath, cli.compile); + const state: RuntimeState = await loadRuntimeState(cli.specs, cli.serverUrl, cli.cachePath, cli.compile); if (cli.runtime.responseTransformModule) { responseTransform = await loadResponseTransform(cli.runtime.responseTransformModule); } if (cli.validateSpec) { - process.stdout.write(`Spec valid. Compiled ${state.operations.size} tools.\n`); + process.stdout.write( + cli.specs.length === 1 + ? `Spec valid. Compiled ${state.operations.size} tools.\n` + : `Specs valid. Compiled ${state.operations.size} tools from ${cli.specs.length} specs.\n` + ); return; } @@ -149,7 +161,7 @@ async function main(): Promise { const mcpServer = createMcpServer(state, cli); if (cli.watchSpec) { - wireSpecWatcher(specPath, cli, state, async () => { + wireSpecWatcher(cli.specs, cli, state, async () => { await mcpServer.sendToolListChanged(); }); } @@ -159,7 +171,7 @@ async function main(): Promise { return; } - await startWebServer(state, cli, specPath); + await startWebServer(state, cli); } function createMcpServer(state: RuntimeState, cli: CliOptions): Server { @@ -355,9 +367,9 @@ function validateResponseByStatus( return ok ? undefined : validator.errors; } -async function startWebServer(state: RuntimeState, cli: CliOptions, specPath: string): Promise { +async function startWebServer(state: RuntimeState, cli: CliOptions): Promise { if (cli.transport === "sse") { - await startSseServer(state, cli, specPath); + await startSseServer(state, cli); return; } @@ -392,7 +404,7 @@ async function startWebServer(state: RuntimeState, cli: CliOptions, specPath: st }); if (cli.watchSpec) { - wireSpecWatcher(specPath, cli, state, async () => { + wireSpecWatcher(cli.specs, cli, state, async () => { // Stateless web transports rebuild handlers per request, no in-session update required. }); } @@ -413,7 +425,7 @@ async function startWebServer(state: RuntimeState, cli: CliOptions, specPath: st await new Promise(() => undefined); } -async function startSseServer(state: RuntimeState, cli: CliOptions, specPath: string): Promise { +async function startSseServer(state: RuntimeState, cli: CliOptions): Promise { const transports = new Map(); const server = createHttpServer(async (req, res) => { @@ -513,7 +525,7 @@ async function startSseServer(state: RuntimeState, cli: CliOptions, specPath: st }); if (cli.watchSpec) { - wireSpecWatcher(specPath, cli, state, async () => {}); + wireSpecWatcher(cli.specs, cli, state, async () => {}); } server.listen(cli.port, cli.host); @@ -533,13 +545,13 @@ async function startSseServer(state: RuntimeState, cli: CliOptions, specPath: st await new Promise(() => undefined); } -function wireSpecWatcher(specPath: string, cli: CliOptions, state: RuntimeState, onReload: () => Promise): void { +function wireSpecWatcher(specs: SpecRef[], cli: CliOptions, state: RuntimeState, onReload: () => Promise): void { let debounce: ReturnType | undefined; - watch(specPath, () => { + const reload = () => { if (debounce) clearTimeout(debounce); debounce = setTimeout(async () => { try { - const next = await loadRuntimeState(specPath, cli.serverUrl, cli.cachePath, cli.compile); + const next = await loadRuntimeState(specs, cli.serverUrl, cli.cachePath, cli.compile); state.operations = next.operations; state.validators = next.validators; await onReload(); @@ -547,24 +559,60 @@ function wireSpecWatcher(specPath: string, cli: CliOptions, state: RuntimeState, process.stderr.write(`Spec reload failed: ${error instanceof Error ? error.message : String(error)}\n`); } }, 200); - }); + }; + for (const spec of specs) { + watch(resolve(spec.path), reload); + } } -async function loadRuntimeState(specPath: string, serverUrl: string | undefined, cachePath: string, compile: CompileOptions): Promise { - const doc = await loadOpenApiDocument(specPath); - const diagnostics = lintOpenApiDocument(doc, compile); - const errors = diagnostics.filter((d) => d.level === "error"); - if (errors.length > 0) { - throw new Error(`OpenAPI lint failed:\n${errors.map((d) => `- [${d.code}] ${d.message}${d.location ? ` (${d.location})` : ""}`).join("\n")}`); - } - const warnings = diagnostics.filter((d) => d.level === "warning"); - if (warnings.length > 0) { - process.stderr.write(`${warnings.map((d) => `Warning [${d.code}] ${d.message}${d.location ? ` (${d.location})` : ""}`).join("\n")}\n`); +// With a single spec, tool names are the bare operationIds (unchanged behavior). +// With multiple specs, every tool is prefixed with the spec's name (explicit +// `--spec name=path` or the file's basename) so names are deterministic and +// policy patterns keep working; remaining collisions get a numeric suffix. +function deriveSpecName(spec: SpecRef, separator?: string): string { + const raw = spec.name ?? basename(spec.path).replace(/\.(yaml|yml|json)$/i, ""); + return normalizeToolName(raw, separator); +} + +async function loadRuntimeState(specs: SpecRef[], serverUrl: string | undefined, cachePath: string, compile: CompileOptions): Promise { + const sep = compile.toolNameSeparator ?? "_"; + const merged = new Map(); + + for (const spec of specs) { + const specPath = resolve(spec.path); + const doc = await loadOpenApiDocument(specPath); + const diagnostics = lintOpenApiDocument(doc, compile); + const errors = diagnostics.filter((d) => d.level === "error"); + if (errors.length > 0) { + throw new Error( + `OpenAPI lint failed for ${spec.path}:\n${errors.map((d) => `- [${d.code}] ${d.message}${d.location ? ` (${d.location})` : ""}`).join("\n")}` + ); + } + const warnings = diagnostics.filter((d) => d.level === "warning"); + if (warnings.length > 0) { + process.stderr.write(`${warnings.map((d) => `Warning [${d.code}] ${d.message}${d.location ? ` (${d.location})` : ""}`).join("\n")}\n`); + } + + const specCachePath = specs.length === 1 ? cachePath : `${cachePath}.${deriveSpecName(spec, sep)}`; + const operations = await compileDocumentWithCache(doc, specPath, serverUrl, specCachePath, compile); + + for (const operation of operations.values()) { + const baseName = + specs.length === 1 + ? operation.operationId + : normalizeToolName(`${deriveSpecName(spec, sep)}${sep}${operation.operationId}`, sep); + let toolName = baseName; + let suffix = 1; + while (merged.has(toolName)) { + suffix += 1; + toolName = normalizeToolName(`${baseName}${sep}${suffix}`, sep); + } + merged.set(toolName, { ...operation, operationId: toolName }); + } } - const operations = await compileDocumentWithCache(doc, specPath, serverUrl, cachePath, compile); - const validators = buildValidators(operations); - return { operations, validators }; + const validators = buildValidators(merged); + return { operations: merged, validators }; } function buildValidators(operations: Map): CompiledValidators { @@ -975,7 +1023,7 @@ function parseArgs(argv: string[]): CliOptions { let command: CliOptions["command"] = "run"; let initDir: string | undefined; let generateDir: string | undefined; - let specPath = ""; + const specs: SpecRef[] = []; let serverUrl: string | undefined; let cachePath = ".cache/mcp-openapi-cache.json"; let strict = false; @@ -1011,7 +1059,7 @@ function parseArgs(argv: string[]): CliOptions { return { command, initDir, - specPath, + specs, serverUrl, cachePath, compile: { strict, toolNameTemplate, descriptionFile: descriptionsFile, toolNameSeparator }, @@ -1049,7 +1097,16 @@ function parseArgs(argv: string[]): CliOptions { for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--spec") { - specPath = argv[++i] ?? ""; + const value = argv[++i] ?? ""; + if (!value) { + throw new Error("--spec requires a value: [name=]path"); + } + const named = /^([A-Za-z0-9][A-Za-z0-9_-]*)=(.+)$/.exec(value); + if (named) { + specs.push({ name: named[1], path: named[2] }); + } else { + specs.push({ path: value }); + } continue; } if (arg === "--server-url") { @@ -1198,16 +1255,20 @@ function parseArgs(argv: string[]): CliOptions { throw new Error(`Unknown argument: ${arg}. Run with --help for usage.`); } - if (!specPath) { + if (specs.length === 0) { printHelp(); throw new Error("Missing required argument: --spec "); } + if (serverUrl && specs.length > 1) { + throw new Error("--server-url is only valid with a single --spec; per-spec URLs come from each spec's servers[]"); + } + return { command, initDir, generateDir, - specPath, + specs, serverUrl, cachePath, compile: { strict, toolNameTemplate, descriptionFile: descriptionsFile, toolNameSeparator }, @@ -1270,7 +1331,8 @@ function printHelp(): void { " mcp-openapi --spec [options]", "", "Options:", - " --server-url ", + " --spec [name=] repeatable; with multiple specs each tool is prefixed with the spec name", + " --server-url single --spec only", " --cache-path ", " --out-dir ", " --strict", diff --git a/test/multi-spec.test.ts b/test/multi-spec.test.ts new file mode 100644 index 0000000..9d63544 --- /dev/null +++ b/test/multi-spec.test.ts @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import yaml from "js-yaml"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; + +const tsxCli = resolve("node_modules/tsx/dist/cli.mjs"); +const sampleSpec = "test/fixtures/sample-openapi.yaml"; + +test("multiple named specs prefix tool names deterministically", () => { + const result = spawnSync( + process.execPath, + [tsxCli, "src/server.ts", "--spec", `alpha=${sampleSpec}`, "--spec", `beta=${sampleSpec}`, "--print-tools"], + { cwd: process.cwd(), encoding: "utf8" } + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^alpha_getHealth\t/m); + assert.match(result.stdout, /^beta_getHealth\t/m); + assert.doesNotMatch(result.stdout, /^getHealth\t/m); +}); + +test("unnamed duplicate specs fall back to basename prefix plus numeric suffix", () => { + const result = spawnSync( + process.execPath, + [tsxCli, "src/server.ts", "--spec", sampleSpec, "--spec", sampleSpec, "--print-tools"], + { cwd: process.cwd(), encoding: "utf8" } + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^sample-openapi_getHealth\t/m); + assert.match(result.stdout, /^sample-openapi_getHealth_2\t/m); +}); + +test("single spec keeps bare operationIds", () => { + const result = spawnSync( + process.execPath, + [tsxCli, "src/server.ts", "--spec", sampleSpec, "--print-tools"], + { cwd: process.cwd(), encoding: "utf8" } + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^getHealth\t/m); +}); + +test("--server-url is rejected with multiple specs", () => { + const result = spawnSync( + process.execPath, + [tsxCli, "src/server.ts", "--spec", `a=${sampleSpec}`, "--spec", `b=${sampleSpec}`, "--server-url", "http://127.0.0.1:9", "--print-tools"], + { cwd: process.cwd(), encoding: "utf8" } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /--server-url is only valid with a single --spec/); +}); + +test("multi-spec tools are callable end-to-end over stdio", async () => { + const apiServer = createServer((req, res) => { + res.setHeader("content-type", "application/json"); + if (req.url?.startsWith("/health")) { + res.end(JSON.stringify({ ok: true })); + return; + } + res.statusCode = 404; + res.end("{}"); + }); + await new Promise((resolveListen) => apiServer.listen(0, resolveListen)); + const address = apiServer.address(); + assert.ok(address && typeof address === "object"); + const apiBase = `http://127.0.0.1:${address.port}`; + + const dir = await mkdtemp(join(tmpdir(), "mcp-openapi-multi-")); + const doc = yaml.load(await readFile(sampleSpec, "utf8")) as Record; + doc.servers = [{ url: apiBase }]; + const specA = join(dir, "a.yaml"); + const specB = join(dir, "b.yaml"); + await writeFile(specA, yaml.dump(doc), "utf8"); + await writeFile(specB, yaml.dump(doc), "utf8"); + + const client = new Client({ name: "multi-spec-e2e", version: "0.1.0" }, { capabilities: {} }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["dist/server.js", "--spec", `alpha=${specA}`, "--spec", `beta=${specB}`], + cwd: process.cwd(), + stderr: "pipe" + }); + + try { + await client.connect(transport); + + const tools = await client.request({ method: "tools/list", params: {} }, ListToolsResultSchema); + const names = tools.tools.map((t) => t.name); + assert.ok(names.includes("alpha_getHealth"), `missing alpha_getHealth in ${names.join(",")}`); + assert.ok(names.includes("beta_getHealth"), `missing beta_getHealth in ${names.join(",")}`); + + const health = await client.request( + { method: "tools/call", params: { name: "beta_getHealth", arguments: {} } }, + CallToolResultSchema + ); + assert.equal(health.isError, false); + assert.equal((health.structuredContent as Record).ok, true); + } finally { + await transport.close(); + await new Promise((resolveClose, reject) => apiServer.close((err) => (err ? reject(err) : resolveClose()))); + } +});