diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 6b091c4a0..cecfb47c4 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -36,13 +36,13 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Client imports -| v1 import path | v2 package | -| ---------------------------------------------------- | ------------------------------ | -| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | +| v1 import path | v2 package | +| ---------------------------------------------------- | ------------------------------------------------------------------------------ | +| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | | `@modelcontextprotocol/sdk/client/websocket.js` | REMOVED (use Streamable HTTP or stdio; implement `Transport` for custom needs) | ### Server imports @@ -59,8 +59,8 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Types / shared imports -| v1 import path | v2 package | -| ------------------------------------------------- | ---------------------------- | +| v1 import path | v2 package | +| ------------------------------------------------- | ---------------------------------------------------------------- | | `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | @@ -81,22 +81,22 @@ Notes: ## 5. Removed / Renamed Type Aliases and Symbols -| v1 (removed) | v2 (replacement) | -| ---------------------------------------- | -------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` | -| `ResourceReference` | `ResourceTemplateReference` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | +| v1 (removed) | v2 (replacement) | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` | +| `ResourceReference` | `ResourceTemplateReference` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | -| `McpError` | `ProtocolError` | -| `ErrorCode` | `ProtocolErrorCode` | -| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | -| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | -| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | -| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | +| `McpError` | `ProtocolError` | +| `ErrorCode` | `ProtocolErrorCode` | +| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | +| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | +| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | +| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | All other symbols from `@modelcontextprotocol/sdk/types.js` retain their original names (e.g., `CallToolResultSchema`, `ListToolsResultSchema`, etc.). @@ -210,7 +210,8 @@ Zod schemas, all callback return types. Note: `callTool()` and `request()` signa The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. -**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with +`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. ### Tools @@ -280,20 +281,20 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata. ### Schema Migration Quick Reference -| v1 (raw shape) | v2 (Standard Schema object) | -|----------------|-----------------| -| `{ name: z.string() }` | `z.object({ name: z.string() })` | +| v1 (raw shape) | v2 (Standard Schema object) | +| ---------------------------------- | -------------------------------------------- | +| `{ name: z.string() }` | `z.object({ name: z.string() })` | | `{ count: z.number().optional() }` | `z.object({ count: z.number().optional() })` | | `{}` (empty) | `z.object({})` | | `undefined` (no schema) | `undefined` or omit the field | ### Removed core exports -| Removed from `@modelcontextprotocol/core` | Replacement | -|---|---| -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed from `@modelcontextprotocol/core` | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | none (internal Zod introspection helpers) | ## 7. Headers API @@ -380,6 +381,18 @@ Schema to method string mapping: Request/notification params remain fully typed. Remove unused schema imports after migration. +**Custom (non-standard) methods** — vendor extensions or sub-protocols whose method strings are not in the MCP spec — are no longer accepted by `setRequestHandler`/`setNotificationHandler`. Use the `*Custom*` API instead: + +| v1 | v2 | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| `setRequestHandler(CustomReqSchema, (req, extra) => ...)` | `setCustomRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` | +| `setNotificationHandler(CustomNotifSchema, n => ...)` | `setCustomNotificationHandler('vendor/method', ParamsSchema, params => ...)` | +| `this.request({ method: 'vendor/x', params }, ResultSchema)` | `this.sendCustomRequest('vendor/x', params, ResultSchema)` | +| `this.notification({ method: 'vendor/x', params })` | `this.sendCustomNotification('vendor/x', params)` | +| `class X extends Protocol` | `class X extends Client` (or `Server`), or compose a `Client` instance | + +The v1 schema's `.shape.params` becomes the `ParamsSchema` argument; the `method: z.literal('...')` value becomes the string argument. + ## 10. Request Handler Context Types `RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks. @@ -427,15 +440,15 @@ const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { . const tool = await client.callTool({ name: 'my-tool', arguments: {} }); ``` -| v1 call | v2 call | -| ------------------------------------------------------------ | ---------------------------------- | -| `client.request(req, ResultSchema)` | `client.request(req)` | -| `client.request(req, ResultSchema, options)` | `client.request(req, options)` | +| v1 call | v2 call | +| -------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `client.request(req, ResultSchema)` | `client.request(req)` | +| `client.request(req, ResultSchema, options)` | `client.request(req, options)` | | `client.experimental.tasks.callToolStream(params, ResultSchema, options?)` | `client.experimental.tasks.callToolStream(params, options?)` | -| `ctx.mcpReq.send(req, ResultSchema)` | `ctx.mcpReq.send(req)` | -| `ctx.mcpReq.send(req, ResultSchema, options)` | `ctx.mcpReq.send(req, options)` | -| `client.callTool(params, CompatibilityCallToolResultSchema)` | `client.callTool(params)` | -| `client.callTool(params, schema, options)` | `client.callTool(params, options)` | +| `ctx.mcpReq.send(req, ResultSchema)` | `ctx.mcpReq.send(req)` | +| `ctx.mcpReq.send(req, ResultSchema, options)` | `ctx.mcpReq.send(req, options)` | +| `client.callTool(params, CompatibilityCallToolResultSchema)` | `client.callTool(params)` | +| `client.callTool(params, schema, options)` | `client.callTool(params, options)` | Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. @@ -443,18 +456,18 @@ Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResu `TaskCreationParams.ttl` changed from `z.union([z.number(), z.null()]).optional()` to `z.number().optional()`. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Omit `ttl` to let the server decide. -| v1 | v2 | -|---|---| -| `task: { ttl: null }` | `task: {}` (omit ttl) | +| v1 | v2 | +| ---------------------- | ---------------------------------- | +| `task: { ttl: null }` | `task: {}` (omit ttl) | | `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) | Type changes in handler context: -| Type | v1 | v2 | -|---|---|---| -| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| Type | v1 | v2 | +| ------------------------------------------- | ----------------------------- | --------------------- | +| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | | `CreateTaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | -| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | > These task APIs are `@experimental` and may change without notice. diff --git a/docs/migration.md b/docs/migration.md index e20bc1dca..ccaee847c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -57,7 +57,8 @@ import { McpServer, StdioServerTransport, WebStandardStreamableHTTPServerTranspo import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; ``` -Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly — it is an internal package. +Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly +— it is an internal package. ### Dropped Node.js 18 and CommonJS @@ -138,7 +139,8 @@ app.all('/mcp', async (req, res) => { }); ``` -With `sessionIdGenerator: undefined` the transport runs in stateless mode, so creating a fresh instance per request is correct. For stateful sessions, the transport must be created once per session and stored in a `Map` keyed by session ID — see `examples/server/src/simpleStreamableHttp.ts` for the full pattern. +With `sessionIdGenerator: undefined` the transport runs in stateless mode, so creating a fresh instance per request is correct. For stateful sessions, the transport must be created once per session and stored in a `Map` keyed by session +ID — see `examples/server/src/simpleStreamableHttp.ts` for the full pattern. ### `WebSocketClientTransport` removed @@ -324,11 +326,11 @@ This applies to: **Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): -| Removed | Replacement | -|---|---| -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | ### Host header validation moved @@ -416,10 +418,63 @@ Common method string replacements: | `ElicitationCompleteNotificationSchema` | `'notifications/elicitation/complete'` | | `InitializedNotificationSchema` | `'notifications/initialized'` | +### Custom (non-standard) protocol methods + +In v1, `setRequestHandler` accepted any Zod schema with a `method: z.literal('...')` shape, so vendor-specific methods (e.g. `mcp-ui/initialize`) could be registered the same way as spec methods. The `Protocol` generics widened the +send-side types to match. + +In v2, `setRequestHandler`/`setNotificationHandler` accept only standard MCP method strings, and the class-level send-side generics have been removed. For methods outside the MCP spec, use the dedicated `*Custom*` methods on `Client` and `Server` (inherited from `Protocol`): + +**Before (v1):** + +```typescript +import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js'; + +const SearchRequestSchema = z.object({ + method: z.literal('acme/search'), + params: z.object({ query: z.string() }) +}); + +class App extends Protocol { + constructor() { + super(); + this.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] })); + } + search(query: string) { + return this.request({ method: 'acme/search', params: { query } }, SearchResultSchema); + } +} +``` + +**After (v2):** + +```typescript +import { Client } from '@modelcontextprotocol/client'; + +const SearchParams = z.object({ query: z.string() }); +const SearchResult = z.object({ hits: z.array(z.string()) }); + +class App extends Client { + constructor() { + super({ name: 'app', version: '1.0.0' }); + this.setCustomRequestHandler('acme/search', SearchParams, params => ({ hits: [params.query] })); + } + search(query: string) { + return this.sendCustomRequest('acme/search', { query }, { params: SearchParams, result: SearchResult }); + } +} +``` + +Custom handlers share the same dispatch path as standard handlers — context, cancellation, task delivery, and error wrapping all apply. Passing a `{ params, result }` schema bundle to `sendCustomRequest` (or `{ params }` to `sendCustomNotification`) validates outbound params +before sending and gives typed `params`; passing a bare result schema sends params unvalidated. + +For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExtAppsExample.ts` for a worked +example. + ### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter -The public `Protocol.request()`, `BaseContext.mcpReq.send()`, `Client.callTool()`, and `client.experimental.tasks.callToolStream()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas -like `CallToolResultSchema` or `ElicitResultSchema` when making requests. +The public `Protocol.request()`, `BaseContext.mcpReq.send()`, `Client.callTool()`, and `client.experimental.tasks.callToolStream()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This +means you no longer need to import result schemas like `CallToolResultSchema` or `ElicitResultSchema` when making requests. **`client.request()` — Before (v1):** @@ -527,15 +582,15 @@ For **production in-process connections**, prefer `StreamableHTTPClientTransport The following deprecated type aliases have been removed from `@modelcontextprotocol/core`: -| Removed | Replacement | -| ---------------------------------------- | ------------------------------------------------ | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `ResourceReference` | `ResourceTemplateReference` | -| `IsomorphicHeaders` | Use Web Standard `Headers` | +| Removed | Replacement | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `ResourceReference` | `ResourceTemplateReference` | +| `IsomorphicHeaders` | Use Web Standard `Headers` | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | All other types and schemas exported from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. @@ -566,7 +621,7 @@ The `RequestHandlerExtra` type has been replaced with a structured context type | `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | `extra.authInfo` | `ctx.http?.authInfo` | -| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | | `extra.sessionId` | `ctx.sessionId` | @@ -833,7 +888,8 @@ try { ### Experimental: `TaskCreationParams.ttl` no longer accepts `null` -The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let the server decide the lifetime. +The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let +the server decide the lifetime. This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`. diff --git a/examples/server/package.json b/examples/server/package.json index fcff95d9a..00de54cfa 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", + "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/examples-shared": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts new file mode 100644 index 000000000..ba9c4e840 --- /dev/null +++ b/examples/server/src/customMethodExample.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Demonstrates custom (non-standard) request and notification methods. + * + * The Protocol class exposes setCustomRequestHandler / setCustomNotificationHandler / + * sendCustomRequest / sendCustomNotification for vendor-specific methods that are not + * part of the MCP spec. Params and results are validated against user-provided Zod + * schemas, and handlers receive the same context (cancellation, task support, + * bidirectional send/notify) as standard handlers. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, Server } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +const SearchParamsSchema = z.object({ + query: z.string(), + limit: z.number().int().positive().optional() +}); + +const SearchResultSchema = z.object({ + results: z.array(z.object({ id: z.string(), title: z.string() })), + total: z.number() +}); + +const AnalyticsParamsSchema = z.object({ + event: z.string(), + properties: z.record(z.string(), z.unknown()).optional() +}); + +const AnalyticsResultSchema = z.object({ recorded: z.boolean() }); + +const StatusUpdateParamsSchema = z.object({ + status: z.enum(['idle', 'busy', 'error']), + detail: z.string().optional() +}); + +async function main() { + const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); + const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} }); + + server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => { + console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`); + return { + results: [ + { id: 'r1', title: `Result for "${params.query}"` }, + { id: 'r2', title: 'Another result' } + ], + total: 2 + }; + }); + + server.setCustomRequestHandler('acme/analytics', AnalyticsParamsSchema, async params => { + console.log(`[server] acme/analytics event="${params.event}"`); + return { recorded: true }; + }); + + client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => { + console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? ''}`); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + const searchResult = await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 5 }, SearchResultSchema); + console.log(`[client] received ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`); + + const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema); + console.log(`[client] analytics recorded=${analyticsResult.recorded}`); + + await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: 'indexing' }); + + // Validation error: wrong param type (limit must be a number) + try { + await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 'five' }, SearchResultSchema); + console.error('[client] expected validation error but request succeeded'); + } catch (error) { + console.log(`[client] validation error (expected): ${(error as Error).message}`); + } + + await client.close(); + await server.close(); +} + +await main(); diff --git a/examples/server/src/customMethodExtAppsExample.ts b/examples/server/src/customMethodExtAppsExample.ts new file mode 100644 index 000000000..764475e7b --- /dev/null +++ b/examples/server/src/customMethodExtAppsExample.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Demonstrates that the ext-apps (mcp-ui) pattern is fully implementable on top of the v2 + * SDK's custom-method-handler API, without extending Protocol or relying on the v1 generic + * type parameters. + * + * In v1, ext-apps defined `class ProtocolWithEvents<...> extends Protocol` to + * widen the request/notification type unions. In v2, the same is achieved by composing + * setCustomRequestHandler / setCustomNotificationHandler / sendCustomRequest / sendCustomNotification + * on top of the standard Client and Server classes. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, Server } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +// ─────────────────────────────────────────────────────────────────────────────── +// Custom method schemas (mirror the ext-apps spec.types.ts pattern) +// ─────────────────────────────────────────────────────────────────────────────── + +const InitializeParams = z.object({ + protocolVersion: z.string(), + appInfo: z.object({ name: z.string(), version: z.string() }) +}); +const InitializeResult = z.object({ + protocolVersion: z.string(), + hostInfo: z.object({ name: z.string(), version: z.string() }), + hostContext: z.object({ theme: z.enum(['light', 'dark']), locale: z.string() }) +}); + +const OpenLinkParams = z.object({ url: z.url() }); +const OpenLinkResult = z.object({ opened: z.boolean() }); + +const TeardownParams = z.object({ reason: z.string().optional() }); + +const SizeChangedParams = z.object({ width: z.number(), height: z.number() }); +const ToolResultParams = z.object({ toolName: z.string(), content: z.array(z.object({ type: z.string(), text: z.string() })) }); +const HostContextChangedParams = z.object({ theme: z.enum(['light', 'dark']).optional(), locale: z.string().optional() }); + +type AppEventMap = { + toolresult: z.infer; + hostcontextchanged: z.infer; +}; + +// ─────────────────────────────────────────────────────────────────────────────── +// App: wraps Client, exposes typed mcp-ui/* methods + DOM-style events +// (replaces v1's `class App extends ProtocolWithEvents`) +// ─────────────────────────────────────────────────────────────────────────────── + +class App { + readonly client: Client; + private _listeners: { [K in keyof AppEventMap]: ((p: AppEventMap[K]) => void)[] } = { + toolresult: [], + hostcontextchanged: [] + }; + private _hostContext?: z.infer['hostContext']; + + onTeardown?: (params: z.infer) => void | Promise; + + constructor(appInfo: { name: string; version: string }) { + this.client = new Client(appInfo, { capabilities: {} }); + + // Incoming custom request from host + this.client.setCustomRequestHandler('mcp-ui/resourceTeardown', TeardownParams, async params => { + await this.onTeardown?.(params); + return {}; + }); + + // Incoming custom notifications from host -> DOM-style event slots + this.client.setCustomNotificationHandler('mcp-ui/toolResult', ToolResultParams, p => this._dispatch('toolresult', p)); + this.client.setCustomNotificationHandler('mcp-ui/hostContextChanged', HostContextChangedParams, p => { + this._hostContext = { ...this._hostContext!, ...p }; + this._dispatch('hostcontextchanged', p); + }); + } + + addEventListener(event: K, listener: (p: AppEventMap[K]) => void): void { + this._listeners[event].push(listener); + } + + removeEventListener(event: K, listener: (p: AppEventMap[K]) => void): void { + const arr = this._listeners[event]; + const i = arr.indexOf(listener); + if (i !== -1) arr.splice(i, 1); + } + + private _dispatch(event: K, params: AppEventMap[K]): void { + for (const l of this._listeners[event]) l(params); + } + + async connect(transport: Parameters[0]): Promise { + await this.client.connect(transport); + const result = await this.client.sendCustomRequest( + 'mcp-ui/initialize', + { protocolVersion: '2026-01-26', appInfo: { name: 'demo-app', version: '1.0.0' } }, + InitializeResult + ); + this._hostContext = result.hostContext; + await this.client.sendCustomNotification('mcp-ui/initialized', {}); + } + + getHostContext() { + return this._hostContext; + } + + openLink(url: string) { + return this.client.sendCustomRequest('mcp-ui/openLink', { url }, OpenLinkResult); + } + + notifySizeChanged(width: number, height: number) { + return this.client.sendCustomNotification('mcp-ui/sizeChanged', { width, height }); + } +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Host: wraps Server, handles mcp-ui/* requests and emits mcp-ui/* notifications +// ─────────────────────────────────────────────────────────────────────────────── + +class Host { + readonly server: Server; + onSizeChanged?: (p: z.infer) => void; + + constructor() { + this.server = new Server({ name: 'demo-host', version: '1.0.0' }, { capabilities: {} }); + + this.server.setCustomRequestHandler('mcp-ui/initialize', InitializeParams, params => { + console.log(`[host] mcp-ui/initialize from ${params.appInfo.name}@${params.appInfo.version}`); + return { + protocolVersion: params.protocolVersion, + hostInfo: { name: 'demo-host', version: '1.0.0' }, + hostContext: { theme: 'dark', locale: 'en-US' } + }; + }); + + this.server.setCustomRequestHandler('mcp-ui/openLink', OpenLinkParams, params => { + console.log(`[host] mcp-ui/openLink url=${params.url}`); + return { opened: true }; + }); + + this.server.setCustomNotificationHandler('mcp-ui/initialized', z.object({}).optional(), () => { + console.log('[host] mcp-ui/initialized'); + }); + + this.server.setCustomNotificationHandler('mcp-ui/sizeChanged', SizeChangedParams, p => { + console.log(`[host] mcp-ui/sizeChanged ${p.width}x${p.height}`); + this.onSizeChanged?.(p); + }); + } + + notifyToolResult(toolName: string, text: string) { + return this.server.sendCustomNotification('mcp-ui/toolResult', { + toolName, + content: [{ type: 'text', text }] + }); + } + + notifyHostContextChanged(patch: z.infer) { + return this.server.sendCustomNotification('mcp-ui/hostContextChanged', patch); + } + + requestTeardown(reason: string) { + return this.server.sendCustomRequest('mcp-ui/resourceTeardown', { reason }, z.object({})); + } +} + +// ─────────────────────────────────────────────────────────────────────────────── +// Demo +// ─────────────────────────────────────────────────────────────────────────────── + +async function main() { + const host = new Host(); + const app = new App({ name: 'demo-app', version: '1.0.0' }); + + app.addEventListener('toolresult', p => console.log(`[app] toolresult: ${p.toolName} -> "${p.content[0]?.text}"`)); + app.addEventListener('hostcontextchanged', p => console.log(`[app] hostcontextchanged: ${JSON.stringify(p)}`)); + app.onTeardown = p => console.log(`[app] teardown: ${p.reason}`); + host.onSizeChanged = p => console.log(`[host] app resized to ${p.width}x${p.height}`); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await host.server.connect(serverTransport); + await app.connect(clientTransport); + + console.log(`[app] hostContext after init: ${JSON.stringify(app.getHostContext())}`); + + // App -> Host: custom request + const { opened } = await app.openLink('https://example.com'); + console.log(`[app] openLink -> opened=${opened}`); + + // App -> Host: custom notification + await app.notifySizeChanged(800, 600); + + // Host -> App: custom notifications (DOM-style event listeners fire) + await host.notifyToolResult('search', 'found 3 widgets'); + await host.notifyHostContextChanged({ theme: 'light' }); + console.log(`[app] hostContext after change: ${JSON.stringify(app.getHostContext())}`); + + // Host -> App: custom request + await host.requestTeardown('navigation'); + + await app.client.close(); + await host.server.close(); +} + +await main(); diff --git a/examples/server/tsconfig.json b/examples/server/tsconfig.json index e3c0e9477..bf41c2d43 100644 --- a/examples/server/tsconfig.json +++ b/examples/server/tsconfig.json @@ -7,6 +7,8 @@ "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab6932..482094b27 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -40,6 +40,8 @@ import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, + isNotificationMethod, + isRequestMethod, ProtocolError, ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS @@ -1057,6 +1059,163 @@ export abstract class Protocol { removeNotificationHandler(method: NotificationMethod): void { this._notificationHandlers.delete(method); } + + /** + * Registers a handler for a custom (non-standard) request method. + * + * Unlike {@linkcode setRequestHandler}, this accepts any method + * string and validates incoming params against a user-provided schema instead of an SDK-defined + * one. Capability checks are skipped. The handler receives the same {@linkcode BaseContext | context} + * as standard handlers, including cancellation, task support, and bidirectional send/notify. + */ + setCustomRequestHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

, ctx: ContextT) => Result | Promise + ): void { + if (isRequestMethod(method)) { + throw new Error(`"${method}" is a standard MCP request method. Use setRequestHandler() instead.`); + } + this._requestHandlers.set(method, (request, ctx) => { + const parsed = parseSchema(paramsSchema, request.params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + return Promise.resolve(handler(parsed.data, ctx)); + }); + } + + /** + * Removes a custom request handler previously registered with + * {@linkcode Protocol.setCustomRequestHandler | setCustomRequestHandler}. + */ + removeCustomRequestHandler(method: string): void { + if (isRequestMethod(method)) { + throw new Error(`"${method}" is a standard MCP request method. Use removeRequestHandler() instead.`); + } + this._requestHandlers.delete(method); + } + + /** + * Registers a handler for a custom (non-standard) notification method. + * + * Unlike {@linkcode Protocol.setNotificationHandler | setNotificationHandler}, this accepts any + * method string and validates incoming params against a user-provided schema instead of an + * SDK-defined one. + */ + setCustomNotificationHandler

( + method: string, + paramsSchema: P, + handler: (params: SchemaOutput

) => void | Promise + ): void { + if (isNotificationMethod(method)) { + throw new Error(`"${method}" is a standard MCP notification method. Use setNotificationHandler() instead.`); + } + this._notificationHandlers.set(method, notification => { + const parsed = parseSchema(paramsSchema, notification.params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + return Promise.resolve(handler(parsed.data)); + }); + } + + /** + * Removes a custom notification handler previously registered with + * {@linkcode Protocol.setCustomNotificationHandler | setCustomNotificationHandler}. + */ + removeCustomNotificationHandler(method: string): void { + if (isNotificationMethod(method)) { + throw new Error(`"${method}" is a standard MCP notification method. Use removeNotificationHandler() instead.`); + } + this._notificationHandlers.delete(method); + } + + /** + * Sends a custom (non-standard) request and waits for a response, validating the result against + * the provided schema. + * + * Unlike {@linkcode Protocol.request | request}, this accepts any method string. Capability + * checks do not apply to custom methods regardless of + * {@linkcode ProtocolOptions.enforceStrictCapabilities}, since + * `assertCapabilityForMethod` only covers + * standard MCP methods. + * + * Pass a `{ params, result }` schema bundle as the third argument to get typed `params` and + * pre-send validation; pass a bare result schema for loose, unvalidated params. + */ + sendCustomRequest

( + method: string, + params: SchemaOutput

, + schemas: { params: P; result: R }, + options?: RequestOptions + ): Promise>; + sendCustomRequest( + method: string, + params: Record | undefined, + resultSchema: R, + options?: RequestOptions + ): Promise>; + async sendCustomRequest( + method: string, + params: Record | undefined, + schemaOrBundle: AnySchema | { params: AnySchema; result: AnySchema }, + options?: RequestOptions + ): Promise { + let resultSchema: AnySchema; + if (isSchemaBundle(schemaOrBundle)) { + const parsed = parseSchema(schemaOrBundle.params, params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + resultSchema = schemaOrBundle.result; + } else { + resultSchema = schemaOrBundle; + } + return this._requestWithSchema({ method, params } as Request, resultSchema, options); + } + + /** + * Sends a custom (non-standard) notification. + * + * Unlike {@linkcode Protocol.notification | notification}, this accepts any method string. It + * routes through {@linkcode Protocol.notification | notification}, so debouncing and task-queued + * delivery apply. Capability checks are a no-op for custom methods since + * `assertNotificationCapability` only covers + * standard MCP notifications. + * + * Pass a `{ params }` schema bundle as the third argument to get typed `params` and pre-send + * validation. + */ + sendCustomNotification

( + method: string, + params: SchemaOutput

, + schemas: { params: P }, + options?: NotificationOptions + ): Promise; + sendCustomNotification(method: string, params?: Record, options?: NotificationOptions): Promise; + async sendCustomNotification( + method: string, + params?: Record, + schemasOrOptions?: { params: AnySchema } | NotificationOptions, + maybeOptions?: NotificationOptions + ): Promise { + let options: NotificationOptions | undefined; + if (schemasOrOptions && 'params' in schemasOrOptions) { + const parsed = parseSchema(schemasOrOptions.params, params); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + options = maybeOptions; + } else { + options = schemasOrOptions; + } + return this.notification({ method, params } as Notification, options); + } +} + +function isSchemaBundle(value: AnySchema | { params: AnySchema; result: AnySchema }): value is { params: AnySchema; result: AnySchema } { + return !('~standard' in value) && 'params' in value && 'result' in value; } function isPlainObject(value: unknown): value is Record { diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index 86acf11d7..4743f4f25 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -2209,6 +2209,20 @@ const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, NotificationSchemaType >; +/** + * Type predicate: returns true if `method` is a standard MCP request method. + */ +export function isRequestMethod(method: string): method is RequestMethod { + return Object.hasOwn(requestSchemas, method); +} + +/** + * Type predicate: returns true if `method` is a standard MCP notification method. + */ +export function isNotificationMethod(method: string): method is NotificationMethod { + return Object.hasOwn(notificationSchemas, method); +} + /** * Gets the Zod schema for a given request method. * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts new file mode 100644 index 000000000..0fb346374 --- /dev/null +++ b/packages/core/test/shared/customMethods.test.ts @@ -0,0 +1,248 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +async function linkedPair(): Promise<[TestProtocol, TestProtocol]> { + const a = new TestProtocol(); + const b = new TestProtocol(); + const [ta, tb] = InMemoryTransport.createLinkedPair(); + await Promise.all([a.connect(ta), b.connect(tb)]); + return [a, b]; +} + +const SearchParams = z.object({ query: z.string(), limit: z.number().optional() }); +const SearchResult = z.object({ hits: z.array(z.string()), total: z.number() }); +const StatusParams = z.object({ status: z.enum(['idle', 'busy']) }); + +describe('custom request handlers', () => { + let client: TestProtocol; + let server: TestProtocol; + + beforeEach(async () => { + [client, server] = await linkedPair(); + }); + + test('happy path: typed params and result', async () => { + server.setCustomRequestHandler('acme/search', SearchParams, params => { + return { hits: [`result:${params.query}`], total: 1 }; + }); + + const result = await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 5 }, SearchResult); + expect(result.hits).toEqual(['result:widgets']); + expect(result.total).toBe(1); + }); + + test('handler receives full context (signal, mcpReq id)', async () => { + let received: BaseContext | undefined; + server.setCustomRequestHandler('acme/ctx', z.object({}), (_params, ctx) => { + received = ctx; + return {}; + }); + + await client.sendCustomRequest('acme/ctx', {}, z.object({})); + expect(received).toBeDefined(); + expect(received?.mcpReq.signal).toBeInstanceOf(AbortSignal); + expect(received?.mcpReq.id).toBeDefined(); + expect(received?.mcpReq.method).toBe('acme/ctx'); + }); + + test('invalid params -> InvalidParams ProtocolError', async () => { + server.setCustomRequestHandler('acme/search', SearchParams, () => ({ hits: [], total: 0 })); + + await expect(client.sendCustomRequest('acme/search', { query: 123 }, SearchResult)).rejects.toSatisfy( + (e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.InvalidParams + ); + }); + + test('collision guard: throws on standard request method', () => { + expect(() => server.setCustomRequestHandler('ping', z.object({}), () => ({}))).toThrow(/standard MCP request method/); + expect(() => server.setCustomRequestHandler('tools/call', z.object({}), () => ({}))).toThrow(/standard MCP request method/); + expect(() => server.removeCustomRequestHandler('tools/list')).toThrow(/standard MCP request method/); + }); + + test('collision guard: does NOT trigger on Object.prototype keys', () => { + for (const m of ['toString', 'constructor', 'hasOwnProperty', '__proto__']) { + expect(() => server.setCustomRequestHandler(m, z.object({}), () => ({}))).not.toThrow(); + expect(() => server.setCustomNotificationHandler(m, z.object({}), () => {})).not.toThrow(); + } + }); + + test('removeCustomRequestHandler -> subsequent request fails MethodNotFound', async () => { + server.setCustomRequestHandler('acme/search', SearchParams, () => ({ hits: [], total: 0 })); + await client.sendCustomRequest('acme/search', { query: 'x' }, SearchResult); + + server.removeCustomRequestHandler('acme/search'); + await expect(client.sendCustomRequest('acme/search', { query: 'x' }, SearchResult)).rejects.toSatisfy( + (e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.MethodNotFound + ); + }); + + test('double-register -> last wins', async () => { + server.setCustomRequestHandler('acme/v', z.object({}), () => ({ v: 1 })); + server.setCustomRequestHandler('acme/v', z.object({}), () => ({ v: 2 })); + const result = await client.sendCustomRequest('acme/v', {}, z.object({ v: z.number() })); + expect(result.v).toBe(2); + }); +}); + +describe('custom notification handlers', () => { + let client: TestProtocol; + let server: TestProtocol; + + beforeEach(async () => { + [client, server] = await linkedPair(); + }); + + test('handler invoked with typed params', async () => { + const received: string[] = []; + client.setCustomNotificationHandler('acme/status', StatusParams, params => { + received.push(params.status); + }); + + await server.sendCustomNotification('acme/status', { status: 'busy' }); + await server.sendCustomNotification('acme/status', { status: 'idle' }); + await vi.waitFor(() => expect(received).toEqual(['busy', 'idle'])); + }); + + test('collision guard: throws on standard notification method', () => { + expect(() => client.setCustomNotificationHandler('notifications/cancelled', z.object({}), () => {})).toThrow( + /standard MCP notification method/ + ); + expect(() => client.setCustomNotificationHandler('notifications/progress', z.object({}), () => {})).toThrow( + /standard MCP notification method/ + ); + expect(() => client.removeCustomNotificationHandler('notifications/initialized')).toThrow(/standard MCP notification method/); + }); + + test('removeCustomNotificationHandler -> subsequent notifications not delivered', async () => { + const handler = vi.fn(); + client.setCustomNotificationHandler('acme/status', StatusParams, handler); + await server.sendCustomNotification('acme/status', { status: 'busy' }); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + client.removeCustomNotificationHandler('acme/status'); + await server.sendCustomNotification('acme/status', { status: 'idle' }); + // Give the event loop a tick; handler should not be called again. + await new Promise(r => setTimeout(r, 10)); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('invalid params -> handler not invoked, error surfaced via onerror', async () => { + const handler = vi.fn(); + const errors: Error[] = []; + client.setCustomNotificationHandler('acme/status', StatusParams, handler); + client.onerror = e => errors.push(e); + + await server.sendCustomNotification('acme/status', { status: 'unknown' }); + await vi.waitFor(() => expect(errors.length).toBeGreaterThan(0)); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('sendCustomRequest', () => { + test('not connected -> rejects', async () => { + const proto = new TestProtocol(); + await expect(proto.sendCustomRequest('acme/x', {}, z.object({}))).rejects.toThrow(/Not connected/); + }); + + test('undefined params accepted', async () => { + const [client, server] = await linkedPair(); + server.setCustomRequestHandler('acme/noargs', z.undefined().or(z.object({})), () => ({ ok: true })); + const result = await client.sendCustomRequest('acme/noargs', undefined, z.object({ ok: z.boolean() })); + expect(result.ok).toBe(true); + }); + + test('result validated against resultSchema', async () => { + const [client, server] = await linkedPair(); + server.setCustomRequestHandler('acme/badresult', z.object({}), () => ({ hits: 'not-an-array', total: 0 })); + await expect(client.sendCustomRequest('acme/badresult', {}, SearchResult)).rejects.toThrow(); + }); + + test('schema bundle overload: typed params and result', async () => { + const [client, server] = await linkedPair(); + server.setCustomRequestHandler('acme/search', SearchParams, p => ({ hits: [p.query], total: 1 })); + const result = await client.sendCustomRequest('acme/search', { query: 'q' }, { params: SearchParams, result: SearchResult }); + expect(result.hits).toEqual(['q']); + }); + + test('schema bundle overload: invalid params rejects InvalidParams before transport', async () => { + const proto = new TestProtocol(); // not connected + // InvalidParams (pre-send validation) — proves it does NOT reach the NotConnected path + await expect( + proto.sendCustomRequest('acme/search', { query: 123 } as unknown as z.output, { + params: SearchParams, + result: SearchResult + }) + ).rejects.toSatisfy((e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.InvalidParams); + }); +}); + +describe('sendCustomNotification', () => { + test('not connected -> throws SdkError NotConnected', async () => { + const proto = new TestProtocol(); + await expect(proto.sendCustomNotification('acme/x', {})).rejects.toSatisfy( + (e: unknown) => e instanceof SdkError && e.code === SdkErrorCode.NotConnected + ); + }); + + test('delivered to peer with no handler -> no error thrown on sender', async () => { + const [client, server] = await linkedPair(); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await expect(server.sendCustomNotification('acme/unhandled', { x: 1 })).resolves.toBeUndefined(); + }); + + test('schema bundle overload: invalid params throws InvalidParams before transport', async () => { + const proto = new TestProtocol(); // not connected + await expect( + proto.sendCustomNotification('acme/status', { status: 'bad' } as unknown as z.output, { + params: StatusParams + }) + ).rejects.toSatisfy((e: unknown) => e instanceof ProtocolError && e.code === ProtocolErrorCode.InvalidParams); + }); + + test('schema bundle overload: valid params delivered, options as 4th arg', async () => { + const [client, server] = await linkedPair(); + const received: string[] = []; + client.setCustomNotificationHandler('acme/status', StatusParams, p => { + received.push(p.status); + }); + await server.sendCustomNotification('acme/status', { status: 'busy' }, { params: StatusParams }, {}); + await vi.waitFor(() => expect(received).toEqual(['busy'])); + }); + + test('routes through notification(): debouncing applies to custom methods', async () => { + const a = new TestProtocol({ debouncedNotificationMethods: ['acme/tick'] }); + const b = new TestProtocol(); + const [ta, tb] = InMemoryTransport.createLinkedPair(); + await Promise.all([a.connect(ta), b.connect(tb)]); + + let count = 0; + b.setCustomNotificationHandler('acme/tick', z.undefined().or(z.object({})), () => { + count++; + }); + + // Three synchronous sends should coalesce to one delivery. + void a.sendCustomNotification('acme/tick'); + void a.sendCustomNotification('acme/tick'); + void a.sendCustomNotification('acme/tick'); + await new Promise(r => setTimeout(r, 10)); + expect(count).toBe(1); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 899586750..42503183b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,9 @@ importers: '@hono/node-server': specifier: catalog:runtimeServerOnly version: 1.19.11(hono@4.12.9) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client '@modelcontextprotocol/examples-shared': specifier: workspace:^ version: link:../shared @@ -1706,105 +1709,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2053,70 +2040,60 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.57': resolution: {integrity: sha512-d0kIVezTQtazpyWjiJIn5to8JlwfKITDqwsFv0Xc6s31N16CD2PC/Pl2OtKgS7n8WLOJbfqgIp5ixYzTAxCqMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.57': resolution: {integrity: sha512-E199LPijo98yrLjPCmETx8EF43sZf9t3guSrLee/ej1rCCc3zDVTR4xFfN9BRAapGVl7/8hYqbbiQPTkv73kUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.57': resolution: {integrity: sha512-++EQDpk/UJ33kY/BNsh7A7/P1sr/jbMuQ8cE554ZIy+tCUWCivo9zfyjDUoiMdnxqX6HLJEqqGnbGQOvzm2OMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.57': resolution: {integrity: sha512-voDEBcNqxbUv/GeXKFtxXVWA+H45P/8Dec4Ii/SbyJyGvCqV1j+nNHfnFUIiRQ2Q40DwPe/djvgYBs9PpETiMA==} @@ -2204,79 +2181,66 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -2560,49 +2524,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}