Skip to content
mi3lix9Public

About

Serve an oRPC router as an MCP server — tools, resources, and prompts over Streamable HTTP or stdio.

Topics

Resources

Stars

15 stars

Watchers

0 watching

Forks

Repository files navigation

orpc-mcp

Serve an oRPC router as an MCP server. The same procedures you already serve over RPC and OpenAPI become MCP tools, resources, and prompts — usable by clients like Claude, ChatGPT, and IDEs, with the same types, validation, and middleware.

Note

This is a community package, not part of oRPC core. It was proposed in middleapi/orpc#1604; the maintainer opted to keep MCP out of core for now and promote it as an ecosystem package. It serves MCP revisions 2024-10-07 through 2026-07-28 and oRPC v2 (currently in beta).

Installation

npm install orpc-mcp
# pnpm add orpc-mcp / yarn add orpc-mcp / bun add orpc-mcp

oRPC packages are peer dependencies — install them at 2.0.0-beta.16 or later:

npm install @orpc/server@beta @orpc/client@beta @orpc/contract@beta @orpc/json-schema@beta @orpc/shared@beta

You also need a JSON Schema converter for your validation library, e.g. @orpc/zod@beta or @orpc/valibot@beta.

Setup

Exposing a procedure to MCP is opt-in: annotate it with mcp.tool, mcp.resource, or mcp.prompt. MCP metadata is independent of any openapi meta, so a single procedure can be served over REST and MCP at the same time.

import { os } from '@orpc/server'
import { mcp } from 'orpc-mcp'
import * as z from 'zod'

export const createPlanet = os
  .meta(mcp.tool({ description: 'Create a new planet' }))
  .input(z.object({ name: z.string() }))
  .output(z.object({ id: z.string(), name: z.string() }))
  .handler(({ input }) => ({ id: crypto.randomUUID(), name: input.name }))

export const router = { createPlanet }

Then serve the router with one of the MCPHandler adapters.

Tools

Tools are functions the model can call. A procedure's .input() becomes the tool's JSON Schema, its return value becomes the result, and its .output() adds an output schema plus structured content. Thrown typed errors are reported back to the model as in-band tool errors, so it can react to them.

export const createPlanet = os
  .meta(mcp.tool({
    description: 'Create a new planet',
    annotations: { destructiveHint: false },
  }))
  .input(CreatingPlanetSchema)
  .output(PlanetSchema)
  .handler(({ input }) => create(input))

Behavior hints — readOnlyHint, destructiveHint, idempotentHint, openWorldHint — go in annotations.

Resources

Resources expose read-only data addressed by a URI. Use a fixed uri for a single resource, or a uriTemplate whose variables map to the procedure's input.

// Static resource
export const appConfig = os
  .meta(mcp.resource({ uri: 'config://app', mimeType: 'application/json' }))
  .output(ConfigSchema)
  .handler(() => getConfig())

// Templated resource — `{id}` is read from the input
export const planet = os
  .meta(mcp.resource({ uriTemplate: 'planet://{id}', mimeType: 'application/json' }))
  .input(z.object({ id: z.string() }))
  .output(PlanetSchema)
  .handler(({ input }) => findPlanet(input.id))

Tip

Only annotate read-only, side-effect-free procedures as resources.

Prompts

Prompts are reusable templates a user can invoke. The arguments are derived from the procedure's .input(), and the handler returns the prompt messages.

export const planTrip = os
  .meta(mcp.prompt({ description: 'Plan a vacation' }))
  .input(z.object({ destination: z.string() }))
  .output(z.object({
    messages: z.array(z.object({
      role: z.enum(['user', 'assistant']),
      content: z.object({ type: z.literal('text'), text: z.string() }),
    })),
  }))
  .handler(({ input }) => ({
    messages: [{ role: 'user', content: { type: 'text', text: `Plan a trip to ${input.destination}` } }],
  }))

Serving

MCPHandler speaks MCP over the Streamable HTTP transport (Fetch or Node.js) or over stdio. Pass the schema converter for your validation library — the same converters used by @orpc/openapi.

It is built on oRPC's standard request/response flow, so tool, resource, and prompt calls run through your middleware, validation, and context, and any handler plugin (CORS, request limit, OpenTelemetry) composes as usual.

Fetch

import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { MCPHandler } from 'orpc-mcp/fetch'

const handler = new MCPHandler(router, {
  serverInfo: { name: 'planets', version: '1.0.0' },
  converters: [new ZodToJsonSchemaConverter()],
})

export async function POST(request: Request) {
  const { response } = await handler.handle(request, { context: {} })
  return response ?? new Response('Not found', { status: 404 })
}

Node.js

import { createServer } from 'node:http'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { MCPHandler } from 'orpc-mcp/node'

const handler = new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] })

createServer((req, res) => handler.handle(req, res, { context: {} })).listen(3000)

stdio

For clients that launch your server as a subprocess (Claude Desktop, IDEs):

import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { MCPHandler } from 'orpc-mcp/stdio'

await new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] })
  .listen({ context: {} })

Authorization

Authentication and catalog authorization are separate layers:

  1. Authenticate the request before calling handler.handle and put the resulting actor, tenant, and grants in oRPC context.
  2. Use authorizeCatalogEntry to hide entries from discovery and to reject guessed MCP names/URIs.
  3. Keep ordinary oRPC middleware as the final authorization boundary for ownership, tenant, and resource-state checks.

Application permission metadata is independent of mcp.* metadata:

import type { AnySchema, ErrorMap, Meta, MetaPlugin } from '@orpc/contract'
import { os } from '@orpc/server'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { mcp } from 'orpc-mcp'
import { MCPHandler } from 'orpc-mcp/fetch'

interface AccessMetadata {
  permission: string
}

interface RequestContext {
  user: { id: string }
  permissions: ReadonlySet<string>
}

function access(permission: string): MetaPlugin<AnySchema, AnySchema, ErrorMap> {
  return {
    name: '~access',
    init: (meta: Meta): Meta => ({
      ...meta,
      '~access': { permission } satisfies AccessMetadata,
    }),
  }
}

function isAccessMetadata(value: unknown): value is AccessMetadata {
  return typeof value === 'object'
    && value !== null
    && 'permission' in value
    && typeof value.permission === 'string'
}

const listOrders = os.$context<RequestContext>()
  .meta(access('orders.list'))
  .meta(mcp.tool({
    description: 'List orders visible to the current user',
    annotations: { readOnlyHint: true },
  }))
  .use(enforceOrderOwnership) // Existing oRPC middleware remains mandatory.
  .handler(({ context }) => ordersFor(context.user.id))

const router = { listOrders }

const handler = new MCPHandler(router, {
  converters: [new ZodToJsonSchemaConverter()],
  authorizeCatalogEntry: ({ entry, context }) => {
    const metadata = entry.contractMeta['~access']
    return isAccessMetadata(metadata)
      && context.permissions.has(metadata.permission)
  },
})

export async function POST(request: Request) {
  // Validate the credential, issuer, audience/resource, expiry, and scopes here.
  const context: RequestContext = await authenticateRequest(request)
  const { response } = await handler.handle(request, { context })
  return response ?? new Response('Not found', { status: 404 })
}

The callback runs per request for tools, static resources, resource templates, and prompts. It filters every list before pagination, controls per-request capability advertisement, and gates tools/call, resources/read, and prompts/get before the procedure pipeline. Decisions are never cached across contexts. A denied entry intentionally returns the same protocol error as an unknown entry; callback failures return only -32603 Internal error.

This callback is not an OAuth implementation. For remote HTTP servers, follow the MCP authorization guidance: use a maintained OAuth/token-validation library and validate issuer, audience or resource, expiry, and least-privilege scopes before constructing the request context. Do not log credentials. stdio servers can derive credentials from their local process environment.

Catalog visibility is only a name-level outer gate. A permitted invocation still runs validation and ordinary oRPC middleware; use middleware for ownership, tenant isolation, and checks that depend on the resolved resource.

See the runnable multi-tenant planet SaaS example and its real Node HTTP server. The smoke test drives that server with the official MCP client as tenant admin, tenant member, and an admin from another tenant.

Security

For HTTP servers reachable by browsers, enable Origin and Host validation to guard against DNS-rebinding attacks. A missing Origin header still passes, so non-browser clients are unaffected.

export const handler = new MCPHandler(router, {
  converters: [new ZodToJsonSchemaConverter()],
  enableDnsRebindingProtection: true,
  allowedOrigins: ['https://your-app.example'],
  allowedHosts: ['your-app.example'],
})

One Router, Every Surface

Because MCP exposure lives in procedure metadata, a single router can be mounted on multiple handlers at once — RPC, OpenAPI, and MCP — over the same instance:

export const handlers = {
  rpc: new RPCHandler(router), // typed oRPC clients
  openapi: new OpenAPIHandler(router), // REST + OpenAPI
  mcp: new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] }), // MCP tools / resources / prompts
}

Protocol Revisions

orpc-mcp speaks both MCP wire eras from one endpoint, and classifies every request independently — nothing is remembered between them:

legacy era modern era
Revisions 2024-10-07 … 2025-11-25 2026-07-28
Opening initialize handshake none; server/discover advertises
Client identity once, at initialize per request, in the _meta envelope
Results as-is resultType, plus ttlMs/cacheScope on cacheable ones

A request is modern when it carries the reserved io.modelcontextprotocol/protocolVersion _meta key. Classification is body-primary: the MCP-Protocol-Version, Mcp-Method and Mcp-Name headers are cross-checked against the body and rejected on disagreement (-32020), but a header alone never promotes a request to the modern era.

Because the modern era is stateless by construction, any request can land on any instance behind a plain round-robin load balancer — no shared session store.

Cache hints

Modern-era */list, resources/read and server/discover results must carry cache hints. The default is the always-safe pair — never reuse, never share:

export const handler = new MCPHandler(router, {
  converters: [new ZodToJsonSchemaConverter()],
  // Defaults to { ttlMs: 0, cacheScope: 'private' }.
  cache: { ttlMs: 60_000, cacheScope: 'public' },
})

Set cacheScope: 'public' only when the payload holds no per-user data: a shared gateway may serve a public result to a different caller.

Limitations

  • One JSON-RPC message per request — batching is not supported (removed from the spec in 2025-06-18; a batch carrying a modern element is rejected outright).
  • Sessions, the GET SSE channel, and listChanged/subscribe notifications are not implemented. The modern era removes all three, so the stateless request/response design is the target, not a shortcut.
  • Multi Round-Trip Requests (MRTR) are not implemented. Returning resultType: "input_required" to ask the user for input mid-call is opt-in for servers, and a server that never needs input is fully compliant without it. Consequently elicitation, sampling, and roots are unsupported — the latter two are deprecated as of 2026-07-28 anyway.
  • The tasks and subscriptions extensions are not implemented.

Development

pnpm install
pnpm test        # vitest, incl. an e2e suite driven by the official @modelcontextprotocol/sdk client
pnpm lint
pnpm type:check
pnpm build

License

MIT

About

Serve an oRPC router as an MCP server — tools, resources, and prompts over Streamable HTTP or stdio.

Topics

Resources

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages