MCP Integration
Expose oRPC procedures as Model Context Protocol tools, reusing their OpenAPI metadata, schemas, middleware, and error handling.
Installation
npm install @orpc/mcp@beta @modelcontextprotocol/serverpnpm add @orpc/mcp@beta @modelcontextprotocol/serveryarn add @orpc/mcp@beta @modelcontextprotocol/serverbun add @orpc/mcp@beta @modelcontextprotocol/servernub add @orpc/mcp@beta @modelcontextprotocol/serveraube add @orpc/mcp@beta @modelcontextprotocol/serverCreating a Registrar
McpRegistrar registers every procedure in a router as an MCP tool. Tool calls run through the same standard handler as the RPC and OpenAPI handlers.
import { McpServer } from '@modelcontextprotocol/server'
import { McpRegistrar } from '@orpc/mcp'
const registrar = new McpRegistrar(router)
const server = new McpServer({ name: 'planets', version: '1.0.0' })
await registrar.register(server, {
context: {}, // provide initial context if needed
})
Connect each server to a transport as described in the MCP TypeScript SDK. register can be called on any number of servers, such as one per request.
Tool Definitions
Tool definitions derive from OpenAPI metadata:
| Tool field | Derived from |
|---|---|
name |
operationId, otherwise the router path joined by _, such as planet_list |
title |
summary |
description |
description, otherwise summary |
annotations |
method: GET, HEAD, and QUERY imply readOnlyHint, PUT and DELETE imply idempotentHint |
import { openapi } from '@orpc/openapi'
const listPlanets = os
.meta(openapi({
method: 'GET',
operationId: 'listPlanets',
summary: 'List planets',
description: 'List planets, newest first, optionally limited to a number of results.',
}))
.input(z.object({ limit: z.number().int().min(1).max(100).optional() }))
.output(z.array(PlanetSchema))
.handler(async ({ input }) => {
// ...
})
Tool Meta
Use mcp.tool metadata to override derived fields or set MCP-only ones:
import { mcp } from '@orpc/mcp'
const deletePlanet = os
.meta(mcp.tool({
name: 'delete_planet',
title: 'Delete a planet',
description: 'Permanently delete a planet. Ask the user to confirm first.',
annotations: { destructiveHint: true, openWorldHint: false },
icons: [{ src: 'https://example.com/planet.png', mimeType: 'image/png' }],
}))
.input(z.object({ id: z.number() }))
.handler(async ({ input }) => {
// ...
})
Filtering Procedures
Use filter to skip procedures:
import { getOpenAPIMeta } from '@orpc/openapi'
const registrar = new McpRegistrar(router, {
filter: (procedure, path) => !path.includes('internal') && !getOpenAPIMeta(procedure)?.deprecated,
})
Input and Output
JSON Schema Converters
Tools advertise JSON Schemas converted from your input and output schemas. Standard JSON Schema schemas, such as Zod v4.2+, need no converter; add one from the Zod, Valibot, or ArkType integrations for more types, like the OpenAPI Generator:
import { ZodToJsonSchemaConverter } from '@orpc/zod'
const registrar = new McpRegistrar(router, {
converters: [new ZodToJsonSchemaConverter()],
})
Input
JSON cannot carry types such as Date or BigInt, so add the Smart Coercion Plugin with the same converters to coerce arguments before validation:
import { SmartCoercionHandlerPlugin } from '@orpc/json-schema'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
const converters = [new ZodToJsonSchemaConverter()]
const registrar = new McpRegistrar(router, {
converters,
plugins: [new SmartCoercionHandlerPlugin({ converters })],
})
Tool arguments are always an object, so a non-object input, such as z.string(), is nested under an input argument.
Output
Outputs are serialized like OpenAPI responses. Pass your OpenAPI handler’s serializer as serializer to share custom types.
| Output | Tool result |
|---|---|
undefined |
Empty content |
| String | Text content with the string, and structured content |
| Other values | Text content with the JSON, and structured content |
Blob or File |
Image or audio content by MIME type, otherwise embedded resource content |
Other values containing a Blob or File |
Like other values, with each file also returned as embedded resource content named by its path, such as file:///report/q3.pdf |
ReadableStream |
Like a Blob, typed by the content-type response header, such as one set with the Response Headers Plugin |
| AsyncIteratorObject | The last event, mapped as above, with every event reported as progress when requested |
| Error | Tool execution error |
A tool advertises an output schema only when every result has structured content matching it, so not for optional outputs or outputs containing a Blob or File. For an AsyncIteratorObject, its yield schema is advertised, so it must yield at least once.
Error Handling
Errors are returned as tool execution errors the model can read, with the ORPCError JSON as text:
{
"defined": true,
"code": "NOT_FOUND",
"message": "Planet not found",
"data": { "id": 42 }
}
Other errors become INTERNAL_SERVER_ERROR without their message. Use interceptors to log them.
Context
register takes the initial context for tool calls on that server, as a value or a function of the MCP request context, called on every tool call:
await registrar.register(server, {
context: async ctx => ({ user: await findUser(ctx) }),
})
Plugins and Interceptors
McpRegistrar accepts the same interceptors and plugins as the RPC and OpenAPI handlers:
import { SmartCoercionHandlerPlugin } from '@orpc/json-schema'
import { onError } from '@orpc/server'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
const converters = [new ZodToJsonSchemaConverter()]
const registrar = new McpRegistrar(router, {
converters,
plugins: [new SmartCoercionHandlerPlugin({ converters })],
clientInterceptors: [
onError((error) => {
console.error(error)
}),
],
})
Interceptors see each tool call as a POST to the procedure path, with the arguments as body and only the trace context and baggage as headers. Plugins working on the procedure call, such as Smart Coercion, Timeout, and logging, apply; HTTP plugins, such as CORS or compression, do nothing, and Rethrow is not supported.