Guide · Developer Experience
OpenAPI to MCP — convert REST API specs to MCP tools
If you already have a REST API with an OpenAPI 3.x spec, you have everything you need to build an MCP server — without writing tool definitions by hand. Every operationId becomes an MCP tool name. Every path parameter, query parameter, and request body field becomes an inputSchema property. Every response schema becomes the structure your tool returns. The question is whether to generate this mapping once and maintain it by hand, or to parse the spec at runtime and generate tools dynamically — and how to handle authentication, pagination, and error responses along the way.
TL;DR
Parse the OpenAPI spec with swagger-parser or @readme/openapi-parser. For each operation, build an MCP tool with name: operationId, description from the operation summary, and inputSchema derived from path/query parameters plus the request body schema. In the tool handler, reconstruct the HTTP request from validated arguments and return the response body as MCP content. For typed, maintainable servers, code-generate the tool list from the spec at build time using a custom script — runtime parsing adds startup latency and makes it harder to add tool-specific logic.
Why bridge OpenAPI to MCP?
REST APIs and MCP tools solve different problems — REST is a resource-oriented HTTP convention, MCP is an LLM-native RPC protocol — but they have enough structural overlap that a mapping is natural. An OpenAPI spec already describes what each operation does, what inputs it accepts (with types, formats, and descriptions), and what it returns. That is exactly what an MCP inputSchema and tool description need.
| OpenAPI concept | MCP equivalent | Notes |
|---|---|---|
operationId | Tool name | Must be unique; snake_case preferred for LLM legibility |
Operation summary | Tool description | Write summaries as LLM instructions, not developer docs |
| Path + query parameters | inputSchema.properties | Merge both into a flat object; mark path params required |
| Request body schema | inputSchema.properties | Inline the body properties into the flat input object |
| Response body schema | Content of tool response | Return as JSON string; MCP has no typed output schema |
| Security scheme | Environment variable in server | Never expose credentials in inputSchema |
The main divergence: MCP tools have a flat input — one inputSchema object with all parameters at the top level — while HTTP operations split inputs across URL path, query string, headers, and body. The adapter's job is to flatten these into one MCP input schema and reconstruct the HTTP request from the validated arguments.
Manual mapping — small APIs (under 10 endpoints)
For small APIs, write the MCP server by hand, one tool per endpoint. Read the OpenAPI spec as documentation and translate it directly. This is the most maintainable approach when the API rarely changes and you need to add tool-specific logic (pagination cursors, response post-processing, multi-step composition).
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
// From: GET /users/{userId}
// OpenAPI operationId: get_user
const GetUserSchema = z.object({
userId: z.string().uuid().describe('UUID of the user to retrieve'),
});
// From: POST /users/search
// OpenAPI operationId: search_users
const SearchUsersSchema = z.object({
query: z.string().min(1).describe('Search term — name or email'),
page: z.number().int().positive().default(1).describe('Page number starting at 1'),
pageSize: z.number().int().min(1).max(100).default(20),
status: z.enum(['active', 'inactive', 'all']).default('active').describe('Filter by user status'),
});
const API_BASE = process.env.API_BASE_URL ?? 'https://api.example.com';
const API_KEY = process.env.API_KEY ?? '';
async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', ...init?.headers },
});
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
return res.json();
}
const server = new Server(
{ name: 'example-api-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{ name: 'get_user', description: 'Retrieve a user by UUID.', inputSchema: zodToJsonSchema(GetUserSchema) as Record<string, unknown> },
{ name: 'search_users', description: 'Search users by name or email with filters.', inputSchema: zodToJsonSchema(SearchUsersSchema) as Record<string, unknown> },
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'get_user') {
const r = GetUserSchema.safeParse(request.params.arguments);
if (!r.success) return { content: [{ type: 'text', text: r.error.message }], isError: true };
const data = await apiFetch(`/users/${r.data.userId}`);
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
}
if (request.params.name === 'search_users') {
const r = SearchUsersSchema.safeParse(request.params.arguments);
if (!r.success) return { content: [{ type: 'text', text: r.error.message }], isError: true };
const { query, page, pageSize, status } = r.data;
const data = await apiFetch('/users/search', {
method: 'POST',
body: JSON.stringify({ query, page, pageSize, status }),
});
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
Code-generated mapping — medium APIs (10–50 endpoints)
For APIs with 10–50 endpoints, write a build-time code generator that reads the OpenAPI spec and emits a TypeScript file with the MCP tool list. Run it with npm run generate whenever the spec changes. The generated file is committed and reviewed in PRs, so tool regressions are visible before deployment.
// scripts/generate-tools.ts
import SwaggerParser from '@apidevtools/swagger-parser';
import { writeFileSync } from 'fs';
import type { OpenAPI, OpenAPIV3 } from 'openapi-types';
async function main() {
const spec = (await SwaggerParser.validate('openapi.yaml')) as OpenAPIV3.Document;
const tools: string[] = [];
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
for (const method of ['get', 'post', 'put', 'patch', 'delete'] as const) {
const op = (pathItem as Record<string, OpenAPIV3.OperationObject>)[method];
if (!op || !op.operationId) continue;
const params = (op.parameters as OpenAPIV3.ParameterObject[] ?? [])
.filter(p => p.in === 'path' || p.in === 'query');
const required: string[] = params.filter(p => p.required).map(p => p.name);
const properties: string[] = params.map(p => {
const schema = (p.schema as OpenAPIV3.SchemaObject) ?? { type: 'string' };
return ` ${p.name}: { type: '${schema.type ?? 'string'}', description: ${JSON.stringify(p.description ?? '')} }`;
});
// Inline request body properties
const body = op.requestBody as OpenAPIV3.RequestBodyObject | undefined;
if (body?.content?.['application/json']?.schema) {
const bodySchema = body.content['application/json'].schema as OpenAPIV3.SchemaObject;
for (const [name, prop] of Object.entries(bodySchema.properties ?? {})) {
const p = prop as OpenAPIV3.SchemaObject;
properties.push(` ${name}: { type: '${p.type ?? 'string'}', description: ${JSON.stringify(p.description ?? '')} }`);
if (bodySchema.required?.includes(name)) required.push(name);
}
}
tools.push(`{
name: '${op.operationId}',
description: ${JSON.stringify(op.summary ?? op.description ?? op.operationId)},
inputSchema: {
type: 'object',
properties: {
${properties.join(',\n')}
},
required: ${JSON.stringify(required)},
},
_meta: { method: '${method.toUpperCase()}', path: '${path}' },
}`);
}
}
writeFileSync('src/generated-tools.ts',
`// AUTO-GENERATED — run npm run generate to update\n` +
`export const GENERATED_TOOLS = [\n${tools.join(',\n')}\n] as const;\n`
);
console.log(`Generated ${tools.length} tools.`);
}
main().catch(console.error);
In the MCP server, import GENERATED_TOOLS and dispatch HTTP calls using _meta.method and _meta.path with path-parameter interpolation. Add tool-specific post-processing (pagination unwrapping, field renaming) in a separate handler map keyed by operationId.
Runtime dynamic mapping — large APIs (50+ endpoints)
For APIs with many endpoints or specs that change frequently (e.g., a microservice mesh), parse the OpenAPI spec at server startup and register tools dynamically. The tradeoff is startup latency (typically 100–500ms for a 500-operation spec) and the inability to add per-tool logic at compile time.
import SwaggerParser from '@apidevtools/swagger-parser';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js';
import type { OpenAPIV3 } from 'openapi-types';
type ToolMeta = { method: string; path: string };
async function buildServer(specPath: string) {
const spec = (await SwaggerParser.validate(specPath)) as OpenAPIV3.Document;
const tools: Tool[] = [];
const meta = new Map<string, ToolMeta>();
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
for (const method of ['get','post','put','patch','delete'] as const) {
const op = (pathItem as Record<string, OpenAPIV3.OperationObject>)[method];
if (!op?.operationId) continue;
const allParams = (op.parameters as OpenAPIV3.ParameterObject[] ?? [])
.filter(p => ['path','query'].includes(p.in));
const properties: Record<string, object> = {};
const required: string[] = [];
for (const p of allParams) {
const s = (p.schema as OpenAPIV3.SchemaObject) ?? {};
properties[p.name] = { type: s.type ?? 'string', description: p.description ?? '' };
if (p.required) required.push(p.name);
}
const body = op.requestBody as OpenAPIV3.RequestBodyObject | undefined;
const bodySchema = body?.content?.['application/json']?.schema as OpenAPIV3.SchemaObject | undefined;
if (bodySchema?.properties) {
for (const [name, prop] of Object.entries(bodySchema.properties)) {
const p = prop as OpenAPIV3.SchemaObject;
properties[name] = { type: p.type ?? 'string', description: p.description ?? '' };
if (bodySchema.required?.includes(name)) required.push(name);
}
}
tools.push({
name: op.operationId,
description: op.summary ?? op.description ?? op.operationId,
inputSchema: { type: 'object', properties, required },
});
meta.set(op.operationId, { method: method.toUpperCase(), path });
}
}
const server = new Server({ name: 'dynamic-api-mcp', version: '1.0.0' }, { capabilities: { tools: {} } });
const base = process.env.API_BASE_URL!;
const key = process.env.API_KEY!;
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolMeta = meta.get(request.params.name);
if (!toolMeta) throw new Error(`Unknown tool: ${request.params.name}`);
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
let urlPath = toolMeta.path.replace(/\{(\w+)\}/g, (_, k) => {
const v = args[k]; delete args[k]; return encodeURIComponent(String(v ?? ''));
});
const isBody = ['POST','PUT','PATCH'].includes(toolMeta.method);
const url = `${base}${urlPath}${!isBody ? '?' + new URLSearchParams(args as Record<string,string>) : ''}`;
const res = await fetch(url, {
method: toolMeta.method,
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
...(isBody ? { body: JSON.stringify(args) } : {}),
});
const text = await res.text();
if (!res.ok) return { content: [{ type: 'text', text: `HTTP ${res.status}: ${text}` }], isError: true };
return { content: [{ type: 'text', text }] };
});
return server;
}
buildServer(process.env.OPENAPI_SPEC_PATH ?? 'openapi.yaml')
.then(s => s.connect(new StdioServerTransport()))
.catch(console.error);
Authentication patterns
Never surface API credentials in the MCP inputSchema. LLMs will happily echo credentials back in tool descriptions or pass them between tool calls — treat them as server-side secrets only.
| Auth type | Pattern | Env var |
|---|---|---|
| Bearer token (JWT / PAT) | Authorization: Bearer ${process.env.API_TOKEN} | API_TOKEN |
| API key (header) | X-Api-Key: ${process.env.API_KEY} | API_KEY |
| API key (query string) | Append ?apiKey=${...} server-side, strip from inputSchema | API_KEY |
| OAuth 2.0 client credentials | Token exchange at startup; refresh in background | CLIENT_ID, CLIENT_SECRET, TOKEN_URL |
| Basic auth | Authorization: Basic ${Buffer.from(`${u}:${p}`).toString('base64')} | API_USER, API_PASS |
For OAuth flows, fetch the access token at startup and cache it with its expiry. Use a middleware function around your apiFetch helper that refreshes the token transparently when it expires rather than wiring token management into each tool handler.
Writing LLM-friendly tool descriptions
OpenAPI summaries are written for developers reading reference docs. MCP tool descriptions are instructions for an LLM deciding which tool to call and how to populate its arguments. Rewrite summaries before generating tools.
| OpenAPI summary | Better MCP description |
|---|---|
| "Get user" | "Retrieve a single user record by their UUID. Use when you have a specific userId and need the full user object including email, role, and created date." |
| "Search users" | "Search the user directory by name or email. Returns a paginated list. Use for finding users before calling get_user or update_user." |
| "Create order" | "Place a new order for a customer. Requires a valid customerId and at least one item. Returns the orderId for tracking with get_order." |
Add a post-processing step in your generator that reads a tool-descriptions.yaml file with overrides, falling back to the spec summary when no override exists. This keeps human-authored LLM instructions separate from the OpenAPI spec, which often changes independently.
Pagination and result size
REST APIs return paginated responses; LLMs prefer a single, complete answer. Decide per tool whether to:
- Expose pagination directly: include
pageandpageSizeininputSchema; return one page with anextPagehint in the response. The LLM calls the tool again with the next page if needed. Best for large datasets or when the LLM needs to scan-and-filter. - Auto-paginate (use with caution): the tool fetches all pages and returns a concatenated result. Cap at a sensible limit (e.g., 500 items) to avoid huge responses. Only appropriate when the full result set is reliably small and the latency is acceptable.
- Limit and truncate: fetch one page (e.g., top 20 results) and note the total count in the response. LLMs can ask for more specific queries rather than requesting all pages. Simplest to implement and least error-prone.
Include the total count and a note about truncation in the JSON response so the LLM can tell the user when results are incomplete: { "total": 1847, "returned": 20, "note": "Showing top 20 — use query to narrow results." }
Keeping spec and server in sync
The most common failure mode for OpenAPI-to-MCP bridges is the spec advancing (new endpoints, renamed parameters, changed response shapes) while the MCP server lags behind. Guard against this at three points:
- CI check: run
npm run generatein CI and fail if the generated file differs from the committed version. This catches spec changes that weren't propagated to the MCP server. - Schema drift test: write a test that
ListToolsthe MCP server and validates each tool'sinputSchemaagainst the corresponding OpenAPI parameter list. Fail on any mismatch. - AliveMCP monitoring: once the MCP server is deployed, AliveMCP pings the endpoint every 60 seconds. When a breaking API change renders the server unable to complete the
initializehandshake or return a validListToolsresponse, the alert fires before any LLM client encounters the failure.
Related questions
Can I use openapi-mcp-generator or similar npm packages?
Yes. Packages like openapi-to-mcp, mcp-openapi-proxy, and vendor SDKs (e.g., Cloudflare Workers AI has a hosted OpenAPI-to-MCP bridge) handle the parsing and tool generation automatically. The tradeoff vs. a custom script is flexibility: auto-generated servers don't support tool-specific post-processing, custom descriptions, or selective exposure of endpoints. Use a library for quick prototyping or internal tools; use a custom generator when you need control over which endpoints are exposed and how responses are formatted for the LLM.
Should I expose every OpenAPI endpoint as an MCP tool?
No. Expose the subset of operations that are useful in an LLM conversation. Omit: administrative endpoints (account deletion, billing), write operations that require human confirmation (unless your MCP client shows approval prompts), endpoints that return binary data (images, PDFs) since MCP's content model handles these differently, and internal health-check or metrics endpoints. A tool list of 10–20 focused operations is more useful to an LLM than a 200-tool dump of every endpoint.
How do I handle OpenAPI schemas with $ref references?
SwaggerParser.validate() (or SwaggerParser.dereference()) resolves all $ref pointers inline before you iterate the spec. After dereferencing, every schema object is self-contained — no recursive reference resolution needed. Use validate() rather than parse() to get a spec that has already been validated and dereferenced in one step.
What about GraphQL or gRPC APIs?
The same pattern applies: parse the schema (GraphQL SDL or protobuf), generate one MCP tool per query/mutation or RPC method, derive inputSchema from the input type, and execute the operation in the tool handler. There are community packages for GraphQL-to-MCP (graphql-mcp) and gRPC-to-MCP that follow this pattern. The core mapping — schema type → JSON Schema, operation → MCP tool — is the same regardless of the underlying protocol.
Further reading
- MCP server local development — full local stack setup for OpenAPI bridges
- MCP server TypeScript — project setup and type-checking
- MCP server Zod validation — validate tool inputs with typed schemas
- MCP server error handling — HTTP errors to MCP isError responses
- MCP server authentication — OAuth and API key patterns
- MCP server hot reload — iterate quickly while developing API bridges
- MCP server unit testing — test tool handlers with InMemoryTransport
- AliveMCP — uptime monitoring for deployed MCP endpoints