-
-
Notifications
You must be signed in to change notification settings - Fork 152
feat(mcp): @orpc/mcp — serve one oRPC router as an MCP server (tools/resources/prompts) #1604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mi3lix9
wants to merge
13
commits into
middleapi:main
Choose a base branch
from
mi3lix9:feat/mcp-integration
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+3,465
−2
Open
Changes from 9 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
eae3d77
feat(mcp): @orpc/mcp — serve one oRPC router as an MCP server
mi3lix9 f80544a
test(mcp): unit + adapter coverage for @orpc/mcp (72 tests)
mi3lix9 5d0cab7
feat(mcp): wire MCP into the Next playground + docs
mi3lix9 65b87ef
test(mcp): satisfy strict tsc in test files (noUncheckedIndexedAccess…
mi3lix9 19dc1a8
test(mcp): e2e spec-compliance with the official @modelcontextprotoco…
mi3lix9 2676629
fix(mcp): omit structuredContent on tool errors (MCP outputSchema val…
mi3lix9 0db6a51
refactor(mcp)!: rebuild MCPHandler on StandardHandler + auto-register…
mi3lix9 1261c32
refactor(mcp): single namespaced meta API — mcp.tool/resource/prompt …
mi3lix9 c4e931c
feat(mcp): catalog pagination for list methods + auth/result-paginati…
mi3lix9 2884a01
docs(mcp): rewrite the integration page in the standard docs style
mi3lix9 dec3d6e
fix(mcp): address code-review findings (security, robustness, correct…
mi3lix9 b6c283c
Merge origin/main into feat/mcp-integration
mi3lix9 6a654af
refactor(mcp): remove unused getMessageId export (dead code)
mi3lix9 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| --- | ||
| title: MCP | ||
| description: Expose your oRPC router as a Model Context Protocol (MCP) server — tools, resources, and prompts — alongside RPC and OpenAPI. | ||
| --- | ||
|
|
||
| # Model Context Protocol (MCP) | ||
|
|
||
| [`@orpc/mcp`](https://www.npmjs.com/package/@orpc/mcp) turns an oRPC router into an [MCP](https://modelcontextprotocol.io) server, so the **same** procedures you already serve over RPC and OpenAPI can be called by MCP clients (Claude, ChatGPT, IDEs, agents). | ||
|
|
||
| Exposure is **opt-in**: only procedures annotated with `mcp.tool` / `mcp.resource` / `mcp.prompt` are visible to MCP clients. The procedure's `.input()` schema becomes the tool's JSON Schema `inputSchema`, and `.output()` becomes its `outputSchema` — reusing the same converters as [`@orpc/openapi`](/docs/openapi/openapi-specification). | ||
|
|
||
| ## Installation | ||
|
|
||
| ::: code-group | ||
|
|
||
| ```sh [npm] | ||
| npm install @orpc/mcp@latest | ||
| ``` | ||
|
|
||
| ```sh [pnpm] | ||
| pnpm add @orpc/mcp@latest | ||
| ``` | ||
|
|
||
| ```sh [yarn] | ||
| yarn add @orpc/mcp@latest | ||
| ``` | ||
|
|
||
| ```sh [bun] | ||
| bun add @orpc/mcp@latest | ||
| ``` | ||
|
|
||
| ::: | ||
|
|
||
| ## Annotate procedures | ||
|
|
||
| Annotate a procedure with `mcp.tool`, `mcp.resource`, or `mcp.prompt`. MCP metadata is independent of `openapi()` — a procedure can carry both. | ||
|
|
||
| ```ts twoslash | ||
| import { os } from '@orpc/server' | ||
| import { mcp } from '@orpc/mcp' | ||
| import * as z from 'zod' | ||
|
|
||
| // Tool (the default) — the model can call it | ||
| export const createPlanet = os | ||
| .meta(mcp.tool({ | ||
| description: 'Create a new planet', | ||
| annotations: { destructiveHint: false }, | ||
| })) | ||
| .input(z.object({ name: z.string(), description: z.string().optional() })) | ||
| .output(z.object({ id: z.string(), name: z.string() })) | ||
| .handler(({ input }) => ({ id: crypto.randomUUID(), name: input.name })) | ||
|
|
||
| // Resource — read-only data, addressed by a URI template (vars map to input) | ||
| export const planet = os | ||
| .meta(mcp.resource({ uriTemplate: 'planet://{id}', mimeType: 'application/json' })) | ||
| .input(z.object({ id: z.string() })) | ||
| .output(z.object({ id: z.string(), name: z.string() })) | ||
| .handler(({ input }) => ({ id: input.id, name: `Planet ${input.id}` })) | ||
|
|
||
| // Prompt — arguments come from .input(), messages from the handler's return | ||
| export const planTrip = os | ||
| .meta(mcp.prompt({ description: 'Plan a vacation' })) | ||
| .input(z.object({ destination: z.string(), days: z.number() })) | ||
| .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' as const, content: { type: 'text' as const, text: `Plan ${input.days} days in ${input.destination}` } }], | ||
| })) | ||
|
|
||
| export const router = { createPlanet, planet, planTrip } | ||
| ``` | ||
|
|
||
| ### Meta options | ||
|
|
||
| The primitive is chosen by which factory you call (`mcp.tool` / `mcp.resource` / | ||
| `mcp.prompt`); the remaining fields are: | ||
|
|
||
| | Field | Applies to | Description | | ||
| | -------------- | ---------- | -------------------------------------------------------------------------------------- | | ||
| | `name` | all | Identifier in the server. Defaults to the router path joined by `_`. | | ||
| | `title` | all | Human-readable display name. | | ||
| | `description` | all | Explanation used by the model to decide when/how to use it. | | ||
| | `annotations` | tool | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. | | ||
| | `outputSchema` | tool | Emit an MCP `outputSchema` from `.output()` (default: `true` when `.output()` is set). | | ||
| | `uri` | resource | Fixed URI of a static resource (e.g. `config://app`). | | ||
| | `uriTemplate` | resource | Templated URI (e.g. `planet://{id}`); variables bind to the procedure input. | | ||
| | `mimeType` | resource | MIME type of the resource contents. | | ||
|
|
||
| ## Serve it | ||
|
|
||
| `MCPHandler` speaks the MCP protocol (`initialize`, `tools/list`, `tools/call`, `resources/*`, `prompts/*`) over the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) transport (fetch/node) or stdio. Pass the schema converters for your validation library. | ||
|
|
||
| ### Streamable HTTP (fetch) | ||
|
|
||
| ```ts | ||
| import { MCPHandler } from '@orpc/mcp/fetch' | ||
| import { ZodToJsonSchemaConverter } from '@orpc/zod' | ||
|
|
||
| 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 }) | ||
| } | ||
| ``` | ||
|
|
||
| `MCPHandler` is built on oRPC's `StandardHandler`: `tools/call` / `resources/read` | ||
| / `prompts/get` run through the normal procedure pipeline (middleware, validation, | ||
| context) and accept any `StandardHandler` plugin (CORS, body-limit, OpenTelemetry), | ||
| while the MCP protocol routes (`initialize`, the `list` methods, …) are answered | ||
| by an auto-registered plugin. | ||
|
|
||
| ### Security (DNS-rebinding / Origin) | ||
|
|
||
| For browser-facing HTTP servers, enable Origin/Host validation (a missing `Origin` | ||
| still passes, for non-browser clients): | ||
|
|
||
| ```ts | ||
| export const handler = new MCPHandler(router, { | ||
| converters: [new ZodToJsonSchemaConverter()], | ||
| enableDnsRebindingProtection: true, | ||
| allowedOrigins: ['https://your-app.example'], | ||
| allowedHosts: ['your-app.example'], | ||
| }) | ||
| ``` | ||
|
|
||
| Request body size is bounded via oRPC's `BodyLimitHandlerPlugin` (node) — pass it | ||
| in `plugins`. | ||
|
|
||
| ### Streamable HTTP (Node.js) | ||
|
|
||
| ```ts | ||
| import { createServer } from 'node:http' | ||
| import { MCPHandler } from '@orpc/mcp/node' | ||
| import { ZodToJsonSchemaConverter } from '@orpc/zod' | ||
|
|
||
| const handler = new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] }) | ||
|
|
||
| createServer((req, res) => { | ||
| handler.handle(req, res, { context: {} }) | ||
| }).listen(3000) | ||
| ``` | ||
|
|
||
| ### stdio (local servers) | ||
|
|
||
| For MCP clients that launch your server as a subprocess (Claude Desktop, IDEs): | ||
|
|
||
| ```ts | ||
| import { MCPHandler } from '@orpc/mcp/stdio' | ||
| import { ZodToJsonSchemaConverter } from '@orpc/zod' | ||
|
|
||
| await new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] }) | ||
| .listen({ context: {} }) | ||
| ``` | ||
|
|
||
| ## One router, every surface | ||
|
|
||
| Because MCP exposure lives in procedure meta, a single router can be mounted on multiple handlers — RPC, OpenAPI, and MCP — at different paths over the same instance: | ||
|
|
||
| ```ts | ||
| export const handlers = { | ||
| rpc: new RPCHandler(router), // /rpc — typed oRPC clients | ||
| openapi: new OpenAPIHandler(router), // /api — REST + OpenAPI spec | ||
| mcp: new MCPHandler(router), // /mcp — MCP tools / resources / prompts | ||
| } | ||
| ``` | ||
|
|
||
| ## How it maps | ||
|
|
||
| | oRPC | MCP | | ||
| | -------------------------------- | --------------------------------------------------------------------------------------- | | ||
| | `.input()` schema | tool `inputSchema` / prompt `arguments` / resource template variables | | ||
| | `.output()` schema | tool `outputSchema` (+ `structuredContent`) / prompt `messages` / resource `contents` | | ||
| | handler return value | tool `content[]` (+ `structuredContent`), resource `contents[]`, or prompt `messages[]` | | ||
| | thrown `errors.*()` (typed) | in-band tool result with `isError: true` (so the model can react) | | ||
| | `ORPCError` in a resource/prompt | JSON-RPC protocol error | | ||
|
|
||
| ## Authorization | ||
|
|
||
| Authentication and authorization are the **application's** responsibility — `@orpc/mcp` is unopinionated about tokens, scopes, or OAuth servers. Use oRPC's normal context + middleware; the MCP handler runs it for every `tools/call` / `resources/read` / `prompts/get`. Supply request-derived values as `context` at the transport boundary (`handler.handle(request, { context: { authToken: request.headers.get('authorization') } })`), then enforce with middleware: | ||
|
|
||
| ```ts | ||
| export const authed = os.use(({ context, next, errors }) => { | ||
| const user = verifyToken(context.authToken) // your token format / scopes | ||
| if (!user) | ||
| throw errors.UNAUTHORIZED() | ||
| return next({ context: { user } }) | ||
| }) | ||
|
|
||
| export const deletePlanet = authed | ||
| .meta(mcp.tool({ description: 'Delete a planet' })) | ||
| .handler(({ context }) => context.user.id) | ||
| ``` | ||
|
|
||
| A thrown `UNAUTHORIZED` surfaces as an in-band tool error (tools) or a JSON-RPC error (resources/prompts). The OAuth _discovery_ handshake (HTTP `401` + `WWW-Authenticate` + RFC 9728 metadata) belongs at your HTTP layer in front of the handler. | ||
|
|
||
| ## Pagination | ||
|
|
||
| Two distinct kinds: | ||
|
|
||
| - **Catalog pagination** (the cursor on `tools/list` / `resources/list` / `prompts/list`) is **built in**. Set `pageSize` to page the server's catalog; catalogs at or under the page size return a single page. Cursors are opaque and an invalid one returns `-32602`. | ||
|
|
||
| ```ts | ||
| export const handler = new MCPHandler(router, { | ||
| converters: [new ZodToJsonSchemaConverter()], | ||
| pageSize: 50, | ||
| }) | ||
| ``` | ||
|
|
||
| - **Result pagination** (a tool that returns many rows) is the **developer's** job — model it in the tool's own `.input()`/`.output()`, like any API: | ||
|
|
||
| ```ts | ||
| export const listPlanets = os | ||
| .meta(mcp.tool({ description: 'List planets' })) | ||
| .input(z.object({ cursor: z.number().int().min(0).default(0), limit: z.number().int().max(100).default(20) })) | ||
| .output(z.object({ items: z.array(PlanetSchema), nextCursor: z.number().optional() })) | ||
| .handler(({ input }) => paginatePlanets(input.cursor, input.limit)) | ||
| ``` | ||
|
|
||
| ## Notes & limitations | ||
|
|
||
| - Targets MCP protocol revision `2025-11-25` (negotiated at `initialize`; older revisions are accepted). | ||
| - One JSON-RPC message per request — **JSON-RPC batching is not supported** (incompatible with the standard one-request/one-procedure flow, and deprecated in the MCP spec direction). | ||
| - Server → client streaming (`GET` SSE), `listChanged`/`subscribe` notifications, and sessions (`Mcp-Session-Id`) are not implemented — and are being **removed/replaced in the next MCP revision**, so the stateless POST→response design is intentional. | ||
| - Resource handlers should be side-effect free; only annotate read-only procedures as resources. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| { | ||
| "name": "@orpc/mcp", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should use the name |
||
| "type": "module", | ||
| "version": "2.0.0-beta.5", | ||
| "license": "MIT", | ||
| "homepage": "https://orpc.dev", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/middleapi/orpc.git", | ||
| "directory": "packages/mcp" | ||
| }, | ||
| "keywords": [ | ||
| "orpc", | ||
| "mcp", | ||
| "model-context-protocol" | ||
| ], | ||
| "sideEffects": false, | ||
| "publishConfig": { | ||
| "exports": { | ||
| "./package.json": "./package.json", | ||
| ".": { | ||
| "types": "./dist/index.d.mts", | ||
| "import": "./dist/index.mjs", | ||
| "default": "./dist/index.mjs" | ||
| }, | ||
| "./standard": { | ||
| "types": "./dist/adapters/standard/index.d.mts", | ||
| "import": "./dist/adapters/standard/index.mjs", | ||
| "default": "./dist/adapters/standard/index.mjs" | ||
| }, | ||
| "./fetch": { | ||
| "types": "./dist/adapters/fetch/index.d.mts", | ||
| "import": "./dist/adapters/fetch/index.mjs", | ||
| "default": "./dist/adapters/fetch/index.mjs" | ||
| }, | ||
| "./node": { | ||
| "types": "./dist/adapters/node/index.d.mts", | ||
| "import": "./dist/adapters/node/index.mjs", | ||
| "default": "./dist/adapters/node/index.mjs" | ||
| }, | ||
| "./stdio": { | ||
| "types": "./dist/adapters/stdio/index.d.mts", | ||
| "import": "./dist/adapters/stdio/index.mjs", | ||
| "default": "./dist/adapters/stdio/index.mjs" | ||
| } | ||
| } | ||
| }, | ||
| "exports": { | ||
| "./package.json": "./package.json", | ||
| ".": "./src/index.ts", | ||
| "./standard": "./src/adapters/standard/index.ts", | ||
| "./fetch": "./src/adapters/fetch/index.ts", | ||
| "./node": "./src/adapters/node/index.ts", | ||
| "./stdio": "./src/adapters/stdio/index.ts" | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "scripts": { | ||
| "build": "unbuild", | ||
| "type:check": "tsc -b" | ||
| }, | ||
| "dependencies": { | ||
| "@orpc/client": "workspace:*", | ||
| "@orpc/contract": "workspace:*", | ||
| "@orpc/json-schema": "workspace:*", | ||
| "@orpc/server": "workspace:*", | ||
| "@orpc/shared": "workspace:*" | ||
| }, | ||
| "devDependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "@orpc/zod": "workspace:*", | ||
| "zod": "^4.4.3" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './mcp-handler' |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.