MCP Server
Generate Model Context Protocol servers from OpenAPI
Generate Model Context Protocol (MCP) servers from your OpenAPI specification for AI agent integration.
Overview
MCP servers relay API clients to AI agents, eliminating the need to wait for third-party implementations. Create MCP servers for any service with an OpenAPI specification and use them with AI agents like Claude, Cline, and others.
Configuration
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
input: {
target: './petstore.yaml',
},
output: {
mode: 'single',
client: 'mcp',
baseUrl: 'https://petstore3.swagger.io/api/v3',
target: 'src/handlers.ts',
schemas: 'src/http-schemas',
},
},
});The mcp client currently only works in single mode.
Generated Structure
src/
├── http-schemas/
│ ├── createPetsBodyItem.ts
│ ├── error.ts
│ ├── index.ts
│ └── pet.ts
├── handlers.ts # Handler functions returning MCP format
├── http-client.ts # Generated fetch client
├── server.ts # MCP tools and server configuration
└── tool-schemas.zod.ts # Zod schemas for tool inputsUsage
1. Build Docker Image
docker build ./ -t mcp-petstore2. Configure AI Agent
For Cline:
{
"mcpServers": {
"petstore": {
"command": "docker",
"args": ["run", "-i", "--rm", "mcp-petstore"],
"disabled": false,
"alwaysAllow": []
}
}
}This allows your AI agent to interact with the API through the MCP protocol.
Request Cancellation
When an MCP client cancels a tool call, the generated server.ts passes the cancellation signal of that call to the underlying HTTP request. The in-flight fetch is aborted with an AbortError instead of running to completion.
tools.findPetsByStatus = server.registerTool(
'findPetsByStatus',
{
// ...
},
(args, ctx) =>
findPetsByStatusHandler(args, {
...options,
signal: options?.signal
? AbortSignal.any([options.signal, ctx.signal])
: ctx.signal,
}),
);If you pass a signal through createMcpServer(options), for example from a custom server to abort every in-flight request on shutdown, it is combined with the per-call signal using AbortSignal.any. Either signal aborts the request.
const shutdown = new AbortController();
const { server } = createMcpServer({ signal: shutdown.signal });
process.on('SIGTERM', () => shutdown.abort());AbortSignal.any requires Node.js 20.3 or later at runtime.
Custom Handler
By default, each generated handler returns the response body as text, marks HTTP status codes of 400 and above as errors, and returns the body as structuredContent while the tool declares a matching outputSchema. The body is first validated with the operation's response schema, which drops fields that are not in the spec, so structuredContent always satisfies the declared schema. When the body does not match the schema, the handler returns the raw body together with the validation error as an isError result, so the agent still sees what the API returned. MCP requires both to be objects, so a plain JSON object is passed as is and any other body (array, primitive, union) is wrapped as { result: body }. Empty responses get neither, and so do responses that cannot be expressed as JSON Schema: those containing allOf (or oneOf with sibling properties) anywhere, whose JSON Schema becomes an allOf of closed objects that no value satisfies, and those containing Date (with useDates) or binary bodies, which have no JSON Schema representation. To take over response shaping and error mapping, or to use the tool call context for logging, authorization, or elicitation, provide a custom handler via override.mcp.handler.
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
input: {
target: './petstore.yaml',
},
output: {
mode: 'single',
client: 'mcp',
baseUrl: 'https://petstore3.swagger.io/api/v3',
target: 'src/handlers.ts',
schemas: 'src/http-schemas',
override: {
mcp: {
handler: {
path: './custom-handler.ts',
name: 'customHandler',
},
},
},
},
},
});When set, every generated handler binds the tool arguments to a fetcher and delegates to your function together with the tool call context and the toStructuredContent function that server.ts builds for that operation, next to its outputSchema:
import { customHandler } from '../custom-handler';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type {
ServerNotification,
ServerRequest,
} from '@modelcontextprotocol/sdk/types.js';
export const findPetsByStatusHandler = async (
args: findPetsByStatusArgs,
options: RequestInit,
ctx: RequestHandlerExtra<ServerRequest, ServerNotification>,
toStructuredContent: (
data: unknown,
) =>
| { success: true; data: Record<string, unknown> | undefined }
| { success: false; error: { message: string } },
) => {
const fetcher = (overrides?: RequestInit) =>
findPetsByStatus(args.queryParams, {
...options,
...overrides,
headers: {
...Object.fromEntries(new Headers(options.headers)),
...Object.fromEntries(new Headers(overrides?.headers)),
},
});
return customHandler(fetcher, ctx, toStructuredContent);
};tools.findPetsByStatus = server.registerTool(
'findPetsByStatus',
{
inputSchema: { queryParams: FindPetsByStatusQueryParams },
outputSchema: FindPetsByStatusOutput,
annotations: { readOnlyHint: true },
},
(args, ctx) =>
findPetsByStatusHandler(
args,
{
...options,
signal: options?.signal
? AbortSignal.any([options.signal, ctx.signal])
: ctx.signal,
},
ctx,
(data: unknown) => FindPetsByStatusOutput.safeParse({ result: data }),
),
);Implement the handler with the following signature. Its return value is used as the tool result as is. Pass the body through toStructuredContent so that structuredContent matches the outputSchema the generated server declares for the tool.
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type {
CallToolResult,
ServerNotification,
ServerRequest,
} from '@modelcontextprotocol/sdk/types.js';
export const customHandler = async (
fetcher: (
overrides?: RequestInit,
) => Promise<{ status: number; data: unknown; headers: Headers }>,
ctx: RequestHandlerExtra<ServerRequest, ServerNotification>,
toStructuredContent: (
data: unknown,
) =>
| { success: true; data: Record<string, unknown> | undefined }
| { success: false; error: { message: string } },
): Promise<CallToolResult> => {
const res = await fetcher({
headers: ctx.sessionId ? { 'Mcp-Session-Id': ctx.sessionId } : undefined,
});
if (res.status >= 400) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
requestId: ctx.requestId,
status: res.status,
error: res.data ?? null,
}),
},
],
isError: true,
};
}
const text = JSON.stringify(res.data ?? null);
const result = toStructuredContent(res.data);
return result.success
? { content: [{ type: 'text', text }], structuredContent: result.data }
: {
content: [
{ type: 'text', text },
{ type: 'text', text: result.error.message },
],
isError: true,
};
};fetcher already carries the tool arguments and the RequestInit passed to createMcpServer, including the cancellation signal described above. Pass overrides to add headers or to replace the signal. Headers from overrides are merged into the base headers instead of replacing them, so headers such as Authorization set through createMcpServer(options) stay on the request.
ctx is the SDK's RequestHandlerExtra for the current tool call. It exposes signal, requestId, sessionId, authInfo, requestInfo, sendNotification, and sendRequest.
toStructuredContent validates the body with the operation's response schema and returns the safeParse result. On success, data is the body as is when the response is a plain object (with fields that are not in the spec dropped), { result: body } for any other response, and undefined when the tool declares no outputSchema. On failure, error.message describes the mismatch; return it together with the raw body so the agent still sees what the API returned.
Place the custom handler file outside the generated output directory. If clean: true is set, orval deletes the output directory on each run and will remove any file inside it.
Custom Server
By default, the generated server.ts connects via StdioServerTransport. To use a different transport (e.g., Streamable HTTP for container deployments), provide a custom server function via override.mcp.server.
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
input: {
target: './petstore.yaml',
},
output: {
mode: 'single',
client: 'mcp',
baseUrl: 'https://petstore3.swagger.io/api/v3',
target: 'src/handlers.ts',
schemas: 'src/http-schemas',
override: {
mcp: {
server: {
path: './custom-server.ts',
name: 'customServer',
},
},
},
},
},
});The generated server.ts calls your function with a createMcpServer factory:
import { customServer } from '../custom-server';
const createMcpServer = (
options?: RequestInit,
): { server: McpServer; tools: Record<string, RegisteredTool> } => {
// ...tool registrations
};
customServer(createMcpServer);Implement the custom server function to set up any transport. The following example uses Hono with @hono/mcp for Streamable HTTP:
import type {
McpServer,
RegisteredTool,
} from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPTransport } from '@hono/mcp';
import { Hono } from 'hono';
export const customServer = (
createMcpServer: (options?: RequestInit) => {
server: McpServer;
tools: Record<string, RegisteredTool>;
},
) => {
const app = new Hono();
const { server } = createMcpServer();
const transport = new StreamableHTTPTransport();
app.all('/mcp', async (c) => {
if (!server.isConnected()) {
await server.connect(transport);
}
return transport.handleRequest(c);
});
Bun.serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });
};Place the custom server file outside the generated output directory. If clean: true is set, orval deletes the output directory on each run and will remove any file inside it.
Full Example
See the MCP Petstore sample and MCP Custom Server sample on GitHub.