Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -104,8 +115,8 @@ mcp-openapi generate --spec <openapi-file> [--out-dir ./generated]

| Flag | Default | Purpose |
|---|---|---|
| `--spec <file>` | required | OpenAPI 3.x file, YAML or JSON |
| `--server-url <url>` | spec `servers[0]` | Override upstream base URL |
| `--spec [name=]<file>` | required, repeatable | OpenAPI 3.x file, YAML or JSON; multiple specs prefix tool names |
| `--server-url <url>` | spec `servers[0]` | Override upstream base URL (single spec only) |
| `--transport <t>` | `stdio` | `stdio`, `streamable-http`, or `sse` |
| `--port <n>` | `3000` | Web transport port |
| `--host <addr>` | `127.0.0.1` | Web transport bind address |
Expand Down
2 changes: 1 addition & 1 deletion src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down
134 changes: 98 additions & 36 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -119,22 +125,28 @@ async function main(): Promise<void> {
}

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;
}

Expand All @@ -149,7 +161,7 @@ async function main(): Promise<void> {
const mcpServer = createMcpServer(state, cli);

if (cli.watchSpec) {
wireSpecWatcher(specPath, cli, state, async () => {
wireSpecWatcher(cli.specs, cli, state, async () => {
await mcpServer.sendToolListChanged();
});
}
Expand All @@ -159,7 +171,7 @@ async function main(): Promise<void> {
return;
}

await startWebServer(state, cli, specPath);
await startWebServer(state, cli);
}

function createMcpServer(state: RuntimeState, cli: CliOptions): Server {
Expand Down Expand Up @@ -355,9 +367,9 @@ function validateResponseByStatus(
return ok ? undefined : validator.errors;
}

async function startWebServer(state: RuntimeState, cli: CliOptions, specPath: string): Promise<void> {
async function startWebServer(state: RuntimeState, cli: CliOptions): Promise<void> {
if (cli.transport === "sse") {
await startSseServer(state, cli, specPath);
await startSseServer(state, cli);
return;
}

Expand Down Expand Up @@ -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.
});
}
Expand All @@ -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<void> {
async function startSseServer(state: RuntimeState, cli: CliOptions): Promise<void> {
const transports = new Map<string, { transport: SSEServerTransport; createdAt: number }>();

const server = createHttpServer(async (req, res) => {
Expand Down Expand Up @@ -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);
Expand All @@ -533,38 +545,74 @@ async function startSseServer(state: RuntimeState, cli: CliOptions, specPath: st
await new Promise(() => undefined);
}

function wireSpecWatcher(specPath: string, cli: CliOptions, state: RuntimeState, onReload: () => Promise<void>): void {
function wireSpecWatcher(specs: SpecRef[], cli: CliOptions, state: RuntimeState, onReload: () => Promise<void>): void {
let debounce: ReturnType<typeof setTimeout> | 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();
} catch (error) {
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<RuntimeState> {
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<RuntimeState> {
const sep = compile.toolNameSeparator ?? "_";
const merged = new Map<string, OperationModel>();

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<string, OperationModel>): CompiledValidators {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1011,7 +1059,7 @@ function parseArgs(argv: string[]): CliOptions {
return {
command,
initDir,
specPath,
specs,
serverUrl,
cachePath,
compile: { strict, toolNameTemplate, descriptionFile: descriptionsFile, toolNameSeparator },
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 <path-to-openapi-file>");
}

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 },
Expand Down Expand Up @@ -1270,7 +1331,8 @@ function printHelp(): void {
" mcp-openapi --spec <openapi-file> [options]",
"",
"Options:",
" --server-url <url>",
" --spec [name=]<file> repeatable; with multiple specs each tool is prefixed with the spec name",
" --server-url <url> single --spec only",
" --cache-path <file>",
" --out-dir <dir>",
" --strict",
Expand Down
Loading
Loading