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()))); + } +});