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 conceptMCP equivalentNotes
operationIdTool nameMust be unique; snake_case preferred for LLM legibility
Operation summaryTool descriptionWrite summaries as LLM instructions, not developer docs
Path + query parametersinputSchema.propertiesMerge both into a flat object; mark path params required
Request body schemainputSchema.propertiesInline the body properties into the flat input object
Response body schemaContent of tool responseReturn as JSON string; MCP has no typed output schema
Security schemeEnvironment variable in serverNever 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 typePatternEnv 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 inputSchemaAPI_KEY
OAuth 2.0 client credentialsToken exchange at startup; refresh in backgroundCLIENT_ID, CLIENT_SECRET, TOKEN_URL
Basic authAuthorization: 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 summaryBetter 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:

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:

  1. CI check: run npm run generate in CI and fail if the generated file differs from the committed version. This catches spec changes that weren't propagated to the MCP server.
  2. Schema drift test: write a test that ListTools the MCP server and validates each tool's inputSchema against the corresponding OpenAPI parameter list. Fail on any mismatch.
  3. 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 initialize handshake or return a valid ListTools response, 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