diff --git a/README.md b/README.md index 407bb3480..7887c5f21 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ You can read the documentation [here](https://orpc.dev). **Framework & ecosystem integrations** +- [@orpc/mcp](https://www.npmjs.com/package/@orpc/mcp): Serve your router as a [Model Context Protocol](https://modelcontextprotocol.io) server. - [@orpc/next](https://www.npmjs.com/package/@orpc/next): Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index 0f73b7afc..245d137eb 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -185,6 +185,7 @@ export default withMermaid(defineConfig({ items: [ { text: 'Effect', link: '/docs/integrations/effect' }, { text: 'Evlog', link: '/docs/integrations/evlog' }, + { text: 'MCP', link: '/docs/integrations/mcp' }, { text: 'NestJS', link: '/docs/integrations/nest' }, { text: 'Next.js', link: '/docs/integrations/next' }, { text: 'OpenTelemetry', link: '/docs/integrations/opentelemetry' }, diff --git a/apps/content/docs/integrations/mcp.md b/apps/content/docs/integrations/mcp.md new file mode 100644 index 000000000..2ef1f3a89 --- /dev/null +++ b/apps/content/docs/integrations/mcp.md @@ -0,0 +1,209 @@ +# MCP Integration + +[Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open standard for connecting AI applications to external tools and data. This integration exposes your oRPC router as an MCP server, so 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. + +::: warning +This guide assumes you are familiar with [MCP](https://modelcontextprotocol.io). The integration targets protocol revision `2025-11-25`. +::: + +## Installation + +::: code-group + +```sh [npm] +npm install @orpc/mcp@beta +``` + +```sh [yarn] +yarn add @orpc/mcp@beta +``` + +```sh [pnpm] +pnpm add @orpc/mcp@beta +``` + +```sh [bun] +bun add @orpc/mcp@beta +``` + +```sh [deno] +deno add npm:@orpc/mcp@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`](/docs/openapi/routing) meta, so a single procedure can be served over REST and MCP at the same time. + +```ts twoslash +import { mcp } from '@orpc/mcp' +import { os } from '@orpc/server' +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](#serving) 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](/docs/error-handling) are reported back to the model as in-band tool errors, so it can react to them. + +```ts +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. + +```ts +// 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. + +```ts +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 the MCP protocol over the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) transport (Fetch or Node.js) or over stdio. Pass the schema converter for your validation library — the same converters used by [`@orpc/openapi`](/docs/openapi/specification). + +It is built on oRPC's standard request/response flow, so tool, resource, and prompt calls run through your [middleware](/docs/middleware), validation, and context, and any handler plugin (CORS, body limit, OpenTelemetry) composes as usual. + +### 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 }) +} +``` + +### 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 + +For 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: {} }) +``` + +## Authorization + +Authentication and authorization are your application's responsibility — the integration stays unopinionated about tokens, scopes, and OAuth. Supply request-derived values as `context` when calling the handler, then enforce them with ordinary [middleware](/docs/middleware), which runs for every tool, resource, and prompt call. + +```ts +export const authed = os.use(({ context, next, errors }) => { + const user = verifyToken(context.authToken) + if (!user) + throw errors.UNAUTHORIZED() + return next({ context: { user } }) +}) + +export const deletePlanet = authed + .meta(mcp.tool({ description: 'Delete a planet' })) + .handler(({ context }) => remove(context.user)) +``` + +A thrown `UNAUTHORIZED` reaches the model as an in-band tool error, or a resource/prompt request as a protocol error. + +## 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. + +```ts +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: + +```ts +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 +} +``` + +## Limitations + +- Targets MCP revision `2025-11-25`; older revisions are accepted during negotiation. +- One JSON-RPC message per request — batching is not supported. +- Server-initiated streaming (the `GET` SSE channel), `listChanged`/`subscribe` notifications, and sessions are not implemented. These are being removed or replaced in the next MCP revision, so the stateless request/response design is intentional. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 000000000..37140d6a9 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,75 @@ +{ + "name": "@orpc/mcp", + "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" + } +} diff --git a/packages/mcp/src/adapters/fetch/index.ts b/packages/mcp/src/adapters/fetch/index.ts new file mode 100644 index 000000000..83bdf7c28 --- /dev/null +++ b/packages/mcp/src/adapters/fetch/index.ts @@ -0,0 +1 @@ +export * from './mcp-handler' diff --git a/packages/mcp/src/adapters/fetch/mcp-handler.test.ts b/packages/mcp/src/adapters/fetch/mcp-handler.test.ts new file mode 100644 index 000000000..c9d0050b1 --- /dev/null +++ b/packages/mcp/src/adapters/fetch/mcp-handler.test.ts @@ -0,0 +1,120 @@ +import { os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import * as z from 'zod' +import { mcp } from '../../meta' +import { MCPHandler } from './mcp-handler' + +const greet = os + .meta(mcp.tool({ title: 'Greet', description: 'Greet a person' })) + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })) + .handler(({ input }) => ({ message: `Hello, ${input.name}!` })) + +const router = { greet } + +function createHandler() { + return new MCPHandler(router, { + converters: [new ZodToJsonSchemaConverter()], + serverInfo: { name: 'test', version: '0.1.0' }, + }) +} + +function makeRequest(method: string, body: string): Request { + return new Request('https://x/mcp', { + method, + body, + headers: { 'content-type': 'application/json' }, + }) +} + +function postRequest(payload: unknown): Request { + return makeRequest('POST', JSON.stringify(payload)) +} + +async function handle(handler: MCPHandler, request: Request): Promise { + const { response } = await handler.handle(request, { context: {} }) + expect(response).toBeDefined() + return response as Response +} + +describe('mCPHandler (fetch)', () => { + it('handles POST initialize with a 200 and a string protocolVersion', async () => { + const response = await handle(createHandler(), postRequest({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25' } })) + + expect(response.status).toBe(200) + + const body: any = await response.json() + expect(typeof body.result.protocolVersion).toBe('string') + expect(body.result.protocolVersion).toBe('2025-11-25') + expect(body.result.serverInfo).toEqual({ name: 'test', version: '0.1.0' }) + }) + + it('handles POST tools/call returning content as an array', async () => { + const response = await handle(createHandler(), postRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'greet', arguments: { name: 'World' } } })) + + expect(response.status).toBe(200) + + const body: any = await response.json() + expect(Array.isArray(body.result.content)).toBe(true) + expect(body.result.content[0]).toMatchObject({ type: 'text' }) + expect(body.result.content[0].text).toContain('Hello, World!') + expect(body.result.structuredContent).toEqual({ message: 'Hello, World!' }) + }) + + it('rejects a GET request with 405 Method Not Allowed', async () => { + const response = await handle(createHandler(), new Request('https://x/mcp', { method: 'GET' })) + + expect(response.status).toBe(405) + expect(response.headers.get('allow')).toBe('POST') + }) + + it('returns 400 with a parse error (-32700) for an invalid JSON body', async () => { + const response = await handle(createHandler(), makeRequest('POST', 'not json')) + + expect(response.status).toBe(400) + + const body: any = await response.json() + expect(body.error.code).toBe(-32700) + expect(body.id).toBeNull() + }) + + it('acknowledges a notification (no id) with 202 and an empty body', async () => { + const response = await handle(createHandler(), postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' })) + + expect(response.status).toBe(202) + expect(await response.text()).toBe('') + }) + + it('rejects JSON-RPC batches with 400 (-32600) — batching is unsupported', async () => { + const response = await handle(createHandler(), postRequest([ + { jsonrpc: '2.0', id: 1, method: 'ping' }, + { jsonrpc: '2.0', id: 2, method: 'tools/list' }, + ])) + + expect(response.status).toBe(400) + + const body: any = await response.json() + expect(body.error.code).toBe(-32600) + expect(body.id).toBeNull() + }) + + it('validates the Origin header when DNS-rebinding protection is enabled', async () => { + const handler = new MCPHandler(router, { + converters: [new ZodToJsonSchemaConverter()], + enableDnsRebindingProtection: true, + allowedOrigins: ['https://trusted.example'], + }) + + // A disallowed Origin is rejected with 403. + const blockedResponse = await handle(handler, new Request('https://x/mcp', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), + headers: { 'content-type': 'application/json', 'origin': 'https://evil.example' }, + })) + expect(blockedResponse.status).toBe(403) + + // A missing Origin (non-browser client) still passes. + const okResponse = await handle(handler, makeRequest('POST', JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }))) + expect(okResponse.status).toBe(200) + }) +}) diff --git a/packages/mcp/src/adapters/fetch/mcp-handler.ts b/packages/mcp/src/adapters/fetch/mcp-handler.ts new file mode 100644 index 000000000..7edb33613 --- /dev/null +++ b/packages/mcp/src/adapters/fetch/mcp-handler.ts @@ -0,0 +1,45 @@ +import type { JsonSchemaConverter } from '@orpc/json-schema' +import type { Context, Router } from '@orpc/server' +import type { FetchHandlerOptions } from '@orpc/server/fetch' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { MCPHandlerPluginOptions } from '../standard/mcp-handler-plugin' +import { FetchHandler } from '@orpc/server/fetch' +import { StandardHandler } from '@orpc/server/standard' +import { toArray } from '@orpc/shared' +import { createMCPRegistryProvider } from '../../registry' +import { MCPHandlerCodec } from '../standard/mcp-handler-codec' +import { MCPHandlerPlugin } from '../standard/mcp-handler-plugin' + +export interface MCPHandlerOptions + extends FetchHandlerOptions, Omit, 'plugins'>, MCPHandlerPluginOptions { + /** Schema → JSON Schema converters (e.g. `new ZodToJsonSchemaConverter()`). */ + converters?: JsonSchemaConverter[] +} + +/** + * Serves an oRPC router as an MCP server over the Streamable HTTP transport + * (POST JSON-RPC) on a Fetch-compatible runtime (Bun, Deno, Workers, Next.js…). + * + * Built on oRPC's {@link StandardHandler}: `tools/call` / `resources/read` / + * `prompts/get` run through the standard procedure pipeline (middleware, + * validation, context, plugins), while {@link MCPHandlerPlugin} answers the + * MCP protocol routes (`initialize`, the `list` methods, …). + * + * @example + * ```ts + * const handler = new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] }) + * const { response } = await handler.handle(request, { context: {} }) + * return response ?? new Response('Not found', { status: 404 }) + * ``` + */ +export class MCPHandler extends FetchHandler { + constructor(router: Router, options: NoInfer> = {}) { + const registry = createMCPRegistryProvider(router, { converters: options.converters }) + const codec = new MCPHandlerCodec(registry) + const handler = new StandardHandler(codec, { + ...options, + plugins: [new MCPHandlerPlugin(registry, options), ...toArray(options.plugins)], + }) + super(handler, options) + } +} diff --git a/packages/mcp/src/adapters/node/index.ts b/packages/mcp/src/adapters/node/index.ts new file mode 100644 index 000000000..83bdf7c28 --- /dev/null +++ b/packages/mcp/src/adapters/node/index.ts @@ -0,0 +1 @@ +export * from './mcp-handler' diff --git a/packages/mcp/src/adapters/node/mcp-handler.test.ts b/packages/mcp/src/adapters/node/mcp-handler.test.ts new file mode 100644 index 000000000..c5a653179 --- /dev/null +++ b/packages/mcp/src/adapters/node/mcp-handler.test.ts @@ -0,0 +1,101 @@ +import type { AddressInfo } from 'node:net' +import { createServer } from 'node:http' +import { os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import * as z from 'zod' +import { LATEST_PROTOCOL_VERSION, PARSE_ERROR } from '../../constants' +import { mcp } from '../../meta' +import { MCPHandler } from './mcp-handler' + +const greet = os + .meta(mcp.tool({ title: 'Greet', description: 'Greet a person' })) + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })) + .handler(({ input }) => ({ message: `Hello, ${input.name}!` })) + +const router = { greet } + +describe('mCPHandler (node adapter, real server)', () => { + let server: ReturnType + let baseUrl: string + + beforeAll(async () => { + const handler = new MCPHandler(router, { + converters: [new ZodToJsonSchemaConverter()], + serverInfo: { name: 'test', version: '0.1.0' }, + }) + + server = createServer((req, res) => { + void handler.handle(req, res, { context: {} }) + }) + + await new Promise((resolve) => { + server.listen(0, resolve) + }) + + const port = (server.address() as AddressInfo).port + baseUrl = `http://127.0.0.1:${port}` + }) + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())) + }) + }) + + function rpc(method: string, params?: Record): Promise { + return fetch(baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) + } + + it('handles a POST initialize request over HTTP', async () => { + const res = await rpc('initialize', { protocolVersion: LATEST_PROTOCOL_VERSION }) + expect(res.status).toBe(200) + + const json = await res.json() as any + expect(typeof json.result.protocolVersion).toBe('string') + expect(json.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION) + expect(json.result.serverInfo).toEqual({ name: 'test', version: '0.1.0' }) + expect(json.id).toBe(1) + }) + + it('lists the MCP-opted tool over HTTP', async () => { + const res = await rpc('tools/list') + expect(res.status).toBe(200) + + const json = await res.json() as any + expect(Array.isArray(json.result.tools)).toBe(true) + + const names = json.result.tools.map((t: any) => t.name) + expect(names).toContain('greet') + + const greetTool = json.result.tools.find((t: any) => t.name === 'greet') + expect(greetTool.title).toBe('Greet') + expect(greetTool.inputSchema.type).toBe('object') + expect(greetTool.inputSchema.properties).toHaveProperty('name') + }) + + it('rejects a GET request with HTTP 405', async () => { + const res = await fetch(baseUrl, { method: 'GET' }) + await res.body?.cancel() + expect(res.status).toBe(405) + expect(res.headers.get('allow')).toBe('POST') + }) + + it('returns a JSON-RPC parse error for invalid JSON with HTTP 400', async () => { + const res = await fetch(baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{ not valid json', + }) + expect(res.status).toBe(400) + + const json = await res.json() as any + expect(json.error.code).toBe(PARSE_ERROR) + expect(json.error.code).toBe(-32700) + expect(json.error.message).toBe('Parse error') + }) +}) diff --git a/packages/mcp/src/adapters/node/mcp-handler.ts b/packages/mcp/src/adapters/node/mcp-handler.ts new file mode 100644 index 000000000..d5c59dc9b --- /dev/null +++ b/packages/mcp/src/adapters/node/mcp-handler.ts @@ -0,0 +1,40 @@ +import type { JsonSchemaConverter } from '@orpc/json-schema' +import type { Context, Router } from '@orpc/server' +import type { NodeHttpHandlerOptions } from '@orpc/server/node' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { MCPHandlerPluginOptions } from '../standard/mcp-handler-plugin' +import { NodeHttpHandler } from '@orpc/server/node' +import { StandardHandler } from '@orpc/server/standard' +import { toArray } from '@orpc/shared' +import { createMCPRegistryProvider } from '../../registry' +import { MCPHandlerCodec } from '../standard/mcp-handler-codec' +import { MCPHandlerPlugin } from '../standard/mcp-handler-plugin' + +export interface MCPHandlerOptions + extends NodeHttpHandlerOptions, Omit, 'plugins'>, MCPHandlerPluginOptions { + /** Schema → JSON Schema converters (e.g. `new ZodToJsonSchemaConverter()`). */ + converters?: JsonSchemaConverter[] +} + +/** + * Serves an oRPC router as an MCP server over the Streamable HTTP transport on a + * Node.js `http`/`https` server. The Node adapter reads/limits the request body + * (`BodyLimitHandlerPlugin`) — no hand-rolled body parsing. + * + * @example + * ```ts + * const handler = new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] }) + * createServer((req, res) => handler.handle(req, res, { context: {} })) + * ``` + */ +export class MCPHandler extends NodeHttpHandler { + constructor(router: Router, options: NoInfer> = {}) { + const registry = createMCPRegistryProvider(router, { converters: options.converters }) + const codec = new MCPHandlerCodec(registry) + const handler = new StandardHandler(codec, { + ...options, + plugins: [new MCPHandlerPlugin(registry, options), ...toArray(options.plugins)], + }) + super(handler, options) + } +} diff --git a/packages/mcp/src/adapters/standard/index.ts b/packages/mcp/src/adapters/standard/index.ts new file mode 100644 index 000000000..ec7655032 --- /dev/null +++ b/packages/mcp/src/adapters/standard/index.ts @@ -0,0 +1,2 @@ +export * from './mcp-handler-codec' +export * from './mcp-handler-plugin' diff --git a/packages/mcp/src/adapters/standard/mcp-handler-codec.ts b/packages/mcp/src/adapters/standard/mcp-handler-codec.ts new file mode 100644 index 000000000..4c555fec2 --- /dev/null +++ b/packages/mcp/src/adapters/standard/mcp-handler-codec.ts @@ -0,0 +1,88 @@ +import type { AnyORPCError } from '@orpc/client' +import type { AnyProcedure, Context } from '@orpc/server' +import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure, StandardHandlerHandleOptions } from '@orpc/server/standard' +import type { Promisable } from '@orpc/shared' +import type { StandardLazyRequest, StandardResponse } from '@standardserver/core' +import type { MCPRegistryProvider } from '../../registry' +import { isObject, isValidIncoming, readMCPPayload } from './utils' + +/** + * Internal `StandardResponse.body` produced by the codec. The + * {@link MCPHandlerPlugin} reads it back to shape the MCP result and frame the + * JSON-RPC envelope (it owns the request `id`); this body never reaches the wire. + */ +export interface MCPCodecBody { + kind: 'result' | 'error' + output?: unknown + error?: AnyORPCError +} + +/** + * `StandardHandlerCodec` for the MCP methods that invoke a procedure + * (`tools/call`, `resources/read`, `prompts/get`). It resolves the target + * procedure from the JSON-RPC body and hands the raw output/error back to the + * plugin (tagged via {@link MCP_CODEC_BODY}) — the plugin shapes the MCP result + * and frames the JSON-RPC envelope. Everything else (the actual call) goes + * through oRPC's standard procedure pipeline. + */ +export class MCPHandlerCodec implements StandardHandlerCodec { + constructor(private readonly registry: MCPRegistryProvider) {} + + async resolveProcedure( + request: StandardLazyRequest, + _options: StandardHandlerHandleOptions, + ): Promise { + const message = await readMCPPayload(request) + if (!isValidIncoming(message) || !('id' in message) || message.id === undefined) { + return undefined + } + + const params = isObject(message.params) ? message.params : {} + const registry = await this.registry.get() + + if (message.method === 'tools/call') { + const entry = typeof params.name === 'string' ? registry.tools.get(params.name) : undefined + if (entry === undefined) { + return undefined + } + const input = isObject(params.arguments) ? params.arguments : {} + return { path: [entry.definition.name], procedure: entry.procedure, decodeInput: () => Promise.resolve(input) } + } + + if (message.method === 'resources/read') { + if (typeof params.uri !== 'string') { + return undefined + } + const staticEntry = registry.resources.get(params.uri) + if (staticEntry !== undefined) { + return { path: [staticEntry.definition.name], procedure: staticEntry.procedure, decodeInput: () => Promise.resolve({}) } + } + for (const entry of registry.resourceTemplates) { + const variables = entry.template.match(params.uri) + if (variables !== undefined) { + return { path: [entry.definition.name], procedure: entry.procedure, decodeInput: () => Promise.resolve(variables) } + } + } + return undefined + } + + if (message.method === 'prompts/get') { + const entry = typeof params.name === 'string' ? registry.prompts.get(params.name) : undefined + if (entry === undefined) { + return undefined + } + const input = isObject(params.arguments) ? params.arguments : {} + return { path: [entry.definition.name], procedure: entry.procedure, decodeInput: () => Promise.resolve(input) } + } + + return undefined + } + + encodeOutput(output: unknown, _procedure: AnyProcedure, _path: string[], _options: StandardHandlerHandleOptions): Promisable { + return { status: 200, headers: {}, body: { kind: 'result', output } satisfies MCPCodecBody as never } + } + + encodeError(error: AnyORPCError, _procedure: AnyProcedure, _path: string[], _options: StandardHandlerHandleOptions): Promisable { + return { status: 200, headers: {}, body: { kind: 'error', error } satisfies MCPCodecBody as never } + } +} diff --git a/packages/mcp/src/adapters/standard/mcp-handler-plugin.ts b/packages/mcp/src/adapters/standard/mcp-handler-plugin.ts new file mode 100644 index 000000000..4f6db56eb --- /dev/null +++ b/packages/mcp/src/adapters/standard/mcp-handler-plugin.ts @@ -0,0 +1,366 @@ +import type { AnyORPCError } from '@orpc/client' +import type { Context } from '@orpc/server' +import type { + StandardHandlerHandleResult, + StandardHandlerOptions, + StandardHandlerPlugin, +} from '@orpc/server/standard' +import type { StandardBody, StandardLazyRequest } from '@standardserver/core' +import type { MCPRegistry, MCPRegistryProvider } from '../../registry' +import type { + Implementation, + InitializeResult, + JSONRPCErrorObject, + JSONRPCIncoming, + ServerCapabilities, +} from '../../types' +import type { MCPCodecBody } from './mcp-handler-codec' +import { flattenStandardHeader } from '@standardserver/core' +import { + DEFAULT_LIST_PAGE_SIZE, + DEFAULT_SERVER_NAME, + DEFAULT_SERVER_VERSION, + FORBIDDEN_ERROR, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + JSONRPC_VERSION, + LATEST_PROTOCOL_VERSION, + METHOD_NOT_FOUND, + PARSE_ERROR, + RESOURCE_NOT_FOUND, + SUPPORTED_PROTOCOL_VERSIONS, +} from '../../constants' +import { encodePromptMessages, encodeResourceContents, encodeToolResult } from '../../content' +import { JSONRPCError, orpcErrorToJSONRPCError } from '../../error' +import { isObject, isValidIncoming, readMCPPayload } from './utils' + +const PROCEDURE_METHODS = new Set(['tools/call', 'resources/read', 'prompts/get']) + +export interface MCPHandlerPluginOptions { + /** Server identity reported during `initialize`. */ + serverInfo?: Partial + /** Optional `instructions` returned to the client during `initialize`. */ + instructions?: string + /** + * Enable Origin/Host validation (DNS-rebinding protection) for HTTP transports. + * A missing `Origin` header always passes (non-browser clients). When enabled, + * a present `Origin`/`Host` not in the corresponding allowlist is rejected (403). + * + * @default false + */ + enableDnsRebindingProtection?: boolean + /** Allowed `Origin` header values (exact match) when protection is enabled. */ + allowedOrigins?: string[] + /** Allowed `Host` header values (exact match) when protection is enabled. */ + allowedHosts?: string[] + /** + * Page size for catalog pagination of the `list` methods (`tools/list`, + * `resources/list`, `resources/templates/list`, `prompts/list`). Catalogs at + * or under this size return a single page. + * + * @default 100 + */ + pageSize?: number +} + +/** + * Auto-registered plugin that turns a {@link StandardHandler} into an MCP server. + * + * It installs a routing interceptor that owns the JSON-RPC envelope: + * - protocol methods (`initialize`, `ping`, the `list` methods, `completion/complete`, + * `notifications/*`) are answered with an early response (no procedure call); + * - procedure methods (`tools/call`, `resources/read`, `prompts/get`) fall + * through to {@link MCPHandlerCodec} via `next()` (the standard procedure + * pipeline), then this plugin shapes the MCP result and frames the JSON-RPC + * envelope with the request `id`. + */ +export class MCPHandlerPlugin implements StandardHandlerPlugin { + readonly name = '~mcp' + + private readonly serverInfo: Implementation + private readonly instructions: string | undefined + private readonly enableDnsRebindingProtection: boolean + private readonly allowedOrigins: string[] | undefined + private readonly allowedHosts: string[] | undefined + private readonly pageSize: number + + constructor( + private readonly registry: MCPRegistryProvider, + options: MCPHandlerPluginOptions = {}, + ) { + this.serverInfo = { + name: options.serverInfo?.name ?? DEFAULT_SERVER_NAME, + version: options.serverInfo?.version ?? DEFAULT_SERVER_VERSION, + ...(options.serverInfo?.title !== undefined ? { title: options.serverInfo.title } : {}), + } + this.instructions = options.instructions + this.enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false + this.allowedOrigins = options.allowedOrigins + this.allowedHosts = options.allowedHosts + // Fail loud on a no-op security config: enabling protection without any + // allowlist would otherwise silently allow every Origin/Host. + if (this.enableDnsRebindingProtection && this.allowedOrigins === undefined && this.allowedHosts === undefined) { + throw new TypeError('`enableDnsRebindingProtection` requires `allowedOrigins` and/or `allowedHosts` to be set.') + } + this.pageSize = options.pageSize !== undefined && Number.isInteger(options.pageSize) && options.pageSize > 0 + ? options.pageSize + : DEFAULT_LIST_PAGE_SIZE + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + return { + ...options, + routingInterceptors: [ + ...(options.routingInterceptors ?? []), + ({ request, next }) => this.route(request, next), + ], + } + } + + private async route( + request: StandardLazyRequest, + next: () => Promise, + ): Promise { + // 1. DNS-rebinding / Origin protection (no-op for stdio / non-browser clients). + if (!this.checkSecurity(request)) { + return jsonRpc(403, null, { error: { code: FORBIDDEN_ERROR, message: 'Origin not allowed' } }) + } + + // 2. MCP uses HTTP POST. (GET SSE / DELETE sessions are not implemented yet.) + if (request.method !== 'POST') { + return { matched: true, response: { status: 405, headers: { allow: 'POST' }, body: undefined } } + } + + // 3. Parse the JSON-RPC envelope. + let payload: unknown + try { + payload = await readMCPPayload(request) + } + catch { + return jsonRpc(400, null, { error: { code: PARSE_ERROR, message: 'Parse error' } }) + } + + // 4. Batching is intentionally unsupported (incompatible with the standard + // one-request/one-procedure flow and deprecated in the MCP spec direction). + if (Array.isArray(payload)) { + return jsonRpc(400, null, { error: { code: INVALID_REQUEST, message: 'JSON-RPC batching is not supported' } }) + } + + if (!isValidIncoming(payload)) { + return jsonRpc(400, null, { error: { code: INVALID_REQUEST, message: 'Invalid Request' } }) + } + + const id = 'id' in payload ? payload.id : undefined + + // 5. Notification (no id) — acknowledge with 202, no body. + if (id === undefined) { + return { matched: true, response: { status: 202, headers: {}, body: undefined } } + } + + try { + // 6. Procedure methods → standard pipeline via the codec, then frame. + if (PROCEDURE_METHODS.has(payload.method)) { + return await this.frameProcedure(payload, id, next) + } + + // 7. Protocol methods → early response. + const result = await this.handleProtocol(payload) + return jsonRpc(200, id, { result }) + } + catch (error) { + const jsonRpcError = error instanceof JSONRPCError + ? error + : new JSONRPCError(INTERNAL_ERROR, error instanceof Error ? error.message : 'Internal error') + return jsonRpc(200, id, { error: jsonRpcError.toJSON() }) + } + } + + private async frameProcedure( + message: JSONRPCIncoming, + id: string | number, + next: () => Promise, + ): Promise { + const result = await next() + const params = isObject(message.params) ? message.params : {} + + if (!result.matched) { + return jsonRpc(200, id, { error: this.notFound(message.method, params) }) + } + + const codecBody = result.response.body as unknown as MCPCodecBody + const registry = await this.registry.get() + + if (codecBody.kind === 'error') { + const error = codecBody.error as AnyORPCError + // Tool errors are reported in-band (so the model can react); resource and + // prompt errors are protocol-level JSON-RPC errors. + if (message.method === 'tools/call') { + return jsonRpc(200, id, { result: { content: [{ type: 'text', text: error.message }], isError: true } }) + } + return jsonRpc(200, id, { error: orpcErrorToJSONRPCError(error).toJSON() }) + } + + return jsonRpc(200, id, { result: this.shapeProcedureResult(message.method, params, codecBody.output, registry) }) + } + + private shapeProcedureResult(method: string, params: Record, output: unknown, registry: MCPRegistry): unknown { + if (method === 'tools/call') { + const name = typeof params.name === 'string' ? params.name : '' + const hasOutputSchema = registry.tools.get(name)?.definition.outputSchema !== undefined + return encodeToolResult(output, hasOutputSchema) + } + if (method === 'resources/read') { + const uri = typeof params.uri === 'string' ? params.uri : '' + return { contents: encodeResourceContents(output, uri, this.resourceMimeType(uri, registry)) } + } + // prompts/get + const name = typeof params.name === 'string' ? params.name : '' + const description = registry.prompts.get(name)?.meta.description + const result = encodePromptMessages(output) + return description !== undefined && result.description === undefined ? { description, ...result } : result + } + + private resourceMimeType(uri: string, registry: MCPRegistry): string | undefined { + const staticEntry = registry.resources.get(uri) + if (staticEntry !== undefined) { + return staticEntry.meta.mimeType + } + for (const entry of registry.resourceTemplates) { + if (entry.template.match(uri) !== undefined) { + return entry.meta.mimeType + } + } + return undefined + } + + private notFound(method: string, params: Record): JSONRPCErrorObject { + if (method === 'resources/read') { + // A malformed request (missing/non-string uri) is invalid params, not a + // "resource not found"; reserve -32002 for syntactically valid URIs. + if (typeof params.uri !== 'string') { + return { code: INVALID_PARAMS, message: 'resources/read requires a string "uri"' } + } + return { code: RESOURCE_NOT_FOUND, message: `Resource not found: ${params.uri}`, data: { uri: params.uri } } + } + const kind = method === 'prompts/get' ? 'prompt' : 'tool' + return { code: INVALID_PARAMS, message: `Unknown ${kind}: ${String(params.name)}` } + } + + /** Apply opaque-cursor catalog pagination to a list result. */ + private paginate(items: unknown[], key: string, cursor: unknown): Record { + const offset = decodeCursor(cursor) + // A valid cursor always points within the catalog (nextCursor is only emitted + // when more items remain), so an out-of-range offset is a stale/invalid cursor. + if (offset > 0 && offset >= items.length) { + throw new JSONRPCError(INVALID_PARAMS, 'Invalid cursor') + } + const page = items.slice(offset, offset + this.pageSize) + const result: Record = { [key]: page } + if (offset + this.pageSize < items.length) { + result.nextCursor = encodeCursor(offset + this.pageSize) + } + return result + } + + private async handleProtocol(message: JSONRPCIncoming): Promise { + const params = isObject(message.params) ? message.params : {} + switch (message.method) { + case 'initialize': + return this.initialize(params) + case 'ping': + return {} + case 'tools/list': + return this.paginate([...(await this.registry.get()).tools.values()].map(entry => entry.definition), 'tools', params.cursor) + case 'resources/list': + return this.paginate([...(await this.registry.get()).resources.values()].map(entry => entry.definition), 'resources', params.cursor) + case 'resources/templates/list': + return this.paginate((await this.registry.get()).resourceTemplates.map(entry => entry.definition), 'resourceTemplates', params.cursor) + case 'prompts/list': + return this.paginate([...(await this.registry.get()).prompts.values()].map(entry => entry.definition), 'prompts', params.cursor) + default: + throw new JSONRPCError(METHOD_NOT_FOUND, `Method not found: ${message.method}`) + } + } + + private async initialize(params: Record): Promise { + const requested = typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined + const protocolVersion = requested !== undefined && (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested) + ? requested + : LATEST_PROTOCOL_VERSION + + const registry = await this.registry.get() + const capabilities: ServerCapabilities = {} + if (registry.tools.size > 0) { + capabilities.tools = { listChanged: false } + } + if (registry.resources.size > 0 || registry.resourceTemplates.length > 0) { + capabilities.resources = { subscribe: false, listChanged: false } + } + if (registry.prompts.size > 0) { + capabilities.prompts = { listChanged: false } + } + + return { + protocolVersion, + capabilities, + serverInfo: this.serverInfo, + ...(this.instructions !== undefined ? { instructions: this.instructions } : {}), + } + } + + private checkSecurity(request: StandardLazyRequest): boolean { + if (!this.enableDnsRebindingProtection) { + return true + } + + const origin = flattenStandardHeader(request.headers.origin) + if (origin !== undefined && this.allowedOrigins !== undefined && !this.allowedOrigins.includes(origin)) { + return false + } + + const host = flattenStandardHeader(request.headers.host) + if (host !== undefined && this.allowedHosts !== undefined && !this.allowedHosts.includes(host)) { + return false + } + + return true + } +} + +/** Opaque, offset-based pagination cursor (the registry order is deterministic). */ +function encodeCursor(offset: number): string { + return btoa(String(offset)) +} + +function decodeCursor(cursor: unknown): number { + if (cursor === undefined) { + return 0 + } + if (typeof cursor !== 'string') { + throw new JSONRPCError(INVALID_PARAMS, 'Invalid cursor') + } + let offset: number + try { + offset = Number(atob(cursor)) + } + catch { + throw new JSONRPCError(INVALID_PARAMS, 'Invalid cursor') + } + if (!Number.isInteger(offset) || offset < 0) { + throw new JSONRPCError(INVALID_PARAMS, 'Invalid cursor') + } + return offset +} + +function jsonRpc( + status: number, + id: string | number | null, + payload: { result: unknown } | { error: JSONRPCErrorObject }, +): StandardHandlerHandleResult { + const body = { jsonrpc: JSONRPC_VERSION, id, ...payload } + return { + matched: true, + response: { status, headers: { 'content-type': 'application/json' }, body: body as unknown as StandardBody }, + } +} diff --git a/packages/mcp/src/adapters/standard/mcp-hardening.test.ts b/packages/mcp/src/adapters/standard/mcp-hardening.test.ts new file mode 100644 index 000000000..25a18cb4d --- /dev/null +++ b/packages/mcp/src/adapters/standard/mcp-hardening.test.ts @@ -0,0 +1,133 @@ +import { os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import * as z from 'zod' +import { mcp } from '../../meta' +import { createMCPRegistryProvider } from '../../registry' +import { MCPHandler } from '../fetch/mcp-handler' + +const router = { + t1: os.meta(mcp.tool({ description: 'one' })).input(z.object({})).handler(() => 1), + t2: os.meta(mcp.tool({ description: 'two' })).input(z.object({})).handler(() => 2), + t3: os.meta(mcp.tool({ description: 'three' })).input(z.object({})).handler(() => 3), + cfg: os + .meta(mcp.resource({ uri: 'config://app', mimeType: 'application/json' })) + .output(z.object({ v: z.number() })) + .handler(() => ({ v: 1 })), +} + +function createHandler(options: Record = {}) { + return new MCPHandler(router as any, { converters: [new ZodToJsonSchemaConverter()], ...options }) +} + +async function send(h: MCPHandler, message: unknown, headers: Record = {}): Promise { + const { response } = await h.handle( + new Request('https://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(message), + }), + { context: {} }, + ) + return response as Response +} + +async function rpc(h: MCPHandler, message: unknown, headers?: Record): Promise { + return (await send(h, message, headers)).json() +} + +describe('jSON-RPC id validation', () => { + it('rejects a non-primitive (object) id with Invalid Request', async () => { + const body = await rpc(createHandler(), { jsonrpc: '2.0', id: {}, method: 'ping' }) + expect(body.error.code).toBe(-32600) + }) + + it('rejects a boolean id', async () => { + const body = await rpc(createHandler(), { jsonrpc: '2.0', id: false, method: 'ping' }) + expect(body.error.code).toBe(-32600) + }) + + it('treats a missing id as a notification (202, no body)', async () => { + const res = await send(createHandler(), { jsonrpc: '2.0', method: 'ping' }) + expect(res.status).toBe(202) + }) +}) + +describe('catalog pagination hardening', () => { + it('ignores a non-integer pageSize and falls back to the default', async () => { + const body = await rpc(createHandler({ pageSize: 2.5 }), { jsonrpc: '2.0', id: 1, method: 'tools/list' }) + expect(body.result.tools).toHaveLength(3) + expect(body.result.nextCursor).toBeUndefined() + }) + + it('rejects a stale/out-of-range cursor with -32602', async () => { + const body = await rpc(createHandler({ pageSize: 2 }), { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { cursor: btoa('10') }, + }) + expect(body.error.code).toBe(-32602) + }) +}) + +describe('dNS-rebinding protection', () => { + it('throws when enabled without any allowlist', () => { + expect(() => createHandler({ enableDnsRebindingProtection: true })).toThrow(/allowedOrigins/) + }) + + it('rejects a disallowed Origin with 403', async () => { + const h = createHandler({ enableDnsRebindingProtection: true, allowedOrigins: ['https://ok.test'] }) + const res = await send(h, { jsonrpc: '2.0', id: 1, method: 'ping' }, { origin: 'https://evil.test' }) + expect(res.status).toBe(403) + }) + + it('allows a permitted Origin', async () => { + const h = createHandler({ enableDnsRebindingProtection: true, allowedOrigins: ['https://ok.test'] }) + const body = await rpc(h, { jsonrpc: '2.0', id: 1, method: 'ping' }, { origin: 'https://ok.test' }) + expect(body.result).toEqual({}) + }) +}) + +describe('resources/read error codes', () => { + it('returns -32602 (invalid params) for a missing/non-string uri', async () => { + const body = await rpc(createHandler(), { jsonrpc: '2.0', id: 1, method: 'resources/read', params: {} }) + expect(body.error.code).toBe(-32602) + }) + + it('returns -32002 (resource not found) for a valid but unknown uri', async () => { + const body = await rpc(createHandler(), { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'config://nope' } }) + expect(body.error.code).toBe(-32002) + }) +}) + +describe('completion capability', () => { + it('does not handle completion/complete (it is not advertised) → method not found', async () => { + const body = await rpc(createHandler(), { jsonrpc: '2.0', id: 1, method: 'completion/complete', params: {} }) + expect(body.error.code).toBe(-32601) + }) +}) + +describe('registry integrity', () => { + it('throws on duplicate tool names', async () => { + const dup = { + a: os.meta(mcp.tool({ name: 'same' })).input(z.object({})).handler(() => 1), + b: os.meta(mcp.tool({ name: 'same' })).input(z.object({})).handler(() => 2), + } + const provider = createMCPRegistryProvider(dup as any, { converters: [new ZodToJsonSchemaConverter()] }) + await expect(provider.get()).rejects.toThrow(/Duplicate MCP tool name/) + }) + + it('merges properties from multiple input schemas (allOf)', async () => { + const multiRouter = { + multi: os + .meta(mcp.tool({ description: 'm' })) + .input(z.object({ a: z.string() })) + .input(z.object({ b: z.number() })) + .handler(() => 1), + } + const provider = createMCPRegistryProvider(multiRouter as any, { converters: [new ZodToJsonSchemaConverter()] }) + const registry = await provider.get() + const inputSchema = registry.tools.get('multi')!.definition.inputSchema + expect(Object.keys(inputSchema.properties ?? {})).toEqual(expect.arrayContaining(['a', 'b'])) + }) +}) diff --git a/packages/mcp/src/adapters/standard/mcp-pagination.test.ts b/packages/mcp/src/adapters/standard/mcp-pagination.test.ts new file mode 100644 index 000000000..9f5e28f6b --- /dev/null +++ b/packages/mcp/src/adapters/standard/mcp-pagination.test.ts @@ -0,0 +1,69 @@ +import { os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import * as z from 'zod' +import { mcp } from '../../meta' +import { MCPHandler } from '../fetch/mcp-handler' + +// five tools: tool1..tool5 (registry order = insertion order) +const router: Record = {} +for (let i = 1; i <= 5; i++) { + router[`tool${i}`] = os + .meta(mcp.tool({ description: `Tool ${i}` })) + .input(z.object({})) + .handler(() => i) +} + +function createHandler() { + return new MCPHandler(router as any, { + converters: [new ZodToJsonSchemaConverter()], + pageSize: 2, + }) +} + +async function list(handler: MCPHandler, cursor?: string): Promise { + const params = cursor === undefined ? {} : { cursor } + const { response } = await handler.handle( + new Request('https://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params }), + }), + { context: {} }, + ) + return (response as Response).json() +} + +describe('catalog pagination (tools/list)', () => { + it('returns one page plus a nextCursor when more remain', async () => { + const body = await list(createHandler()) + expect(body.result.tools.map((t: any) => t.name)).toEqual(['tool1', 'tool2']) + expect(typeof body.result.nextCursor).toBe('string') + }) + + it('walks every page via nextCursor and omits it on the last page', async () => { + const handler = createHandler() + const pages: string[][] = [] + let cursor: string | undefined + + do { + const body = await list(handler, cursor) + pages.push(body.result.tools.map((t: any) => t.name)) + cursor = body.result.nextCursor + } while (cursor !== undefined) + + // 5 items / pageSize 2 -> [2, 2, 1]; loop terminates because the last page omits nextCursor + expect(pages).toEqual([['tool1', 'tool2'], ['tool3', 'tool4'], ['tool5']]) + }) + + it('rejects an invalid cursor with -32602', async () => { + const body = await list(createHandler(), '!!!not-a-cursor!!!') + expect(body.error.code).toBe(-32602) + }) + + it('does not paginate when the catalog fits in one page (default size)', async () => { + const handler = new MCPHandler(router as any, { converters: [new ZodToJsonSchemaConverter()] }) + const body = await list(handler) + expect(body.result.tools).toHaveLength(5) + expect(body.result.nextCursor).toBeUndefined() + }) +}) diff --git a/packages/mcp/src/adapters/standard/utils.ts b/packages/mcp/src/adapters/standard/utils.ts new file mode 100644 index 000000000..bc037daaf --- /dev/null +++ b/packages/mcp/src/adapters/standard/utils.ts @@ -0,0 +1,36 @@ +import type { StandardLazyRequest } from '@standardserver/core' +import type { JSONRPCIncoming } from '../../types' +import { JSONRPC_VERSION } from '../../constants' + +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isValidIncoming(value: unknown): value is JSONRPCIncoming { + if (!isObject(value) || value.jsonrpc !== JSONRPC_VERSION || typeof value.method !== 'string') { + return false + } + // A JSON-RPC id, when present, must be a string or number (null/object/array + // are invalid). A missing id marks a notification. + return !('id' in value) || typeof value.id === 'string' || typeof value.id === 'number' +} + +/** + * The body stream can only be consumed once, but both the plugin and the codec + * need the parsed JSON-RPC envelope. Memoize the parse per request so they share + * a single read. + */ +const payloadCache = new WeakMap>() + +/** + * Read + parse the JSON-RPC body of an MCP request (once per request). + * The returned promise rejects if the body is not valid JSON. + */ +export function readMCPPayload(request: StandardLazyRequest): Promise { + let parsed = payloadCache.get(request) + if (parsed === undefined) { + parsed = request.resolveBody('json') + payloadCache.set(request, parsed) + } + return parsed +} diff --git a/packages/mcp/src/adapters/stdio/index.ts b/packages/mcp/src/adapters/stdio/index.ts new file mode 100644 index 000000000..83bdf7c28 --- /dev/null +++ b/packages/mcp/src/adapters/stdio/index.ts @@ -0,0 +1 @@ +export * from './mcp-handler' diff --git a/packages/mcp/src/adapters/stdio/mcp-handler.test.ts b/packages/mcp/src/adapters/stdio/mcp-handler.test.ts new file mode 100644 index 000000000..d5b6e3e23 --- /dev/null +++ b/packages/mcp/src/adapters/stdio/mcp-handler.test.ts @@ -0,0 +1,113 @@ +import { Readable, Writable } from 'node:stream' +import { os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import * as z from 'zod' +import { mcp } from '../../meta' +import { MCPHandler } from './mcp-handler' + +const greet = os + .meta(mcp.tool({ title: 'Greet', description: 'Greet a person' })) + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })) + .handler(({ input }) => ({ message: `Hello, ${input.name}!` })) + +const router = { greet } + +function createHandler() { + return new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] }) +} + +/** + * Feed `lines` (already terminated with `\n` as needed) through the stdio + * handler and return the parsed JSON-RPC response lines, in order. + */ +async function drive(handler: MCPHandler>, payload: string): Promise { + const input = Readable.from([payload]) + const chunks: string[] = [] + const output = new Writable({ + write(chunk, _enc, cb) { + chunks.push(chunk.toString()) + cb() + }, + }) + + await handler.listen({ context: {}, input, output }) + + const joined = chunks.join('').trim() + if (joined.length === 0) { + return [] + } + return joined.split('\n').map(line => JSON.parse(line)) +} + +describe('mCPHandler (stdio)', () => { + it('responds to a single initialize line with exactly one response', async () => { + const line = `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })}\n` + const responses = await drive(createHandler(), line) + + expect(responses).toHaveLength(1) + expect(responses[0].jsonrpc).toBe('2.0') + expect(responses[0].id).toBe(1) + expect(typeof responses[0].result.protocolVersion).toBe('string') + }) + + it('processes multiple lines and emits responses in order', async () => { + const payload + = `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })}\n` + + `${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' })}\n` + const responses = await drive(createHandler(), payload) + + expect(responses).toHaveLength(2) + expect(responses.map(r => r.id)).toEqual([1, 2]) + expect(typeof responses[0].result.protocolVersion).toBe('string') + expect(Array.isArray(responses[1].result.tools)).toBe(true) + expect(responses[1].result.tools.map((t: any) => t.name)).toEqual(['greet']) + }) + + it('ignores blank lines without crashing or emitting extra output', async () => { + const payload + = `\n` + + ` \n` + + `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })}\n` + + `\n` + const responses = await drive(createHandler(), payload) + + expect(responses).toHaveLength(1) + expect(responses[0].id).toBe(1) + }) + + it('emits a parse error for an invalid JSON line', async () => { + const payload = `this is not json\n` + const responses = await drive(createHandler(), payload) + + expect(responses).toHaveLength(1) + expect(responses[0].jsonrpc).toBe('2.0') + expect(responses[0].id).toBe(null) + expect(responses[0].error.code).toBe(-32700) + expect(responses[0].error.message).toBe('Parse error') + }) + + it('rejects an over-limit line by its raw length, even when whitespace-padded', async () => { + const handler = new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()], maxMessageLength: 64 }) + // The message trims to well under 64 chars, but the padded raw line exceeds it. + const padded = `${' '.repeat(100)}${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })}\n` + const responses = await drive(handler, padded) + + expect(responses).toHaveLength(1) + expect(responses[0].id).toBe(null) + expect(responses[0].error.code).toBe(-32600) + expect(responses[0].error.message).toBe('Message too large') + }) + + it('recovers after an invalid JSON line and keeps processing valid lines', async () => { + const payload + = `not json at all\n` + + `${JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'initialize', params: {} })}\n` + const responses = await drive(createHandler(), payload) + + expect(responses).toHaveLength(2) + expect(responses[0].error.code).toBe(-32700) + expect(responses[1].id).toBe(7) + expect(typeof responses[1].result.protocolVersion).toBe('string') + }) +}) diff --git a/packages/mcp/src/adapters/stdio/mcp-handler.ts b/packages/mcp/src/adapters/stdio/mcp-handler.ts new file mode 100644 index 000000000..b3fc24239 --- /dev/null +++ b/packages/mcp/src/adapters/stdio/mcp-handler.ts @@ -0,0 +1,110 @@ +import type { JsonSchemaConverter } from '@orpc/json-schema' +import type { Context, Router } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { StandardBody, StandardLazyRequest } from '@standardserver/core' +import type { Readable, Writable } from 'node:stream' +import type { MCPHandlerPluginOptions } from '../standard/mcp-handler-plugin' +import process from 'node:process' +import { createInterface } from 'node:readline' +import { StandardHandler } from '@orpc/server/standard' +import { stringifyJSON, toArray } from '@orpc/shared' +import { INTERNAL_ERROR, INVALID_REQUEST, JSONRPC_VERSION } from '../../constants' +import { createMCPRegistryProvider } from '../../registry' +import { MCPHandlerCodec } from '../standard/mcp-handler-codec' +import { MCPHandlerPlugin } from '../standard/mcp-handler-plugin' + +const DEFAULT_MAX_MESSAGE_LENGTH = 4 * 1024 * 1024 // 4 MB + +export interface MCPHandlerOptions extends Omit, 'plugins'>, MCPHandlerPluginOptions { + /** Schema → JSON Schema converters (e.g. `new ZodToJsonSchemaConverter()`). */ + converters?: JsonSchemaConverter[] + plugins?: StandardHandlerPlugin[] + /** Reject a single stdio message longer than this (characters). @default 4_194_304 */ + maxMessageLength?: number +} + +export interface MCPHandlerListenOptions { + context: T + signal?: AbortSignal + /** Defaults to `process.stdin`. */ + input?: Readable + /** Defaults to `process.stdout`. */ + output?: Writable +} + +/** + * Serves an oRPC router as an MCP server over the stdio transport + * (newline-delimited JSON-RPC). Each line is dispatched through the same + * {@link StandardHandler} + {@link MCPHandlerPlugin} as the HTTP adapters via a + * synthesized request — one code path for every transport. + * + * @example + * ```ts + * await new MCPHandler(router, { converters: [new ZodToJsonSchemaConverter()] }) + * .listen({ context: {} }) + * ``` + */ +export class MCPHandler { + private readonly handler: StandardHandler + private readonly maxMessageLength: number + + constructor(router: Router, options: NoInfer> = {}) { + const registry = createMCPRegistryProvider(router, { converters: options.converters }) + const codec = new MCPHandlerCodec(registry) + this.handler = new StandardHandler(codec, { + ...options, + plugins: [new MCPHandlerPlugin(registry, options), ...toArray(options.plugins)], + }) + this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH + } + + async listen(options: MCPHandlerListenOptions): Promise { + const input = options.input ?? process.stdin + const output = options.output ?? process.stdout + const rl = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }) + + try { + for await (const line of rl) { + // Check the raw line length BEFORE trimming, so a whitespace-padded + // oversized message can't slip under the limit. + if (line.length > this.maxMessageLength) { + writeMessage(output, { jsonrpc: JSONRPC_VERSION, id: null, error: { code: INVALID_REQUEST, message: 'Message too large' } }) + continue + } + const trimmed = line.trim() + if (trimmed.length === 0) { + continue + } + + try { + const result = await this.handler.handle(synthesizeRequest(trimmed, options.signal), { context: options.context }) + if (result.matched && result.response.body !== undefined) { + writeMessage(output, result.response.body) + } + } + catch { + // An unexpected failure must not tear down the stdio reader; report it + // and keep processing subsequent messages. + writeMessage(output, { jsonrpc: JSONRPC_VERSION, id: null, error: { code: INTERNAL_ERROR, message: 'Internal error' } }) + } + } + } + finally { + rl.close() + } + } +} + +function synthesizeRequest(line: string, signal: AbortSignal | undefined): StandardLazyRequest { + return { + method: 'POST', + url: '/', + headers: { 'content-type': 'application/json' }, + signal, + resolveBody: async () => JSON.parse(line) as StandardBody, + } +} + +function writeMessage(output: Writable, message: unknown): void { + output.write(`${stringifyJSON(message) ?? ''}\n`) +} diff --git a/packages/mcp/src/constants.ts b/packages/mcp/src/constants.ts new file mode 100644 index 000000000..e9759ea9d --- /dev/null +++ b/packages/mcp/src/constants.ts @@ -0,0 +1,34 @@ +/** The MCP protocol revision this package targets by default. */ +export const LATEST_PROTOCOL_VERSION = '2025-11-25' + +/** Protocol revisions this server can negotiate, newest first. */ +export const SUPPORTED_PROTOCOL_VERSIONS = [ + '2025-11-25', + '2025-06-18', + '2025-03-26', + '2024-11-05', +] as const + +export const JSONRPC_VERSION = '2.0' + +/** Default server identity when none is provided to the handler. */ +export const DEFAULT_SERVER_NAME = 'orpc-mcp-server' +export const DEFAULT_SERVER_VERSION = '1.0.0' + +/** + * Default page size for catalog pagination (the `list` methods). Catalogs at or + * under this size return a single page (no `nextCursor`), so small servers are + * unaffected. + */ +export const DEFAULT_LIST_PAGE_SIZE = 100 + +// --- JSON-RPC 2.0 + MCP error codes --- +export const PARSE_ERROR = -32700 +export const INVALID_REQUEST = -32600 +export const METHOD_NOT_FOUND = -32601 +export const INVALID_PARAMS = -32602 +export const INTERNAL_ERROR = -32603 +/** MCP-specific: resource (or prompt) not found. */ +export const RESOURCE_NOT_FOUND = -32002 +/** Implementation-defined server error range (-32000 to -32099); used for rejected Origins. */ +export const FORBIDDEN_ERROR = -32000 diff --git a/packages/mcp/src/content.test.ts b/packages/mcp/src/content.test.ts new file mode 100644 index 000000000..2c1c6af13 --- /dev/null +++ b/packages/mcp/src/content.test.ts @@ -0,0 +1,78 @@ +import { encodePromptMessages, encodeResourceContents, encodeToolResult } from './content' + +describe('encodeToolResult', () => { + it('wraps a string in a single text content block without structuredContent', () => { + const result = encodeToolResult('hi', false) + expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }] }) + expect(result.structuredContent).toBeUndefined() + }) + + it('adds structuredContent for a plain object when an output schema is declared', () => { + const result = encodeToolResult({ a: 1 }, true) + expect(result.structuredContent).toEqual({ a: 1 }) + expect(result.content[0]!.type).toBe('text') + expect(result.content[0]).toMatchObject({ type: 'text', text: '{"a":1}' }) + }) + + it('omits structuredContent for a plain object when no output schema is declared', () => { + const result = encodeToolResult({ a: 1 }, false) + expect(result.structuredContent).toBeUndefined() + expect(result.content).toEqual([{ type: 'text', text: '{"a":1}' }]) + }) + + it('passes through an array of pre-formed content blocks unchanged', () => { + const result = encodeToolResult([{ type: 'text', text: 'x' }], false) + expect(result.content).toEqual([{ type: 'text', text: 'x' }]) + expect(result.structuredContent).toBeUndefined() + }) + + it('returns empty content for undefined output', () => { + const result = encodeToolResult(undefined, false) + expect(result.content).toEqual([]) + expect(result.structuredContent).toBeUndefined() + }) +}) + +describe('encodeResourceContents', () => { + it('encodes a string as a text/plain resource', () => { + const result = encodeResourceContents('txt', 'u://1') + expect(result).toEqual([{ uri: 'u://1', mimeType: 'text/plain', text: 'txt' }]) + }) + + it('encodes a plain object as an application/json resource', () => { + const result = encodeResourceContents({ a: 1 }, 'u://1') + expect(result).toHaveLength(1) + expect(result[0]!.uri).toBe('u://1') + expect(result[0]!.mimeType).toBe('application/json') + expect(JSON.parse(result[0]!.text!)).toEqual({ a: 1 }) + }) + + it('respects an explicit mimeType for a string', () => { + const result = encodeResourceContents('txt', 'u://1', 'text/markdown') + expect(result).toEqual([{ uri: 'u://1', mimeType: 'text/markdown', text: 'txt' }]) + }) +}) + +describe('encodePromptMessages', () => { + it('wraps a string in a single user message', () => { + const result = encodePromptMessages('hi') + expect(result).toEqual({ + messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], + }) + }) + + it('passes through a structured prompt result including its description', () => { + const result = encodePromptMessages({ + messages: [{ role: 'assistant', content: { type: 'text', text: 'y' } }], + description: 'd', + }) + expect(result).toEqual({ + description: 'd', + messages: [{ role: 'assistant', content: { type: 'text', text: 'y' } }], + }) + }) + + it('throws a TypeError for an unsupported output value', () => { + expect(() => encodePromptMessages(123)).toThrow(TypeError) + }) +}) diff --git a/packages/mcp/src/content.ts b/packages/mcp/src/content.ts new file mode 100644 index 000000000..af5445eb7 --- /dev/null +++ b/packages/mcp/src/content.ts @@ -0,0 +1,86 @@ +import type { CallToolResult, ContentBlock, GetPromptResult, PromptMessage, ResourceContents } from './types' +import { stringifyJSON } from '@orpc/shared' + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isContentBlock(value: unknown): value is ContentBlock { + return isPlainRecord(value) && typeof value.type === 'string' +} + +/** + * Encode a tool handler's return value into an MCP `tools/call` result. + * + * - Pre-formed content blocks (or arrays of them) pass through unchanged. + * - `string` → a single text block. + * - `undefined`/`null` → empty content. + * - plain objects → a JSON text block, plus `structuredContent` + * when the tool declares an `outputSchema`. + * - other values (numbers, arrays, …) → a JSON text block. + */ +export function encodeToolResult(output: unknown, hasOutputSchema: boolean): CallToolResult { + if (Array.isArray(output) && output.length > 0 && output.every(isContentBlock)) { + return { content: output } + } + + if (isContentBlock(output)) { + return { content: [output] } + } + + if (typeof output === 'string') { + return { content: [{ type: 'text', text: output }] } + } + + if (output === undefined || output === null) { + return { content: [] } + } + + const text = stringifyJSON(output) + const result: CallToolResult = { content: [{ type: 'text', text }] } + + if (hasOutputSchema && isPlainRecord(output)) { + result.structuredContent = output + } + + return result +} + +/** + * Encode a resource handler's return value into MCP `resources/read` contents. + * Strings are returned as text; everything else is JSON-serialized. + */ +export function encodeResourceContents(output: unknown, uri: string, mimeType?: string): ResourceContents[] { + if (isContentBlock(output) && output.type === 'resource' && isPlainRecord(output.resource)) { + return [output.resource as ResourceContents] + } + + if (typeof output === 'string') { + return [{ uri, mimeType: mimeType ?? 'text/plain', text: output }] + } + + return [{ uri, mimeType: mimeType ?? 'application/json', text: stringifyJSON(output) ?? 'null' }] +} + +/** + * Encode a prompt handler's return value into an MCP `prompts/get` result. + * + * - `string` → a single `user` message. + * - `{ messages, description? }` → passed through (the expected typed shape). + */ +export function encodePromptMessages(output: unknown): GetPromptResult { + if (typeof output === 'string') { + return { messages: [{ role: 'user', content: { type: 'text', text: output } }] } + } + + if (isPlainRecord(output) && Array.isArray(output.messages)) { + const messages = output.messages as PromptMessage[] + return typeof output.description === 'string' + ? { description: output.description, messages } + : { messages } + } + + throw new TypeError( + 'An MCP prompt handler must return a string or an object with a `messages` array', + ) +} diff --git a/packages/mcp/src/error.test.ts b/packages/mcp/src/error.test.ts new file mode 100644 index 000000000..d177010d0 --- /dev/null +++ b/packages/mcp/src/error.test.ts @@ -0,0 +1,90 @@ +import { ORPCError } from '@orpc/client' +import { INTERNAL_ERROR, INVALID_PARAMS, RESOURCE_NOT_FOUND } from './constants' +import { JSONRPCError, orpcErrorToJSONRPCError } from './error' + +describe('jSONRPCError', () => { + it('constructs with code, message and stores fields', () => { + const error = new JSONRPCError(-32601, 'x') + + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe('JSONRPCError') + expect(error.code).toBe(-32601) + expect(error.message).toBe('x') + expect(error.data).toBeUndefined() + }) + + it('toJSON() omits the data key when data is undefined', () => { + const json = new JSONRPCError(-32601, 'x').toJSON() + + expect(json).toEqual({ code: -32601, message: 'x' }) + expect('data' in json).toBe(false) + }) + + it('toJSON() includes data when provided', () => { + const json = new JSONRPCError(-32602, 'y', { a: 1 }).toJSON() + + expect(json).toEqual({ code: -32602, message: 'y', data: { a: 1 } }) + }) +}) + +describe('orpcErrorToJSONRPCError', () => { + it('maps NOT_FOUND to RESOURCE_NOT_FOUND', () => { + const result = orpcErrorToJSONRPCError(new ORPCError('NOT_FOUND')) + + expect(result).toBeInstanceOf(JSONRPCError) + expect(result.code).toBe(RESOURCE_NOT_FOUND) + expect(result.code).toBe(-32002) + }) + + it('maps BAD_REQUEST to INVALID_PARAMS', () => { + const result = orpcErrorToJSONRPCError(new ORPCError('BAD_REQUEST')) + + expect(result.code).toBe(INVALID_PARAMS) + expect(result.code).toBe(-32602) + }) + + it('maps INPUT_VALIDATION_FAILED to INVALID_PARAMS', () => { + const result = orpcErrorToJSONRPCError(new ORPCError('INPUT_VALIDATION_FAILED')) + + expect(result.code).toBe(INVALID_PARAMS) + }) + + it('maps an unknown code to INTERNAL_ERROR', () => { + const result = orpcErrorToJSONRPCError(new ORPCError('SOMETHING_ELSE')) + + expect(result.code).toBe(INTERNAL_ERROR) + expect(result.code).toBe(-32603) + }) + + it('returns the same instance when given an existing JSONRPCError', () => { + const existing = new JSONRPCError(-32700, 'parse error', { detail: true }) + const result = orpcErrorToJSONRPCError(existing) + + expect(result).toBe(existing) + }) + + it('carries the ORPCError message and a data payload of error.toJSON()', () => { + const error = new ORPCError('CUSTOM_FAILURE', { + message: 'something went wrong', + data: { foo: 'bar' }, + }) + const result = orpcErrorToJSONRPCError(error) + + expect(result.code).toBe(INTERNAL_ERROR) + expect(result.message).toBe('something went wrong') + expect(result.data).toEqual(error.toJSON()) + expect(result.data).toEqual({ + defined: false, + inferable: false, + code: 'CUSTOM_FAILURE', + message: 'something went wrong', + data: { foo: 'bar' }, + }) + }) + + it('derives the default message from the code when none is given', () => { + const result = orpcErrorToJSONRPCError(new ORPCError('NOT_FOUND')) + + expect(result.message).toBe('Not Found') + }) +}) diff --git a/packages/mcp/src/error.ts b/packages/mcp/src/error.ts new file mode 100644 index 000000000..badcbff7c --- /dev/null +++ b/packages/mcp/src/error.ts @@ -0,0 +1,46 @@ +import type { JSONRPCErrorObject } from './types' +import { toORPCError } from '@orpc/client' +import { INTERNAL_ERROR, INVALID_PARAMS, RESOURCE_NOT_FOUND } from './constants' + +/** + * A JSON-RPC protocol error. Thrown inside the dispatcher to produce a + * JSON-RPC error response (as opposed to an in-band tool error result). + */ +export class JSONRPCError extends Error { + readonly code: number + readonly data: unknown + + constructor(code: number, message: string, data?: unknown) { + super(message) + this.name = 'JSONRPCError' + this.code = code + this.data = data + } + + toJSON(): JSONRPCErrorObject { + return this.data !== undefined + ? { code: this.code, message: this.message, data: this.data } + : { code: this.code, message: this.message } + } +} + +/** + * Map an error thrown by a resource/prompt handler to a JSON-RPC protocol + * error (these planes don't have an in-band "isError" result like tools do). + */ +export function orpcErrorToJSONRPCError(error: unknown): JSONRPCError { + if (error instanceof JSONRPCError) { + return error + } + + const orpcError = toORPCError(error) + // `.code` is a runtime-preserved string; its static type is narrowed by toORPCError. + const code: string = orpcError.code + const jsonRpcCode = code === 'NOT_FOUND' + ? RESOURCE_NOT_FOUND + : code === 'BAD_REQUEST' || code === 'INPUT_VALIDATION_FAILED' + ? INVALID_PARAMS + : INTERNAL_ERROR + + return new JSONRPCError(jsonRpcCode, orpcError.message, orpcError.toJSON()) +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 000000000..d079fdfe0 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,7 @@ +export * from './constants' +export * from './content' +export * from './error' +export * from './meta' +export * from './registry' +export * from './types' +export * from './uri-template' diff --git a/packages/mcp/src/meta.test.ts b/packages/mcp/src/meta.test.ts new file mode 100644 index 000000000..b114b44ba --- /dev/null +++ b/packages/mcp/src/meta.test.ts @@ -0,0 +1,89 @@ +import { os } from '@orpc/server' +import * as z from 'zod' +import { getMCPMeta, getMCPPrimitiveType, mcp } from './meta' + +describe('mcp meta plugin', () => { + it('annotates a procedure as a tool via mcp.tool()', () => { + const procedure = os + .meta(mcp.tool()) + .input(z.object({})) + .handler(() => 'x') + + const meta = getMCPMeta(procedure) + expect(meta).toBeDefined() + expect(meta?.type).toBe('tool') + }) + + it('annotates a procedure as a resource via mcp.resource()', () => { + const procedure = os + .meta(mcp.resource({ uri: 'x://1' })) + .input(z.object({})) + .handler(() => 'x') + + const meta = getMCPMeta(procedure) + expect(meta).toBeDefined() + expect(meta?.type).toBe('resource') + expect(meta?.uri).toBe('x://1') + }) + + it('annotates a procedure as a prompt via mcp.prompt()', () => { + const procedure = os + .meta(mcp.prompt()) + .input(z.object({})) + .handler(() => 'x') + + const meta = getMCPMeta(procedure) + expect(meta).toBeDefined() + expect(meta?.type).toBe('prompt') + }) + + it('merges meta across multiple .meta() calls (annotations shallow-merge, scalars overwrite)', () => { + const procedure = os + .meta(mcp.tool({ description: 'a', annotations: { readOnlyHint: true } })) + .meta(mcp.tool({ annotations: { idempotentHint: true } })) + .input(z.object({})) + .handler(() => 'x') + + const meta = getMCPMeta(procedure) + expect(meta).toBeDefined() + expect(meta?.type).toBe('tool') + expect(meta?.description).toBe('a') + expect(meta?.annotations).toEqual({ readOnlyHint: true, idempotentHint: true }) + }) + + it('overwrites scalar fields when re-annotated', () => { + const procedure = os + .meta(mcp.tool({ description: 'first', title: 'T1' })) + .meta(mcp.tool({ description: 'second' })) + .input(z.object({})) + .handler(() => 'x') + + const meta = getMCPMeta(procedure) + expect(meta?.description).toBe('second') + expect(meta?.title).toBe('T1') + }) + + it('returns undefined for a procedure not opted into MCP', () => { + const procedure = os + .input(z.object({})) + .handler(() => 'x') + + expect(getMCPMeta(procedure)).toBeUndefined() + }) + + it('getMCPPrimitiveType defaults to tool', () => { + expect(getMCPPrimitiveType({})).toBe('tool') + }) + + it('getMCPPrimitiveType respects an explicit type', () => { + expect(getMCPPrimitiveType({ type: 'resource' })).toBe('resource') + expect(getMCPPrimitiveType({ type: 'prompt' })).toBe('prompt') + expect(getMCPPrimitiveType({ type: 'tool' })).toBe('tool') + }) + + it('exposes a plugin named ~mcp from each factory', () => { + expect(mcp.tool().name).toBe('~mcp') + expect(mcp.resource({ uri: 'x://1' }).name).toBe('~mcp') + expect(mcp.prompt().name).toBe('~mcp') + }) +}) diff --git a/packages/mcp/src/meta.ts b/packages/mcp/src/meta.ts new file mode 100644 index 000000000..5ad769f35 --- /dev/null +++ b/packages/mcp/src/meta.ts @@ -0,0 +1,164 @@ +import type { AnyProcedureContract, AnySchema, ErrorMap, Meta, MetaPlugin } from '@orpc/contract' +import type { Lazy } from '@orpc/server' + +/** + * Which MCP primitive a procedure is exposed as. + * + * @default 'tool' + */ +export type MCPPrimitiveType = 'tool' | 'resource' | 'prompt' + +/** + * MCP tool behavior hints. Purely advisory metadata for clients/agents. + * + * @see https://modelcontextprotocol.io/specification/2025-11-25/server/tools + */ +export interface MCPToolAnnotations { + /** If true, the tool does not modify its environment. */ + readOnlyHint?: boolean | undefined + /** If true, the tool may perform destructive updates (only meaningful when not read-only). */ + destructiveHint?: boolean | undefined + /** If true, repeated calls with the same arguments have no additional effect. */ + idempotentHint?: boolean | undefined + /** If true, the tool may interact with an open/unbounded set of external entities. */ + openWorldHint?: boolean | undefined +} + +/** + * Metadata attached to a procedure via {@link mcp} so it is exposed over MCP. + * + * The shape is intentionally flat (mirroring `OpenAPIMeta`); fields that don't + * apply to the chosen {@link MCPPrimitiveType} are ignored by the handler. + */ +export interface MCPMeta { + /** + * Which MCP primitive this procedure maps to. + * + * @default 'tool' + */ + type?: MCPPrimitiveType | undefined + + /** + * Unique name within the server for this tool/resource/prompt. + * + * @default Router segments joined by `'_'` (kept within MCP's `^[a-zA-Z0-9_-]{1,128}$`). + */ + name?: string | undefined + + /** Human-readable display name shown to users. */ + title?: string | undefined + + /** Detailed description used by the model to decide when/how to use this. */ + description?: string | undefined + + /** Tool-only: behavior hints. Merged when defined multiple times. */ + annotations?: MCPToolAnnotations | undefined + + /** + * Tool-only: whether to emit an MCP `outputSchema` from the procedure's + * `.output()` schema (enables `structuredContent`). + * + * @default true when an output schema is defined + */ + outputSchema?: boolean | undefined + + /** Resource-only: MIME type of the resource contents. */ + mimeType?: string | undefined + + /** Resource-only: fixed URI of a static resource (e.g. `config://app`). */ + uri?: `${string}://${string}` | undefined + + /** Resource-only: RFC 6570-style URI template (e.g. `planet://{id}`); vars map to input. */ + uriTemplate?: string | undefined +} + +export interface MCPMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~mcp' +} + +/** Author-facing meta for {@link mcp.tool}. */ +export type MCPToolMetaInput = Omit + +/** Author-facing meta for {@link mcp.prompt}. */ +export type MCPPromptMetaInput = Omit + +/** Author-facing meta for {@link mcp.resource} — requires exactly one of `uri` / `uriTemplate`. */ +export type MCPResourceMetaInput + = & Omit + & ( + | { uri: `${string}://${string}`, uriTemplate?: never } + | { uriTemplate: string, uri?: never } + ) + +export interface MCPNamespace { + /** Expose a procedure as an MCP tool (the most common primitive). */ + tool: (meta?: MCPToolMetaInput) => MCPMetaPlugin + /** Expose a (read-only) procedure as an MCP resource. Requires `uri` or `uriTemplate`. */ + resource: (meta: MCPResourceMetaInput) => MCPMetaPlugin + /** Expose a procedure as an MCP prompt. Arguments are derived from `.input()`. */ + prompt: (meta?: MCPPromptMetaInput) => MCPMetaPlugin +} + +function createMCPMetaPlugin(incoming: MCPMeta): MCPMetaPlugin { + return { + name: '~mcp', + init(meta: Meta): Meta { + const existing = meta['~mcp'] as MCPMeta | undefined + + const annotations = existing?.annotations && incoming.annotations + ? { ...existing.annotations, ...incoming.annotations } + : 'annotations' in incoming ? incoming.annotations : existing?.annotations + + const merged: MCPMeta = { + ...existing, + ...incoming, + ...(annotations !== undefined ? { annotations } : {}), + } + + return { + ...meta, + '~mcp': merged, + } + }, + } +} + +/** + * Expose a procedure over MCP (opt-in). Use one of `mcp.tool` / `mcp.resource` / + * `mcp.prompt` — there is no bare `mcp()` form. + * + * Each writes a single `~mcp` meta key (independent of any `openapi()` meta on the + * same procedure); calling more than once merges (annotations shallow-merge, + * other fields overwrite). + * + * @example + * ```ts + * const createPlanet = os + * .meta(mcp.tool({ description: 'Create a planet' })) + * .input(CreatingPlanetSchema) + * .output(PlanetSchema) + * .handler(...) + * ``` + */ +export const mcp: MCPNamespace = { + tool: (meta = {}) => createMCPMetaPlugin({ ...meta, type: 'tool' }), + resource: meta => createMCPMetaPlugin({ ...meta, type: 'resource' }), + prompt: (meta = {}) => createMCPMetaPlugin({ ...meta, type: 'prompt' }), +} + +/** + * Read the MCP meta a procedure (or lazy router) was annotated with, if any. + * Returns `undefined` when the procedure is not opted into MCP. + */ +export function getMCPMeta(procedureOrLazy: AnyProcedureContract | Lazy): MCPMeta | undefined { + return procedureOrLazy['~orpc'].meta['~mcp'] as MCPMeta | undefined +} + +/** Resolve the effective primitive type for an MCP meta (defaults to `'tool'`). */ +export function getMCPPrimitiveType(meta: MCPMeta): MCPPrimitiveType { + return meta.type ?? 'tool' +} diff --git a/packages/mcp/src/registry.test.ts b/packages/mcp/src/registry.test.ts new file mode 100644 index 000000000..97fd722dc --- /dev/null +++ b/packages/mcp/src/registry.test.ts @@ -0,0 +1,150 @@ +import { os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import * as z from 'zod' +import { mcp } from './meta' +import { buildMCPRegistry } from './registry' + +// --- procedures --- + +const greet = os + .meta(mcp.tool({ title: 'Greet', description: 'Greet a person' })) + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })) + .handler(({ input }) => ({ message: `Hello, ${input.name}!` })) + +// nested tool with NO explicit name -> default name from path ('planet_list') +const listPlanets = os + .meta(mcp.tool({ description: 'List planets' })) + .input(z.object({})) + .handler(() => []) + +// static resource (fixed uri) +const config = os + .meta(mcp.resource({ uri: 'config://app', mimeType: 'text/plain' })) + .output(z.string()) + .handler(() => 'debug=true') + +// templated resource (uriTemplate) +const planetResource = 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 derived from input) +const planTrip = os + .meta(mcp.prompt({ description: 'Plan a trip' })) + .input(z.object({ destination: z.string(), days: z.number(), note: z.string().optional() })) + .handler(() => ({ messages: [] })) + +// NOT opted into MCP -> must be excluded entirely +const secret = os.input(z.object({})).handler(() => 'secret') + +const router = { + greet, + config, + getPlanet: planetResource, + planTrip, + secret, + planet: { list: listPlanets }, +} + +const converters = [new ZodToJsonSchemaConverter()] + +describe('buildMCPRegistry', () => { + let registry: Awaited> + + beforeAll(async () => { + registry = await buildMCPRegistry(router, { converters }) + }) + + it('keys tools by name and excludes non-mcp procedures everywhere', () => { + expect(registry.tools.has('greet')).toBe(true) + expect(registry.tools.get('greet')!.definition.name).toBe('greet') + + // exactly the two MCP tools: greet + the nested planet_list + expect([...registry.tools.keys()].sort()).toEqual(['greet', 'planet_list']) + + // 'secret' (no mcp meta) appears in no collection + const allNames = [ + ...[...registry.tools.values()].map(e => e.definition.name), + ...[...registry.resources.values()].map(e => e.definition.name), + ...registry.resourceTemplates.map(e => e.definition.name), + ...[...registry.prompts.values()].map(e => e.definition.name), + ] + expect(allNames).not.toContain('secret') + expect(allNames.sort()).toEqual(['config', 'getPlanet', 'greet', 'planTrip', 'planet_list']) + }) + + it('assigns a nested tool the default name from its path joined by "_"', () => { + expect(registry.tools.has('planet_list')).toBe(true) + expect(registry.tools.get('planet_list')!.definition.name).toBe('planet_list') + }) + + it('produces an object inputSchema and an outputSchema only when .output is defined', () => { + const greetDef = registry.tools.get('greet')!.definition + expect(greetDef.inputSchema.type).toBe('object') + expect(greetDef.inputSchema.properties).toHaveProperty('name') + + // greet has .output -> outputSchema present and is an object schema + expect(greetDef.outputSchema).toBeDefined() + expect(greetDef.outputSchema!.type).toBe('object') + expect(greetDef.title).toBe('Greet') + expect(greetDef.description).toBe('Greet a person') + + // planet_list has no .output -> no outputSchema, but still an object inputSchema + const listDef = registry.tools.get('planet_list')!.definition + expect(listDef.inputSchema.type).toBe('object') + expect(listDef.outputSchema).toBeUndefined() + }) + + it('stores static resources in a Map keyed by their fixed uri', () => { + expect(registry.resources).toBeInstanceOf(Map) + expect([...registry.resources.keys()]).toEqual(['config://app']) + + const entry = registry.resources.get('config://app')! + expect(entry.definition.uri).toBe('config://app') + expect(entry.definition.name).toBe('config') + expect(entry.definition.mimeType).toBe('text/plain') + }) + + it('stores templated resources in an array whose compiled template matches concrete uris', () => { + expect(Array.isArray(registry.resourceTemplates)).toBe(true) + expect(registry.resourceTemplates).toHaveLength(1) + + const entry = registry.resourceTemplates[0]! + expect(entry.definition.uriTemplate).toBe('planet://{id}') + expect(entry.definition.name).toBe('getPlanet') + expect(entry.definition.mimeType).toBe('application/json') + + expect(entry.template.variables).toEqual(['id']) + expect(entry.template.match('planet://mars')).toEqual({ id: 'mars' }) + // non-matching uri -> undefined + expect(entry.template.match('config://app')).toBeUndefined() + }) + + it('derives prompt arguments from input fields with correct required flags', () => { + expect(registry.prompts.has('planTrip')).toBe(true) + const promptDef = registry.prompts.get('planTrip')!.definition + expect(promptDef.description).toBe('Plan a trip') + + expect(promptDef.arguments).toHaveLength(3) + expect(promptDef.arguments).toEqual(expect.arrayContaining([ + { name: 'destination', required: true }, + { name: 'days', required: true }, + { name: 'note', required: false }, + ])) + }) + + it('rejects a resource that defines neither uri nor uriTemplate', async () => { + const broken = os + // typed API requires uri|uriTemplate; cast to exercise the runtime guard + .meta((mcp.resource as (meta: any) => any)({})) + .output(z.string()) + .handler(() => 'x') + + await expect( + buildMCPRegistry({ broken }, { converters }), + ).rejects.toThrow(/must define a "uri" or "uriTemplate"/) + }) +}) diff --git a/packages/mcp/src/registry.ts b/packages/mcp/src/registry.ts new file mode 100644 index 000000000..4ceb695ce --- /dev/null +++ b/packages/mcp/src/registry.ts @@ -0,0 +1,290 @@ +import type { JsonSchema, JsonSchemaConverter } from '@orpc/json-schema' +import type { AnyProcedure, AnyRouter } from '@orpc/server' +import type { MCPMeta } from './meta' +import type { + JsonSchemaObject, + PromptArgument, + PromptDefinition, + ResourceDefinition, + ResourceTemplateDefinition, + ToolDefinition, +} from './types' +import type { CompiledUriTemplate } from './uri-template' +import { DelegatingJsonSchemaConverter, StandardJsonSchemaConverter } from '@orpc/json-schema' +import { walkProcedureContractsAsync } from '@orpc/server' +import { toArray } from '@orpc/shared' +import { getMCPMeta, getMCPPrimitiveType } from './meta' +import { compileUriTemplate } from './uri-template' + +export interface ToolEntry { + definition: ToolDefinition + procedure: AnyProcedure + meta: MCPMeta +} + +export interface ResourceEntry { + definition: ResourceDefinition + procedure: AnyProcedure + meta: MCPMeta +} + +export interface ResourceTemplateEntry { + definition: ResourceTemplateDefinition + template: CompiledUriTemplate + procedure: AnyProcedure + meta: MCPMeta +} + +export interface PromptEntry { + definition: PromptDefinition + procedure: AnyProcedure + meta: MCPMeta +} + +export interface MCPRegistry { + tools: Map + /** Static resources keyed by their fixed URI. */ + resources: Map + /** Templated resources (matched in order). */ + resourceTemplates: ResourceTemplateEntry[] + prompts: Map +} + +export interface BuildMCPRegistryOptions { + /** Schema → JSON Schema converters (e.g. `new ZodToJsonSchemaConverter()`). */ + converters?: JsonSchemaConverter[] +} + +/** + * Walk a router and collect every procedure opted into MCP (via `mcp()` meta), + * pre-computing its tool / resource / prompt definition. Resolves lazy routers. + */ +export async function buildMCPRegistry( + router: AnyRouter, + options: BuildMCPRegistryOptions = {}, +): Promise { + const converter = new DelegatingJsonSchemaConverter([ + ...toArray(options.converters), + new StandardJsonSchemaConverter(), + ]) + + const registry: MCPRegistry = { + tools: new Map(), + resources: new Map(), + resourceTemplates: [], + prompts: new Map(), + } + + await walkProcedureContractsAsync(router, async (contract, path) => { + const meta = getMCPMeta(contract) + if (meta === undefined) { + return + } + + const procedure = contract as AnyProcedure + const def = procedure['~orpc'] + const name = meta.name ?? defaultName(path) + const type = getMCPPrimitiveType(meta) + + if (type === 'tool') { + const definition: ToolDefinition = { + name, + inputSchema: await toInputObjectSchema(converter, def.inputSchemas), + } + if (meta.title !== undefined) { + definition.title = meta.title + } + if (meta.description !== undefined) { + definition.description = meta.description + } + + const wantsOutput = meta.outputSchema ?? true + if (wantsOutput && toArray(def.outputSchemas).length > 0) { + const [outputSchema] = await convertSchemas(converter, def.outputSchemas, 'output') + const objectSchema = asObjectJsonSchema(outputSchema) + if (objectSchema !== undefined) { + definition.outputSchema = objectSchema + } + } + if (meta.annotations !== undefined) { + definition.annotations = { ...meta.annotations } + } + + if (registry.tools.has(name)) { + throw new Error(`Duplicate MCP tool name "${name}" (from ${path.join('.')}). Names must be unique — set a distinct \`name\` in mcp.tool().`) + } + registry.tools.set(name, { definition, procedure, meta }) + } + else if (type === 'resource') { + if (meta.uriTemplate !== undefined) { + const definition: ResourceTemplateDefinition = { uriTemplate: meta.uriTemplate, name } + applyResourceMeta(definition, meta) + if (registry.resourceTemplates.some(entry => entry.definition.uriTemplate === meta.uriTemplate)) { + throw new Error(`Duplicate MCP resource template "${meta.uriTemplate}" (from ${path.join('.')}). Resource templates must be unique.`) + } + registry.resourceTemplates.push({ + definition, + template: compileUriTemplate(meta.uriTemplate), + procedure, + meta, + }) + } + else if (meta.uri !== undefined) { + const definition: ResourceDefinition = { uri: meta.uri, name } + applyResourceMeta(definition, meta) + if (registry.resources.has(meta.uri)) { + throw new Error(`Duplicate MCP resource URI "${meta.uri}" (from ${path.join('.')}). Resource URIs must be unique.`) + } + registry.resources.set(meta.uri, { definition, procedure, meta }) + } + else { + throw new Error(`MCP resource "${name}" must define a "uri" or "uriTemplate".`) + } + } + else { + const definition: PromptDefinition = { name } + if (meta.title !== undefined) { + definition.title = meta.title + } + if (meta.description !== undefined) { + definition.description = meta.description + } + const args = await toPromptArguments(converter, def.inputSchemas) + if (args.length > 0) { + definition.arguments = args + } + if (registry.prompts.has(name)) { + throw new Error(`Duplicate MCP prompt name "${name}" (from ${path.join('.')}). Names must be unique — set a distinct \`name\` in mcp.prompt().`) + } + registry.prompts.set(name, { definition, procedure, meta }) + } + }) + + return registry +} + +/** A lazily-built, memoized MCP registry shared between the codec and the plugin. */ +export interface MCPRegistryProvider { + get: () => Promise +} + +export function createMCPRegistryProvider( + router: AnyRouter, + options: BuildMCPRegistryOptions = {}, +): MCPRegistryProvider { + let promise: Promise | undefined + return { + get: () => (promise ??= buildMCPRegistry(router, options)), + } +} + +function defaultName(path: string[]): string { + const joined = path.join('_').replace(/[^\w-]/g, '_') + return joined.length > 0 ? joined.slice(0, 128) : 'unnamed' +} + +function applyResourceMeta( + definition: { title?: string, description?: string, mimeType?: string }, + meta: MCPMeta, +): void { + if (meta.title !== undefined) { + definition.title = meta.title + } + if (meta.description !== undefined) { + definition.description = meta.description + } + if (meta.mimeType !== undefined) { + definition.mimeType = meta.mimeType + } +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isObjectJsonSchema(schema: unknown): schema is JsonSchemaObject { + return isPlainRecord(schema) && schema.type === 'object' +} + +/** + * Coerce a converted schema to a single object JSON Schema. Handles the `allOf` + * wrapper {@link convertSchemas} produces when a procedure has multiple input or + * output schemas, by merging the object members' properties/required. + */ +function asObjectJsonSchema(schema: unknown): JsonSchemaObject | undefined { + if (isObjectJsonSchema(schema)) { + return schema + } + if (!isPlainRecord(schema) || !Array.isArray(schema.allOf)) { + return undefined + } + const members = schema.allOf.filter(isObjectJsonSchema) + if (members.length === 0) { + return undefined + } + const properties: Record = {} + const required = new Set() + for (const member of members) { + if (isPlainRecord(member.properties)) { + Object.assign(properties, member.properties) + } + if (Array.isArray(member.required)) { + for (const key of member.required) { + if (typeof key === 'string') { + required.add(key) + } + } + } + } + const merged = { type: 'object', properties } as JsonSchemaObject + if (required.size > 0) { + merged.required = [...required] + } + return merged +} + +async function convertSchemas( + converter: Pick, + schemas: unknown, + direction: 'input' | 'output', +): Promise<[JsonSchema, boolean]> { + const list = toArray(schemas as undefined) as unknown[] + if (list.length <= 1) { + return converter.convert(list[0] as never, direction) + } + + const results = await Promise.all(list.map(schema => converter.convert(schema as never, direction))) + const allOf = results.map(([schema]) => schema) + const optional = results.every(([, isOptional]) => isOptional) + return [{ allOf } as JsonSchema, optional] +} + +async function toInputObjectSchema( + converter: Pick, + schemas: unknown, +): Promise { + const [schema] = await convertSchemas(converter, schemas, 'input') + // MCP requires inputSchema to be a JSON Schema object; fall back to an empty + // object schema when the procedure has no (object) input. + return asObjectJsonSchema(schema) ?? { type: 'object' } +} + +async function toPromptArguments( + converter: Pick, + schemas: unknown, +): Promise { + const [schema] = await convertSchemas(converter, schemas, 'input') + const object = asObjectJsonSchema(schema) + if (object === undefined || !isPlainRecord(object.properties)) { + return [] + } + + const required = new Set(Array.isArray(object.required) ? object.required : []) + return Object.entries(object.properties).map(([argName, prop]) => { + const argument: PromptArgument = { name: argName, required: required.has(argName) } + if (isPlainRecord(prop) && typeof prop.description === 'string') { + argument.description = prop.description + } + return argument + }) +} diff --git a/packages/mcp/src/sdk-compliance.test.ts b/packages/mcp/src/sdk-compliance.test.ts new file mode 100644 index 000000000..6abd1899c --- /dev/null +++ b/packages/mcp/src/sdk-compliance.test.ts @@ -0,0 +1,159 @@ +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { createServer } from 'node:http' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import * as z from 'zod' +import { MCPHandler } from './adapters/node/mcp-handler' +import { mcp } from './meta' + +// A router exercising all three primitives + a typed error, served to the +// OFFICIAL @modelcontextprotocol/sdk client over real HTTP. If the canonical +// client can complete the handshake and drive every primitive, the server is +// genuinely protocol-compliant — not just self-consistent. + +const greet = os + .meta(mcp.tool({ title: 'Greet', description: 'Greet a person' })) + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })) + .handler(({ input }) => ({ message: `Hello, ${input.name}!` })) + +const failing = os + .meta(mcp.tool({ description: 'always fails' })) + .input(z.object({})) + .errors({ FORBIDDEN: { message: 'nope' } }) + .handler(({ errors }) => { + throw errors.FORBIDDEN() + }) + +// Regression: a tool that DECLARES an output schema and then errors. The SDK +// validates `structuredContent` against the outputSchema, so the in-band error +// result must omit it — otherwise the client rejects with -32602. +const failingTyped = os + .meta(mcp.tool({ description: 'errors but declares an output schema' })) + .input(z.object({})) + .output(z.object({ ok: z.boolean() })) + .errors({ CONFLICT: { message: 'boom' } }) + .handler(({ errors }) => { + throw errors.CONFLICT() + }) + +const config = os + .meta(mcp.resource({ uri: 'config://app', mimeType: 'text/plain' })) + .output(z.string()) + .handler(() => 'debug=true') + +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}` })) + +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' as const, content: { type: 'text' as const, text: `Plan a trip to ${input.destination}` } }], + })) + +const router = { greet, failing, failingTyped, config, planet, planTrip } + +describe('official MCP SDK client <-> @orpc/mcp node handler (e2e over HTTP)', () => { + let server: Server + let client: Client + + beforeAll(async () => { + const handler = new MCPHandler(router, { + serverInfo: { name: 'orpc-mcp-e2e', version: '1.0.0' }, + converters: [new ZodToJsonSchemaConverter()], + }) + + server = createServer((req, res) => { + void handler.handle(req, res, { context: {} }) + }) + await new Promise(resolve => server.listen(0, resolve)) + const { port } = server.address() as AddressInfo + + client = new Client({ name: 'e2e-test-client', version: '1.0.0' }) + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`))) + }) + + afterAll(async () => { + await client?.close() + await new Promise((resolve) => { + if (!server) { + resolve() + return + } + server.close(() => resolve()) + }) + }) + + it('completes the initialize handshake and exposes capabilities', () => { + const capabilities = client.getServerCapabilities() + expect(capabilities?.tools).toBeDefined() + expect(capabilities?.resources).toBeDefined() + expect(capabilities?.prompts).toBeDefined() + expect(client.getServerVersion()).toMatchObject({ name: 'orpc-mcp-e2e', version: '1.0.0' }) + }) + + it('lists tools with JSON Schema input', async () => { + const { tools } = await client.listTools() + expect(tools.map(t => t.name)).toContain('greet') + const greetTool = tools.find(t => t.name === 'greet')! + expect(greetTool.description).toBe('Greet a person') + expect(greetTool.inputSchema.type).toBe('object') + expect(greetTool.inputSchema.properties).toHaveProperty('name') + }) + + it('calls a tool and receives content + structuredContent', async () => { + const result = await client.callTool({ name: 'greet', arguments: { name: 'World' } }) + expect(result.isError).toBeFalsy() + expect((result.content as Array<{ type: string, text: string }>)[0]!.text).toContain('Hello, World!') + expect(result.structuredContent).toEqual({ message: 'Hello, World!' }) + }) + + it('surfaces a thrown typed error as an in-band tool error', async () => { + const result = await client.callTool({ name: 'failing', arguments: {} }) + expect(result.isError).toBe(true) + expect((result.content as Array<{ type: string, text: string }>)[0]!.text).toBe('nope') + }) + + it('errors a schema-typed tool without violating its outputSchema (regression)', async () => { + const result = await client.callTool({ name: 'failingTyped', arguments: {} }) + expect(result.isError).toBe(true) + expect((result.content as Array<{ type: string, text: string }>)[0]!.text).toBe('boom') + }) + + it('lists and reads a static resource', async () => { + const { resources } = await client.listResources() + expect(resources.map(r => r.uri)).toContain('config://app') + + const read = await client.readResource({ uri: 'config://app' }) + expect((read.contents[0] as { text: string }).text).toBe('debug=true') + }) + + it('lists and reads a templated resource', async () => { + const { resourceTemplates } = await client.listResourceTemplates() + expect(resourceTemplates.map(r => r.uriTemplate)).toContain('planet://{id}') + + const read = await client.readResource({ uri: 'planet://mars' }) + expect(JSON.parse((read.contents[0] as { text: string }).text)).toEqual({ id: 'mars', name: 'Planet mars' }) + }) + + it('lists and gets a prompt', async () => { + const { prompts } = await client.listPrompts() + expect(prompts.map(p => p.name)).toContain('planTrip') + + const got = await client.getPrompt({ name: 'planTrip', arguments: { destination: 'Tokyo' } }) + expect((got.messages[0]!.content as { type: string, text: string }).text).toContain('Tokyo') + }) +}) diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts new file mode 100644 index 000000000..c4cfcc4fa --- /dev/null +++ b/packages/mcp/src/types.ts @@ -0,0 +1,159 @@ +/** + * Minimal MCP + JSON-RPC 2.0 wire types used by the handler. + * + * These describe the subset of the MCP schema this package produces/consumes. + * They are intentionally permissive (open records) so passthrough payloads from + * procedure handlers are not rejected. + */ + +// --- JSON-RPC 2.0 --- + +export type JSONRPCId = string | number + +export interface JSONRPCRequest { + jsonrpc: '2.0' + id: JSONRPCId + method: string + params?: Record | undefined +} + +export interface JSONRPCNotification { + jsonrpc: '2.0' + method: string + params?: Record | undefined +} + +export type JSONRPCIncoming = JSONRPCRequest | JSONRPCNotification + +export interface JSONRPCErrorObject { + code: number + message: string + data?: unknown +} + +export interface JSONRPCSuccessResponse { + jsonrpc: '2.0' + id: JSONRPCId + result: unknown +} + +export interface JSONRPCErrorResponse { + jsonrpc: '2.0' + id: JSONRPCId | null + error: JSONRPCErrorObject +} + +export type JSONRPCResponse = JSONRPCSuccessResponse | JSONRPCErrorResponse + +// --- MCP content --- + +export interface TextContent { type: 'text', text: string, [k: string]: unknown } +export interface ImageContent { type: 'image', data: string, mimeType: string, [k: string]: unknown } +export interface AudioContent { type: 'audio', data: string, mimeType: string, [k: string]: unknown } +export interface EmbeddedResourceContent { type: 'resource', resource: ResourceContents, [k: string]: unknown } +export interface ResourceLinkContent { type: 'resource_link', uri: string, [k: string]: unknown } + +export type ContentBlock + = | TextContent + | ImageContent + | AudioContent + | EmbeddedResourceContent + | ResourceLinkContent + +export interface ResourceContents { + uri: string + mimeType?: string + text?: string + blob?: string + [k: string]: unknown +} + +// --- definitions (what `*/list` returns) --- + +export interface JsonSchemaObject { + type: 'object' + properties?: Record + required?: string[] + [k: string]: unknown +} + +export interface ToolDefinition { + name: string + title?: string + description?: string + inputSchema: JsonSchemaObject + outputSchema?: JsonSchemaObject + annotations?: Record +} + +export interface ResourceDefinition { + uri: string + name: string + title?: string + description?: string + mimeType?: string +} + +export interface ResourceTemplateDefinition { + uriTemplate: string + name: string + title?: string + description?: string + mimeType?: string +} + +export interface PromptArgument { + name: string + description?: string + required?: boolean +} + +export interface PromptDefinition { + name: string + title?: string + description?: string + arguments?: PromptArgument[] +} + +// --- results --- + +export interface CallToolResult { + content: ContentBlock[] + structuredContent?: Record + isError?: boolean +} + +export type PromptMessageRole = 'user' | 'assistant' + +export interface PromptMessage { + role: PromptMessageRole + content: ContentBlock +} + +export interface GetPromptResult { + description?: string + messages: PromptMessage[] +} + +// --- lifecycle --- + +export interface Implementation { + name: string + title?: string + version: string +} + +export interface ServerCapabilities { + tools?: { listChanged?: boolean } + resources?: { subscribe?: boolean, listChanged?: boolean } + prompts?: { listChanged?: boolean } + completions?: Record + logging?: Record +} + +export interface InitializeResult { + protocolVersion: string + capabilities: ServerCapabilities + serverInfo: Implementation + instructions?: string +} diff --git a/packages/mcp/src/uri-template.test.ts b/packages/mcp/src/uri-template.test.ts new file mode 100644 index 000000000..212425b3c --- /dev/null +++ b/packages/mcp/src/uri-template.test.ts @@ -0,0 +1,36 @@ +import { compileUriTemplate } from './uri-template' + +describe('compileUriTemplate', () => { + it('extracts a single variable and matches a concrete URI', () => { + const t = compileUriTemplate('planet://{id}') + expect(t.template).toBe('planet://{id}') + expect(t.variables).toEqual(['id']) + expect(t.match('planet://earth')).toEqual({ id: 'earth' }) + expect(t.match('other://x')).toBeUndefined() + }) + + it('extracts multiple variables across segments', () => { + const t = compileUriTemplate('a://{x}/{y}') + expect(t.variables).toEqual(['x', 'y']) + expect(t.match('a://1/2')).toEqual({ x: '1', y: '2' }) + expect(t.match('a://1')).toBeUndefined() + }) + + it('matches a static URI exactly with no variables', () => { + const t = compileUriTemplate('config://app') + expect(t.variables).toEqual([]) + expect(t.match('config://app')).toEqual({}) + expect(t.match('config://other')).toBeUndefined() + }) + + it('uRL-decodes extracted variable values', () => { + const t = compileUriTemplate('doc://{name}') + expect(t.match('doc://a%20b')).toEqual({ name: 'a b' }) + }) + + it('matches a single segment only (a variable does not span "/")', () => { + const t = compileUriTemplate('p://{id}') + expect(t.match('p://a/b')).toBeUndefined() + expect(t.match('p://a')).toEqual({ id: 'a' }) + }) +}) diff --git a/packages/mcp/src/uri-template.ts b/packages/mcp/src/uri-template.ts new file mode 100644 index 000000000..8779dc76d --- /dev/null +++ b/packages/mcp/src/uri-template.ts @@ -0,0 +1,62 @@ +/** + * Minimal URI-template support for MCP resources. + * + * Handles RFC 6570 level-1 simple string expansion (`{var}`), which is what + * resource templates like `planet://{id}` need: each `{var}` matches a single + * path segment (no `/`). Static URIs (no `{}`) are matched exactly. + */ + +import { tryDecodeURIComponent } from '@orpc/shared' + +export interface CompiledUriTemplate { + /** The original template string. */ + template: string + /** Variable names, in order of appearance. */ + variables: string[] + /** + * Match a concrete URI against the template. + * Returns the extracted, URI-decoded variables, or `undefined` on no match. + */ + match: (uri: string) => Record | undefined +} + +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function compileUriTemplate(template: string): CompiledUriTemplate { + const variables: string[] = [] + const placeholder = /\{(\w+)\}/g + + let pattern = '' + let lastIndex = 0 + let match: RegExpExecArray | null + + // eslint-disable-next-line no-cond-assign + while ((match = placeholder.exec(template)) !== null) { + pattern += escapeRegExp(template.slice(lastIndex, match.index)) + variables.push(match[1]!) + pattern += '([^/]+)' + lastIndex = match.index + match[0].length + } + pattern += escapeRegExp(template.slice(lastIndex)) + + const regex = new RegExp(`^${pattern}$`) + + return { + template, + variables, + match(uri) { + const result = regex.exec(uri) + if (!result) { + return undefined + } + + const extracted: Record = {} + variables.forEach((name, index) => { + extracted[name] = tryDecodeURIComponent(result[index + 1]!) + }) + return extracted + }, + } +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 000000000..fc646838a --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "types": ["node"] + }, + "references": [ + { "path": "../client" }, + { "path": "../contract" }, + { "path": "../json-schema" }, + { "path": "../server" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.bench.*", + "**/*.test.*", + "**/*.test-d.ts", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/playgrounds/next/package.json b/playgrounds/next/package.json index 741c14baa..218e85d0d 100644 --- a/playgrounds/next/package.json +++ b/playgrounds/next/package.json @@ -19,6 +19,7 @@ "@orpc/client": "beta", "@orpc/evlog": "beta", "@orpc/json-schema": "beta", + "@orpc/mcp": "workspace:*", "@orpc/next": "beta", "@orpc/openapi": "beta", "@orpc/opentelemetry": "beta", diff --git a/playgrounds/next/src/app/mcp/[[...rest]]/route.ts b/playgrounds/next/src/app/mcp/[[...rest]]/route.ts new file mode 100644 index 000000000..24d98eb24 --- /dev/null +++ b/playgrounds/next/src/app/mcp/[[...rest]]/route.ts @@ -0,0 +1,21 @@ +import { router } from '@/routers' +import { messagePublisher } from '@/context' +import { MCPHandler } from '@orpc/mcp/fetch' +import { ZodToJsonSchemaConverter } from '@orpc/zod' + +const handler = new MCPHandler(router, { + serverInfo: { name: 'orpc-playground', version: '1.0.0' }, + converters: [new ZodToJsonSchemaConverter()], +}) + +async function handleRequest(request: Request) { + const { response } = await handler.handle(request, { + context: { messagePublisher }, + }) + + return response ?? new Response('Not found', { status: 404 }) +} + +export const GET = handleRequest +export const POST = handleRequest +export const DELETE = handleRequest diff --git a/playgrounds/next/src/routers/planet.ts b/playgrounds/next/src/routers/planet.ts index 51f70306e..b904e3976 100644 --- a/playgrounds/next/src/routers/planet.ts +++ b/playgrounds/next/src/routers/planet.ts @@ -2,6 +2,7 @@ import { protectedOS, publicOS } from '@/orpc' import { CreatingPlanetSchema, PlanetSchema } from '@/schemas/planet' import type { Planet } from '@/schemas/planet' import z from 'zod' +import { mcp } from '@orpc/mcp' import { openapi } from '@orpc/openapi' import { call } from '@orpc/server' import { deleteFile, uploadFile } from './file' @@ -26,6 +27,7 @@ export const listPlanets = publicOS summary: 'List all planets', tags: ['Planet'], })) + .meta(mcp.tool({ description: 'List all planets', annotations: { readOnlyHint: true } })) .input(z.object({ keyword: z.string().optional(), limit: z.number().int().min(1).max(100).default(10), @@ -47,6 +49,7 @@ export const findPlanet = publicOS summary: 'Find a planet', tags: ['Planet'], })) + .meta(mcp.tool({ description: 'Find a planet by id', annotations: { readOnlyHint: true } })) .input(z.object({ id: PlanetSchema.shape.id, })) @@ -69,6 +72,7 @@ export const createPlanet = protectedOS summary: 'Create a new planet', tags: ['Planet'], })) + .meta(mcp.tool({ description: 'Create a new planet' })) .input(CreatingPlanetSchema) .output(PlanetSchema) .handler(async ({ input, context }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab936a831..a76551f3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,7 +301,7 @@ importers: devDependencies: evlog: specifier: ^2.18.1 - version: 2.19.2(@orpc/server@packages+server)(fastify@5.9.0)(hono@4.12.27)(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(ofetch@1.5.1)(react@19.2.7)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 2.19.2(@orpc/server@packages+server)(express@5.2.1)(fastify@5.9.0)(hono@4.12.27)(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(ofetch@1.5.1)(react@19.2.7)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) packages/interop: devDependencies: @@ -337,6 +337,34 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/mcp: + dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client + '@orpc/contract': + specifier: workspace:* + version: link:../contract + '@orpc/json-schema': + specifier: workspace:* + version: link:../json-schema + '@orpc/server': + specifier: workspace:* + version: link:../server + '@orpc/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + '@orpc/zod': + specifier: workspace:* + version: link:../zod + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/next: dependencies: '@orpc/client': @@ -644,6 +672,9 @@ importers: '@orpc/json-schema': specifier: beta version: link:../../packages/json-schema + '@orpc/mcp': + specifier: workspace:* + version: link:../../packages/mcp '@orpc/next': specifier: beta version: link:../../packages/next @@ -1522,6 +1553,12 @@ packages: '@hey-api/types@0.1.4': resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/node-server@2.0.6': resolution: {integrity: sha512-7DeRlKG57JDBNZ5Qj2jwVdgwQy4b0tLubRLl3zCf91/rCf9i7p1V5FtW/yWibm1uUHE493ts9ZXH/7g/LQWl+g==} engines: {node: '>=20'} @@ -1780,6 +1817,16 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -3539,6 +3586,10 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -3716,6 +3767,10 @@ packages: birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3945,6 +4000,18 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-gitmoji@0.1.5: resolution: {integrity: sha512-4wqOafJdk2tqZC++cjcbGcaJ13BZ3kwldf06PTiAQRAB76Z1KJwZNL1SaRZMi2w1FM9RYTgZ6QErS8NUl/GBmQ==} @@ -3959,6 +4026,10 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -3979,6 +4050,10 @@ packages: core-js-pure@3.49.0: resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -4289,6 +4364,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4352,6 +4431,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-beta.90: resolution: {integrity: sha512-A0U3OE+2oyK/iFG6VYbFj9gwjJ7rFXjgP7qV+m7n/4lOREp9Lfk1///SlGCpX7HRueOCZO1l7aW0KByXuJeiPA==} @@ -4371,6 +4453,10 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enhanced-resolve@5.24.1: resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} engines: {node: '>=10.13.0'} @@ -4431,6 +4517,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -4676,6 +4765,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -4683,6 +4776,10 @@ packages: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + evlog@2.19.2: resolution: {integrity: sha512-6a8ynL4tKhYK8p+mQv0in1tv3EUDSPvVPAWAuDRlpXTTIoLsR1Nol9dRIqhowakTP9XGNk6atSpvupTTGQDOTA==} engines: {node: '>=18.0.0'} @@ -4748,6 +4845,16 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} @@ -4838,6 +4945,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} @@ -4900,9 +5011,17 @@ packages: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -5104,6 +5223,10 @@ packages: html-whitespace-sensitive-tag-names@3.0.1: resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -5116,6 +5239,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + identifier-regex@1.0.1: resolution: {integrity: sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==} engines: {node: '>=18'} @@ -5171,6 +5298,14 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -5259,6 +5394,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -5315,6 +5453,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-base64@3.8.0: resolution: {integrity: sha512-65kvbemyZhj+ExQt1PEFyBEjL5vAHysu1lJdW1AwhhChkO8ZBPizYk/m9GVrpbS2Je1hF+UYZ+6KywqtZV8mHw==} @@ -5659,6 +5800,14 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} @@ -5775,6 +5924,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -5889,6 +6042,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -5986,6 +6143,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + on-headers@1.1.0: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} @@ -6089,6 +6250,10 @@ packages: parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -6113,6 +6278,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -6146,6 +6314,10 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-pr-new@0.0.75: resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} hasBin: true @@ -6404,6 +6576,10 @@ packages: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -6460,6 +6636,14 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -6720,6 +6904,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -6777,6 +6965,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@8.1.0: resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} engines: {node: '>=10'} @@ -6791,6 +6983,10 @@ packages: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -6804,6 +7000,9 @@ packages: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sha.js@2.4.12: resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} engines: {node: '>= 0.10'} @@ -6961,6 +7160,10 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -7173,6 +7376,10 @@ packages: toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tokenx@1.3.0: resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} @@ -7270,6 +7477,10 @@ packages: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -7359,6 +7570,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unraw@3.0.0: resolution: {integrity: sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==} @@ -7735,6 +7950,11 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -8520,6 +8740,10 @@ snapshots: '@hey-api/types@0.1.4': {} + '@hono/node-server@1.19.14(hono@4.12.27)': + dependencies: + hono: 4.12.27 + '@hono/node-server@2.0.6(hono@4.12.27)': dependencies: hono: 4.12.27 @@ -8751,6 +8975,28 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.27 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -11019,6 +11265,11 @@ snapshots: abstract-logging@2.0.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -11199,6 +11450,20 @@ snapshots: birpc@2.9.0: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} brace-expansion@5.0.6: @@ -11456,6 +11721,12 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-gitmoji@0.1.5: {} convert-hrtime@5.0.0: {} @@ -11464,6 +11735,8 @@ snapshots: cookie-signature@1.2.2: {} + cookie@0.7.2: {} + cookie@1.1.1: {} cookiejar@2.1.4: {} @@ -11482,6 +11755,11 @@ snapshots: core-js-pure@3.49.0: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -11822,6 +12100,8 @@ snapshots: denque@2.1.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -11879,6 +12159,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ee-first@1.1.1: {} + effect@4.0.0-beta.90: dependencies: '@standard-schema/spec': 1.1.0 @@ -11902,6 +12184,8 @@ snapshots: empathic@2.0.1: {} + encodeurl@2.0.0: {} + enhanced-resolve@5.24.1: dependencies: graceful-fs: 4.2.11 @@ -11993,6 +12277,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -12308,13 +12594,20 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@5.0.4: {} eventsource-parser@3.1.0: {} - evlog@2.19.2(@orpc/server@packages+server)(fastify@5.9.0)(hono@4.12.27)(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(ofetch@1.5.1)(react@19.2.7)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)): + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + + evlog@2.19.2(@orpc/server@packages+server)(express@5.2.1)(fastify@5.9.0)(hono@4.12.27)(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(ofetch@1.5.1)(react@19.2.7)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)): optionalDependencies: '@orpc/server': link:packages/server + express: 5.2.1 fastify: 5.9.0 hono: 4.12.27 next: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -12339,6 +12632,44 @@ snapshots: expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.1.0: {} extend-shallow@2.0.1: @@ -12438,6 +12769,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way-ts@0.1.6: {} find-my-way@9.6.0: @@ -12498,8 +12840,12 @@ snapshots: dezalgo: 1.0.4 once: 1.4.0 + forwarded@0.2.0: {} + fraction.js@5.3.4: {} + fresh@2.0.0: {} + fs-minipass@2.1.0: dependencies: minipass: 3.3.6 @@ -12763,6 +13109,14 @@ snapshots: html-whitespace-sensitive-tag-names@3.0.1: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -12776,6 +13130,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + identifier-regex@1.0.1: dependencies: reserved-identifiers: 1.2.0 @@ -12825,6 +13183,10 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + ipaddr.js@2.4.0: {} is-absolute-url@4.0.1: {} @@ -12889,6 +13251,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.9 @@ -12934,6 +13298,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + js-base64@3.8.0: {} js-file-download@0.4.12: {} @@ -13351,6 +13717,10 @@ snapshots: mdurl@2.0.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + mermaid@11.16.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -13599,6 +13969,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} mimic-function@5.0.1: {} @@ -13694,6 +14068,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + neotraverse@0.6.18: {} neverpanic@0.0.8: {} @@ -13783,6 +14159,10 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + on-headers@1.1.0: {} once@1.4.0: @@ -13904,6 +14284,8 @@ snapshots: dependencies: entities: 8.0.0 + parseurl@1.3.3: {} + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -13918,6 +14300,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -13952,6 +14336,8 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + pkce-challenge@5.0.1: {} + pkg-pr-new@0.0.75: {} pkg-types@1.3.1: @@ -14206,6 +14592,11 @@ snapshots: '@types/node': 26.0.1 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} punycode.js@2.3.1: {} @@ -14261,6 +14652,15 @@ snapshots: dependencies: safe-buffer: 5.2.1 + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.7 @@ -14621,6 +15021,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} rw@1.3.3: {} @@ -14666,6 +15076,22 @@ snapshots: semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@8.1.0: dependencies: type-fest: 0.20.2 @@ -14676,6 +15102,15 @@ snapshots: seroval@1.5.4: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + server-only@0.0.1: {} set-cookie-parser@2.7.2: {} @@ -14691,6 +15126,8 @@ snapshots: gopd: 1.2.0 has-property-descriptors: 1.0.2 + setprototypeof@1.2.0: {} + sha.js@2.4.12: dependencies: inherits: 2.0.4 @@ -14889,6 +15326,8 @@ snapshots: standard-as-callback@2.1.0: {} + statuses@2.0.2: {} + std-env@3.10.0: {} std-env@4.1.0: {} @@ -15170,6 +15609,8 @@ snapshots: toggle-selection@1.0.6: {} + toidentifier@1.0.1: {} + tokenx@1.3.0: {} toml-eslint-parser@1.0.3: @@ -15263,6 +15704,12 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -15402,6 +15849,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + unraw@3.0.0: {} untyped@2.0.0: @@ -15753,6 +16202,10 @@ snapshots: zimmerframe@1.1.4: {} + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@4.4.3: {} zwitch@2.0.4: {}