From 5553342652edff4f03ad830fea8246129fe13b32 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 17:57:38 +0000 Subject: [PATCH 01/14] feat(core): restore v1 Zod-schema overloads on setRequestHandler/setNotificationHandler/request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds to Protocol (inherited by Client/Server): - setRequestHandler/setNotificationHandler(ZodSchema, handler) — the v1 form, first-class - request(req, resultSchema, opts?) — the v1 explicit-schema form, first-class - callTool(params, resultSchema?, opts?) — accepts the v1 schema arg (ignored) for source compat - removeRequestHandler/removeNotificationHandler/assertCanSetRequestHandler accept any method string Custom (non-spec) methods work via the Zod-schema form, same as v1. schema.ts/standardSchema.ts unchanged from main. --- .changeset/custom-method-overloads.md | 6 + docs/migration-SKILL.md | 9 ++ docs/migration.md | 19 ++- examples/client/README.md | 1 + examples/client/src/customMethodExample.ts | 36 ++++++ examples/server/README.md | 1 + examples/server/src/customMethodExample.ts | 39 ++++++ packages/client/src/client/client.ts | 44 +++++-- packages/core/src/exports/public/index.ts | 1 + packages/core/src/index.ts | 2 + packages/core/src/shared/protocol.ts | 119 +++++++++++++----- packages/core/src/util/compatSchema.ts | 41 ++++++ .../core/test/shared/customMethods.test.ts | 117 +++++++++++++++++ packages/server/src/server/server.ts | 26 +++- 14 files changed, 418 insertions(+), 43 deletions(-) create mode 100644 .changeset/custom-method-overloads.md create mode 100644 examples/client/src/customMethodExample.ts create mode 100644 examples/server/src/customMethodExample.ts create mode 100644 packages/core/src/util/compatSchema.ts create mode 100644 packages/core/test/shared/customMethods.test.ts diff --git a/.changeset/custom-method-overloads.md b/.changeset/custom-method-overloads.md new file mode 100644 index 000000000..4077f484c --- /dev/null +++ b/.changeset/custom-method-overloads.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +`setRequestHandler`/`setNotificationHandler` accept the v1 `(ZodSchema, handler)` form as a first-class alternative to `(methodString, handler)`. `request()` accepts an explicit result schema (`request(req, resultSchema, options?)`) and has a method-keyed return type for spec methods. `callTool(params, resultSchema?)` accepts the v1 schema arg (ignored). `removeRequestHandler`/`removeNotificationHandler`/`assertCanSetRequestHandler` accept any method string. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index a37b5e206..c40c458a7 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -377,6 +377,15 @@ 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 — work on `Client`/`Server` directly using the same v1 Zod-schema form: + +| Form | Notes | +| ------------------------------------------------------------ | --------------------------------------------------------------------- | +| `setRequestHandler(CustomReqSchema, (req, ctx) => ...)` | unchanged | +| `setNotificationHandler(CustomNotifSchema, n => ...)` | unchanged | +| `this.request({ method: 'vendor/x', params }, ResultSchema)` | unchanged | +| `this.notification({ method: 'vendor/x', params })` | unchanged | + ## 10. Request Handler Context Types `RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks. diff --git a/docs/migration.md b/docs/migration.md index 7cb7d58f6..b758b5975 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -382,9 +382,24 @@ Common method string replacements: | `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` | | `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` | -### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter +### Custom (non-standard) protocol methods -The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` 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 +Vendor-specific methods are registered directly on `Client` or `Server` using the same Zod-schema form as v1: `setRequestHandler(zodSchemaWithMethodLiteral, handler)`. `request({ method, params }, ResultSchema)` and `notification({ method, params })` are unchanged from v1. + +```typescript +import { Server } from '@modelcontextprotocol/server'; + +const server = new Server({ name: 'app', version: '1.0.0' }, { capabilities: {} }); + +server.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] })); + +// Calling from a Client — unchanged from v1: +const result = await client.request({ method: 'acme/search', params: { query: 'x' } }, SearchResult); +``` + +### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` schema parameter is now optional + +The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods still accept a result schema argument, but for spec methods it is optional — the SDK resolves the correct schema internally from the method name. You no longer need to import result schemas like `CallToolResultSchema` or `ElicitResultSchema` when making requests. **`client.request()` — Before (v1):** diff --git a/examples/client/README.md b/examples/client/README.md index 12a2b0d68..8eca78879 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | | Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Custom (non-standard) methods client | Sends `acme/*` custom requests and handles custom server notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts new file mode 100644 index 000000000..f9ed3ba52 --- /dev/null +++ b/examples/client/src/customMethodExample.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Calling vendor-specific (non-spec) JSON-RPC methods from a `Client`. + * + * - Send a custom request: `client.request({ method, params }, resultSchema)` + * - Send a custom notification: `client.notification({ method, params })` + * - Receive a custom notification: `client.setNotificationHandler(ZodSchemaWithMethodLiteral, handler)` + * + * Pair with the server in examples/server/src/customMethodExample.ts. + */ + +import { Client, StdioClientTransport } from '@modelcontextprotocol/client'; +import { z } from 'zod'; + +const SearchResult = z.object({ hits: z.array(z.string()) }); + +const ProgressNotification = z.object({ + method: z.literal('acme/searchProgress'), + params: z.object({ stage: z.string(), pct: z.number() }) +}); + +const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} }); + +client.setNotificationHandler(ProgressNotification, n => { + console.log(`[client] progress: ${n.params.stage} ${n.params.pct}%`); +}); + +await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] })); + +const r = await client.request({ method: 'acme/search', params: { query: 'widgets' } }, SearchResult); +console.log('[client] hits=' + JSON.stringify(r.hits)); + +await client.notification({ method: 'acme/tick', params: { n: 1 } }); +await client.notification({ method: 'acme/tick', params: { n: 2 } }); + +await client.close(); diff --git a/examples/server/README.md b/examples/server/README.md index 384e4f2c2..1a217de0e 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts | Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Custom (non-standard) methods server | Registers `acme/*` custom request handlers and sends custom notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts new file mode 100644 index 000000000..fd92e47fa --- /dev/null +++ b/examples/server/src/customMethodExample.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Registering vendor-specific (non-spec) JSON-RPC methods on a `Server`. + * + * Custom methods use the Zod-schema form of `setRequestHandler` / `setNotificationHandler`: + * pass a Zod object schema whose `method` field is `z.literal('')`. The same overload + * is available on `Client` (for server→client custom methods). + * + * To call these from the client side, use: + * await client.request({ method: 'acme/search', params: { query: 'widgets' } }, SearchResult) + * await client.notification({ method: 'acme/tick', params: { n: 1 } }) + * See examples/client/src/customMethodExample.ts. + */ + +import { Server, StdioServerTransport } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +const SearchRequest = z.object({ + method: z.literal('acme/search'), + params: z.object({ query: z.string() }) +}); + +const TickNotification = z.object({ + method: z.literal('acme/tick'), + params: z.object({ n: z.number() }) +}); + +const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); + +server.setRequestHandler(SearchRequest, request => { + console.log('[server] acme/search query=' + request.params.query); + return { hits: [request.params.query, request.params.query + '-result'] }; +}); + +server.setNotificationHandler(TickNotification, n => { + console.log('[server] acme/tick n=' + n.params.n); +}); + +await server.connect(new StdioServerTransport()); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 21a43bd15..9fea2e0e9 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2,6 +2,7 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims' import type { BaseContext, CallToolRequest, + CallToolResult, ClientCapabilities, ClientContext, ClientNotification, @@ -24,16 +25,19 @@ import type { NotificationMethod, ProtocolOptions, ReadResourceRequest, + Request, RequestMethod, RequestOptions, RequestTypeMap, + Result, ResultTypeMap, ServerCapabilities, SubscribeRequest, TaskManagerOptions, Tool, Transport, - UnsubscribeRequest + UnsubscribeRequest, + ZodLikeRequestSchema } from '@modelcontextprotocol/core'; import { assertClientRequestTaskCapability, @@ -50,6 +54,7 @@ import { extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, + isZodLikeSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, ListPromptsResultSchema, @@ -336,9 +341,21 @@ export class Client extends Protocol { public override setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise - ): void { + ): void; + public override setRequestHandler( + requestSchema: T, + handler: (request: ReturnType, ctx: ClientContext) => Result | Promise + ): void; + public override setRequestHandler(method: string | ZodLikeRequestSchema, schemaHandler: unknown): void { + if (isZodLikeSchema(method)) { + return this._registerCompatRequestHandler( + method, + schemaHandler as (request: unknown, ctx: ClientContext) => Result | Promise + ); + } + const handler = schemaHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise; if (method === 'elicitation/create') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { + const wrappedHandler = async (request: Request, ctx: ClientContext): Promise => { const validatedRequest = parseSchema(ElicitRequestSchema, request); if (!validatedRequest.success) { // Type guard: if success is false, error is guaranteed to exist @@ -404,11 +421,11 @@ export class Client extends Protocol { }; // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + return this._setRequestHandlerByMethod(method, wrappedHandler); } if (method === 'sampling/createMessage') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { + const wrappedHandler = async (request: Request, ctx: ClientContext): Promise => { const validatedRequest = parseSchema(CreateMessageRequestSchema, request); if (!validatedRequest.success) { const errorMessage = @@ -447,11 +464,11 @@ export class Client extends Protocol { }; // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + return this._setRequestHandlerByMethod(method, wrappedHandler); } // Other handlers use default behavior - return super.setRequestHandler(method, handler); + return this._setRequestHandlerByMethod(method, handler); } protected assertCapability(capability: keyof ServerCapabilities, method: string): void { @@ -867,7 +884,18 @@ export class Client extends Protocol { * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise; + /** Result schema is resolved automatically; the second argument is accepted for v1 source compatibility and ignored. */ + async callTool(params: CallToolRequest['params'], resultSchema: unknown, options?: RequestOptions): Promise; + async callTool( + params: CallToolRequest['params'], + optionsOrSchema?: RequestOptions | unknown, + maybeOptions?: RequestOptions + ): Promise { + const options: RequestOptions | undefined = + optionsOrSchema && typeof optionsOrSchema === 'object' && 'parse' in optionsOrSchema + ? maybeOptions + : (optionsOrSchema as RequestOptions | undefined); // Guard: required-task tools need experimental API if (this.isToolTaskRequired(params.name)) { throw new ProtocolError( diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 2dc1e13a8..227cd3e24 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -49,6 +49,7 @@ export type { ServerContext } from '../../shared/protocol.js'; export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js'; +export type { ZodLikeRequestSchema } from '../../util/compatSchema.js'; // Task manager types (NOT TaskManager class itself — internal) export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e707d9939..fb39c2868 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,4 +48,6 @@ export * from './validators/fromJsonSchema.js'; */ // Core types only - implementations are exported via separate entry points +export type { ZodLikeRequestSchema } from './util/compatSchema.js'; +export { extractMethodLiteral, isZodLikeSchema } from './util/compatSchema.js'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab6932..ca1f0b847 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -44,6 +44,8 @@ import { ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; +import type { ZodLikeRequestSchema } from '../util/compatSchema.js'; +import { extractMethodLiteral, isZodLikeSchema } from '../util/compatSchema.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; @@ -390,8 +392,10 @@ export abstract class Protocol { } /** - * Builds the context object for request handlers. Subclasses must override - * to return the appropriate context type (e.g., ServerContext adds HTTP request info). + * Builds the context object for request handlers. + * + * Subclasses implement this to enrich the {@linkcode BaseContext} (e.g. `Server` adds `http` + * and `mcpReq.log` to produce `ServerContext`). */ protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; @@ -757,33 +761,43 @@ export abstract class Protocol { protected abstract assertRequestHandlerCapability(method: string): void; /** - * A method to check if the remote side supports task creation for the given method. - * - * Called when sending a task-augmented outbound request (only when enforceStrictCapabilities is true). + * A method to check if a task creation is supported by the remote side, for the given method to be called. + * This is called by request when a task-augmented request is being sent and enforceStrictCapabilities is true. * This should be implemented by subclasses. */ protected abstract assertTaskCapability(method: string): void; /** - * A method to check if this side supports handling task creation for the given method. - * - * Called when receiving a task-augmented inbound request. + * A method to check if task creation is supported by the local side, for the given method to be handled. + * This is called when a task-augmented request is received. * This should be implemented by subclasses. */ protected abstract assertTaskHandlerCapability(method: string): void; /** - * Sends a request and waits for a response, resolving the result schema - * automatically from the method name. + * Sends a request and waits for a response. * - * Do not use this method to emit notifications! Use {@linkcode Protocol.notification | notification()} instead. + * Two call forms: + * - **Spec method** — `request({ method: 'tools/call', params }, options?)`. The result schema + * is resolved automatically from the method name and the return type is `ResultTypeMap[M]`. + * - **With explicit result schema** — `request({ method, params }, resultSchema, options?)`. + * The result is validated against the supplied schema and typed by it. Use this for non-spec + * methods, or to supply a custom result shape for a spec method. + * + * Do not use this method to emit notifications! Use + * {@linkcode Protocol.notification | notification()} instead. */ request( request: { method: M; params?: Record }, options?: RequestOptions - ): Promise { - const resultSchema = getResultSchema(request.method); - return this._requestWithSchema(request as Request, resultSchema, options) as Promise; + ): Promise; + request(request: Request, resultSchema: T, options?: RequestOptions): Promise>; + request(request: Request, optionsOrSchema?: RequestOptions | AnySchema, maybeOptions?: RequestOptions): Promise { + if (optionsOrSchema && '~standard' in optionsOrSchema) { + return this._requestWithSchema(request, optionsOrSchema, maybeOptions); + } + const schema = getResultSchema(request.method as RequestMethod); + return this._requestWithSchema(request, schema, optionsOrSchema); } /** @@ -809,7 +823,7 @@ export abstract class Protocol { }; if (!this._transport) { - earlyReject(new Error('Not connected')); + earlyReject(new SdkError(SdkErrorCode.NotConnected, 'Not connected')); return; } @@ -1001,19 +1015,54 @@ export abstract class Protocol { } /** - * Registers a handler to invoke when this protocol object receives a request with the given method. + * Registers a handler to invoke when this protocol object receives a request with the given + * method. Replaces any previous handler for the same method. * - * Note that this will replace any previous request handler for the same method. + * Call forms: + * - **Spec method** — `setRequestHandler('tools/call', (request, ctx) => …)`. + * The full `RequestTypeMap[M]` request object is validated by the SDK and passed to the + * handler. This is the form `Client`/`Server` use and override. + * - **Zod schema** — `setRequestHandler(RequestZodSchema, (request, ctx) => …)`. The method + * name is read from the schema's `method` literal; the handler receives the parsed request. */ setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise + ): void; + setRequestHandler( + requestSchema: T, + handler: (request: ReturnType, ctx: ContextT) => Result | Promise + ): void; + setRequestHandler(method: string | ZodLikeRequestSchema, handler: (request: Request, ctx: ContextT) => Result | Promise): void { + if (isZodLikeSchema(method)) { + return this._registerCompatRequestHandler(method, handler as (request: unknown, ctx: ContextT) => Result | Promise); + } + this._setRequestHandlerByMethod(method, handler); + } + + /** + * Dispatches the Zod-schema form of `setRequestHandler` — extracts the method literal from the + * schema and registers a handler that parses the full request through it. Called by + * `Client`/`Server` overrides to avoid forwarding through their own overload set. + */ + protected _registerCompatRequestHandler( + requestSchema: ZodLikeRequestSchema, + handler: (request: unknown, ctx: ContextT) => Result | Promise ): void { - this.assertRequestHandlerCapability(method); - const schema = getRequestSchema(method); + const methodStr = extractMethodLiteral(requestSchema); + this.assertRequestHandlerCapability(methodStr); + this._requestHandlers.set(methodStr, (request, ctx) => Promise.resolve(handler(requestSchema.parse(request), ctx))); + } + /** + * Registers a request handler by method string, bypassing the public overload set. + * Used by `Client`/`Server` overrides to forward without `as RequestMethod` casts. + */ + protected _setRequestHandlerByMethod(method: string, handler: (request: Request, ctx: ContextT) => Result | Promise): void { + this.assertRequestHandlerCapability(method); + const schema = getRequestSchema(method as RequestMethod); this._requestHandlers.set(method, (request, ctx) => { - const parsed = schema.parse(request) as RequestTypeMap[M]; + const parsed = schema.parse(request) as Request; return Promise.resolve(handler(parsed, ctx)); }); } @@ -1021,30 +1070,44 @@ export abstract class Protocol { /** * Removes the request handler for the given method. */ - removeRequestHandler(method: RequestMethod): void { + removeRequestHandler(method: string): void { this._requestHandlers.delete(method); } /** * Asserts that a request handler has not already been set for the given method, in preparation for a new one being automatically installed. */ - assertCanSetRequestHandler(method: RequestMethod): void { + assertCanSetRequestHandler(method: string): void { if (this._requestHandlers.has(method)) { throw new Error(`A request handler for ${method} already exists, which would be overridden`); } } /** - * Registers a handler to invoke when this protocol object receives a notification with the given method. + * Registers a handler to invoke when this protocol object receives a notification with the + * given method. Replaces any previous handler for the same method. * - * Note that this will replace any previous notification handler for the same method. + * Mirrors {@linkcode setRequestHandler}: a spec-method form (handler receives the full + * notification object) and a Zod-schema form (method read from the schema's `method` literal). */ setNotificationHandler( method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise - ): void { - const schema = getNotificationSchema(method); - + ): void; + setNotificationHandler( + notificationSchema: T, + handler: (notification: ReturnType) => void | Promise + ): void; + setNotificationHandler(method: string | ZodLikeRequestSchema, handler: (notification: Notification) => void | Promise): void { + if (isZodLikeSchema(method)) { + const notificationSchema = method; + const methodStr = extractMethodLiteral(notificationSchema); + this._notificationHandlers.set(methodStr, n => + Promise.resolve((handler as (n: unknown) => void | Promise)(notificationSchema.parse(n))) + ); + return; + } + const schema = getNotificationSchema(method as NotificationMethod); this._notificationHandlers.set(method, notification => { const parsed = schema.parse(notification); return Promise.resolve(handler(parsed)); @@ -1054,7 +1117,7 @@ export abstract class Protocol { /** * Removes the notification handler for the given method. */ - removeNotificationHandler(method: NotificationMethod): void { + removeNotificationHandler(method: string): void { this._notificationHandlers.delete(method); } } diff --git a/packages/core/src/util/compatSchema.ts b/packages/core/src/util/compatSchema.ts new file mode 100644 index 000000000..63956c97b --- /dev/null +++ b/packages/core/src/util/compatSchema.ts @@ -0,0 +1,41 @@ +/** + * Helpers for the Zod-schema form of `setRequestHandler` / `setNotificationHandler`. + * + * v1 accepted a Zod object whose `.shape.method` is `z.literal('')`. + * v2 also accepts the method string directly. These helpers detect the schema + * form and extract the literal so the dispatcher can route to the correct path. + * + * @internal + */ + +/** + * Minimal structural type for a Zod object schema. The `method` literal is + * checked at runtime by `extractMethodLiteral`; the type-level constraint + * is intentionally loose because zod v4's `ZodLiteral` doesn't surface `.value` + * in its declared type (only at runtime). + */ +export interface ZodLikeRequestSchema { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shape: any; + parse(input: unknown): unknown; +} + +/** True if `arg` looks like a Zod object schema (has `.shape` and `.parse`). */ +export function isZodLikeSchema(arg: unknown): arg is ZodLikeRequestSchema { + return typeof arg === 'object' && arg !== null && 'shape' in arg && typeof (arg as { parse?: unknown }).parse === 'function'; +} + +/** + * Extracts the string value from a Zod-like schema's `shape.method` literal. + * Throws if no string `method` literal is present. + */ +export function extractMethodLiteral(schema: ZodLikeRequestSchema): string { + const methodField = (schema.shape as Record | undefined)?.method as + | { value?: unknown; def?: { values?: unknown[] } } + | undefined; + const value = methodField?.value ?? methodField?.def?.values?.[0]; + if (typeof value !== 'string') { + throw new TypeError('Schema passed to setRequestHandler/setNotificationHandler is missing a string `method` literal'); + } + return value; +} diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts new file mode 100644 index 000000000..22d1565cd --- /dev/null +++ b/packages/core/test/shared/customMethods.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +// Minimal concrete Protocol for tests; capability checks are no-ops. +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 makePair() { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const a = new TestProtocol(); + const b = new TestProtocol(); + await a.connect(t1); + await b.connect(t2); + return { a, b }; +} + +const EchoRequest = z.object({ method: z.literal('acme/echo'), params: z.object({ msg: z.string() }) }); +const TickNotification = z.object({ method: z.literal('acme/tick'), params: z.object({ n: z.number() }) }); + +describe('setRequestHandler — Zod-schema form', () => { + it('round-trips a custom request via Zod schema', async () => { + const { a, b } = await makePair(); + b.setRequestHandler(EchoRequest, req => ({ reply: req.params.msg.toUpperCase() })); + const result = await a.request({ method: 'acme/echo', params: { msg: 'hi' } }, z.object({ reply: z.string() })); + expect(result).toEqual({ reply: 'HI' }); + }); + + it('rejects invalid params via the Zod schema', async () => { + const { a, b } = await makePair(); + b.setRequestHandler(EchoRequest, req => ({ reply: req.params.msg })); + await expect(a.request({ method: 'acme/echo', params: { msg: 42 } }, z.object({ reply: z.string() }))).rejects.toThrow(); + }); + + it('removeRequestHandler works for any method string', async () => { + const { a, b } = await makePair(); + b.setRequestHandler(EchoRequest, req => ({ reply: req.params.msg })); + await expect(a.request({ method: 'acme/echo', params: { msg: 'x' } }, z.object({ reply: z.string() }))).resolves.toEqual({ + reply: 'x' + }); + b.removeRequestHandler('acme/echo'); + await expect(a.request({ method: 'acme/echo', params: { msg: 'x' } }, z.object({ reply: z.string() }))).rejects.toThrow( + /Method not found/ + ); + }); + + it('two-arg spec-method form still works', async () => { + const { a, b } = await makePair(); + let pinged = false; + b.setRequestHandler('ping', () => { + pinged = true; + return {}; + }); + await a.request({ method: 'ping' }); + expect(pinged).toBe(true); + }); +}); + +describe('setNotificationHandler — Zod-schema form', () => { + it('receives a custom notification via Zod schema', async () => { + const { a, b } = await makePair(); + const received: unknown[] = []; + b.setNotificationHandler(TickNotification, n => { + received.push(n.params); + }); + await a.notification({ method: 'acme/tick', params: { n: 1 } }); + await a.notification({ method: 'acme/tick', params: { n: 2 } }); + await new Promise(r => setTimeout(r, 0)); + expect(received).toEqual([{ n: 1 }, { n: 2 }]); + }); + + it('two-arg spec-method form still works', async () => { + const { a, b } = await makePair(); + let got = false; + b.setNotificationHandler('notifications/initialized', () => { + got = true; + }); + await a.notification({ method: 'notifications/initialized' }); + await new Promise(r => setTimeout(r, 0)); + expect(got).toBe(true); + }); +}); + +describe('request() — explicit result schema overload', () => { + it('uses the supplied result schema for a non-spec method', async () => { + const { a, b } = await makePair(); + b.setRequestHandler(EchoRequest, req => ({ reply: req.params.msg })); + const r = await a.request({ method: 'acme/echo', params: { msg: 'ok' } }, z.object({ reply: z.string() })); + expect(r.reply).toBe('ok'); + }); + + it('spec method without schema uses method-keyed return type', async () => { + const { a, b } = await makePair(); + b.setRequestHandler('ping', () => ({})); + const r = await a.request({ method: 'ping' }); + expect(r).toEqual({}); + }); +}); + +describe('notification() mock-assignability', () => { + it('single-signature notification() is assignable from a simple mock (compile-time check)', () => { + const p = new TestProtocol(); + p.notification = async (_n: { method: string }) => {}; + expect(typeof p.notification).toBe('function'); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4361f3e1e..9327ac1f1 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -21,17 +21,20 @@ import type { NotificationMethod, NotificationOptions, ProtocolOptions, + Request, RequestMethod, RequestOptions, RequestTypeMap, ResourceUpdatedNotification, + Result, ResultTypeMap, ServerCapabilities, ServerContext, ServerResult, TaskManagerOptions, ToolResultContent, - ToolUseContent + ToolUseContent, + ZodLikeRequestSchema } from '@modelcontextprotocol/core'; import { assertClientRequestTaskCapability, @@ -44,6 +47,7 @@ import { ElicitResultSchema, EmptyResultSchema, extractTaskManagerOptions, + isZodLikeSchema, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -225,9 +229,21 @@ export class Server extends Protocol { public override setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise - ): void { + ): void; + public override setRequestHandler( + requestSchema: T, + handler: (request: ReturnType, ctx: ServerContext) => Result | Promise + ): void; + public override setRequestHandler(method: string | ZodLikeRequestSchema, schemaHandler: unknown): void { + if (isZodLikeSchema(method)) { + return this._registerCompatRequestHandler( + method, + schemaHandler as (request: unknown, ctx: ServerContext) => Result | Promise + ); + } + const handler = schemaHandler as (request: Request, ctx: ServerContext) => ServerResult | Promise; if (method === 'tools/call') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ServerContext): Promise => { + const wrappedHandler = async (request: Request, ctx: ServerContext): Promise => { const validatedRequest = parseSchema(CallToolRequestSchema, request); if (!validatedRequest.success) { const errorMessage = @@ -264,11 +280,11 @@ export class Server extends Protocol { }; // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + return this._setRequestHandlerByMethod(method, wrappedHandler); } // Other handlers use default behavior - return super.setRequestHandler(method, handler); + return this._setRequestHandlerByMethod(method, handler); } protected assertCapabilityForMethod(method: RequestMethod): void { From cc7bc8b2b266627bc5c3e1498cca9e575a671d03 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 19:22:59 +0000 Subject: [PATCH 02/14] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20cal?= =?UTF-8?q?lTool(params,=20undefined,=20opts)=20drops=20opts;=20migration.?= =?UTF-8?q?md=20mcpReq.send=20claim;=20complete=20custom-method=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/migration-SKILL.md | 24 +++++------ docs/migration.md | 6 +-- examples/client/README.md | 2 +- examples/server/README.md | 2 +- examples/server/src/customMethodExample.ts | 7 +++- packages/client/src/client/client.ts | 10 +++-- .../test/client/callTool.compat.test.ts | 41 +++++++++++++++++++ 7 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 packages/client/test/client/callTool.compat.test.ts diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index c40c458a7..d9ffabc61 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -204,7 +204,7 @@ if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) ``` **Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all -Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11). +Zod schemas, all callback return types. Note: `callTool()` and `request()` schema parameter is now optional (see section 11). ## 6. McpServer API Changes @@ -416,9 +416,9 @@ Request/notification params remain fully typed. Remove unused schema imports aft | `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler | | `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler | -## 11. Schema parameter removed from `request()`, `send()`, and `callTool()` +## 11. Schema parameter on `request()` / `callTool()` is optional; removed from `send()` -`Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` no longer take a Zod result schema argument. The SDK resolves the schema internally from the method name. +`Protocol.request()` and `Client.callTool()` still accept a Zod result schema as the second argument (the v1 form), but it is optional for spec methods — the SDK resolves the schema internally from the method name. `BaseContext.mcpReq.send()` no longer takes a schema. ```typescript // v1: schema required @@ -427,20 +427,20 @@ const result = await client.request({ method: 'tools/call', params: { ... } }, C const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { ... } }, ElicitResultSchema); const tool = await client.callTool({ name: 'my-tool', arguments: {} }, CompatibilityCallToolResultSchema); -// v2: no schema argument +// v2: schema optional on request()/callTool(); removed from mcpReq.send() const result = await client.request({ method: 'tools/call', params: { ... } }); 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)` | -| `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)` | +| v1 call | v2 call | +| ------------------------------------------------------------ | ---------------------------------------------- | +| `client.request(req, ResultSchema)` | unchanged (schema optional), or `client.request(req)` | +| `client.request(req, ResultSchema, options)` | unchanged, or `client.request(req, 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)` | unchanged (schema ignored), or `client.callTool(params)` | +| `client.callTool(params, schema, options)` | unchanged, or `client.callTool(params, options)` | Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. diff --git a/docs/migration.md b/docs/migration.md index b758b5975..b9625b70e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -397,10 +397,10 @@ server.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] const result = await client.request({ method: 'acme/search', params: { query: 'x' } }, SearchResult); ``` -### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` schema parameter is now optional +### `Protocol.request()` and `Client.callTool()` schema parameter is now optional -The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods still accept a result schema argument, but for spec methods it is optional — the SDK resolves the correct schema internally from the method name. You no longer need to import result schemas -like `CallToolResultSchema` or `ElicitResultSchema` when making requests. +The public `Protocol.request()` and `Client.callTool()` methods still accept a result schema argument, but for spec methods it is optional — the SDK resolves the correct schema internally from the method name. You no longer need to import result schemas +like `CallToolResultSchema` or `ElicitResultSchema` when making requests. (`BaseContext.mcpReq.send()` no longer accepts a schema; drop it.) **`client.request()` — Before (v1):** diff --git a/examples/client/README.md b/examples/client/README.md index 8eca78879..5df1ad826 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -36,7 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | | Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | -| Custom (non-standard) methods client | Sends `acme/*` custom requests and handles custom server notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | +| Custom (non-standard) methods client | Sends `acme/*` custom requests + notifications and handles custom progress notifications from the server. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## URL elicitation example (server + client) diff --git a/examples/server/README.md b/examples/server/README.md index 1a217de0e..c22eb046a 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -38,7 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts | Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | -| Custom (non-standard) methods server | Registers `acme/*` custom request handlers and sends custom notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | +| Custom (non-standard) methods server | Registers `acme/*` custom request + notification handlers and emits custom progress notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts index fd92e47fa..3910cfa5d 100644 --- a/examples/server/src/customMethodExample.ts +++ b/examples/server/src/customMethodExample.ts @@ -27,9 +27,12 @@ const TickNotification = z.object({ const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); -server.setRequestHandler(SearchRequest, request => { +server.setRequestHandler(SearchRequest, async (request, ctx) => { console.log('[server] acme/search query=' + request.params.query); - return { hits: [request.params.query, request.params.query + '-result'] }; + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); + const hits = [request.params.query, request.params.query + '-result']; + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 100 } }); + return { hits }; }); server.setNotificationHandler(TickNotification, n => { diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 9fea2e0e9..f81e58c86 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -892,10 +892,12 @@ export class Client extends Protocol { optionsOrSchema?: RequestOptions | unknown, maybeOptions?: RequestOptions ): Promise { - const options: RequestOptions | undefined = - optionsOrSchema && typeof optionsOrSchema === 'object' && 'parse' in optionsOrSchema - ? maybeOptions - : (optionsOrSchema as RequestOptions | undefined); + const arg2IsSchema = optionsOrSchema != null && typeof optionsOrSchema === 'object' && 'parse' in optionsOrSchema; + // v1 allowed `callTool(params, undefined, opts)` (resultSchema was optional-with-default); + // when arg2 is not a schema, prefer arg3 if present so opts aren't dropped. + const options: RequestOptions | undefined = arg2IsSchema + ? maybeOptions + : (maybeOptions ?? (optionsOrSchema as RequestOptions | undefined)); // Guard: required-task tools need experimental API if (this.isToolTaskRequired(params.name)) { throw new ProtocolError( diff --git a/packages/client/test/client/callTool.compat.test.ts b/packages/client/test/client/callTool.compat.test.ts new file mode 100644 index 000000000..087298d82 --- /dev/null +++ b/packages/client/test/client/callTool.compat.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Client } from '../../src/client/client.js'; + +describe('callTool v1-compat overload dispatch', () => { + function makeClient() { + const client = new Client({ name: 't', version: '1.0.0' }, { capabilities: {} }); + const spy = vi + .spyOn(client as unknown as { _requestWithSchema: (...a: unknown[]) => Promise }, '_requestWithSchema') + .mockResolvedValue({ content: [] }); + return { client, spy }; + } + + it('callTool(params, undefined, options) preserves options (v1: optional resultSchema)', async () => { + const { client, spy } = makeClient(); + const opts = { timeout: 5000 }; + await client.callTool({ name: 'x', arguments: {} }, undefined, opts); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[2]).toBe(opts); + }); + + it('callTool(params, schema, options) preserves options', async () => { + const { client, spy } = makeClient(); + const opts = { timeout: 5000 }; + const schema = { parse: (x: unknown) => x }; + await client.callTool({ name: 'x', arguments: {} }, schema, opts); + expect(spy.mock.calls[0]?.[2]).toBe(opts); + }); + + it('callTool(params, options) — 2-arg form still works', async () => { + const { client, spy } = makeClient(); + const opts = { timeout: 5000 }; + await client.callTool({ name: 'x', arguments: {} }, opts); + expect(spy.mock.calls[0]?.[2]).toBe(opts); + }); + + it('callTool(params) — no options', async () => { + const { client, spy } = makeClient(); + await client.callTool({ name: 'x', arguments: {} }); + expect(spy.mock.calls[0]?.[2]).toBeUndefined(); + }); +}); From 3bd5454182d1038b8d8c40c4bebd1f61fd678bfc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 19:57:23 +0000 Subject: [PATCH 03/14] docs: mark v1-compat schema-arg overloads @deprecated; lint:fix prettier --- examples/client/README.md | 24 ++++++++++---------- examples/server/README.md | 26 +++++++++++----------- packages/client/src/client/client.ts | 3 ++- packages/client/src/validators/cfWorker.ts | 2 +- packages/core/src/shared/protocol.ts | 3 +++ packages/server/src/server/server.ts | 1 + packages/server/src/validators/cfWorker.ts | 2 +- 7 files changed, 33 insertions(+), 28 deletions(-) diff --git a/examples/client/README.md b/examples/client/README.md index 5df1ad826..d20984e2f 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -24,18 +24,18 @@ Most clients expect a server to be running. Start one from [`../server/README.md ## Example index -| Scenario | Description | File | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | -| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | -| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | -| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | -| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | -| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | -| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | -| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Scenario | Description | File | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | +| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | +| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | +| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | +| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | +| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | +| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | +| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | | Custom (non-standard) methods client | Sends `acme/*` custom requests + notifications and handles custom progress notifications from the server. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## URL elicitation example (server + client) diff --git a/examples/server/README.md b/examples/server/README.md index c22eb046a..cf0d9313c 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -25,19 +25,19 @@ pnpm tsx src/simpleStreamableHttp.ts ## Example index -| Scenario | Description | File | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | -| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | -| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | -| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | -| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | -| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | -| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | -| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | -| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Scenario | Description | File | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | +| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | +| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | +| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | +| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | +| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | +| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | +| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | +| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | | Custom (non-standard) methods server | Registers `acme/*` custom request + notification handlers and emits custom progress notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index f81e58c86..52dd1a25e 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -342,6 +342,7 @@ export class Client extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise ): void; + /** @deprecated Pass the method string instead. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ClientContext) => Result | Promise @@ -885,7 +886,7 @@ export class Client extends Protocol { * ``` */ async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise; - /** Result schema is resolved automatically; the second argument is accepted for v1 source compatibility and ignored. */ + /** @deprecated The result schema is resolved internally; use `callTool(params)`. The second argument is accepted for v1 source compatibility and ignored. */ async callTool(params: CallToolRequest['params'], resultSchema: unknown, options?: RequestOptions): Promise; async callTool( params: CallToolRequest['params'], diff --git a/packages/client/src/validators/cfWorker.ts b/packages/client/src/validators/cfWorker.ts index b068e69a1..7d1c843e5 100644 --- a/packages/client/src/validators/cfWorker.ts +++ b/packages/client/src/validators/cfWorker.ts @@ -6,5 +6,5 @@ * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/client/validators/cf-worker'; * ``` */ -export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core'; +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index ca1f0b847..1a61f7fb3 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -791,6 +791,7 @@ export abstract class Protocol { request: { method: M; params?: Record }, options?: RequestOptions ): Promise; + /** @deprecated The result schema is resolved internally; use `request(req)`. */ request(request: Request, resultSchema: T, options?: RequestOptions): Promise>; request(request: Request, optionsOrSchema?: RequestOptions | AnySchema, maybeOptions?: RequestOptions): Promise { if (optionsOrSchema && '~standard' in optionsOrSchema) { @@ -1029,6 +1030,7 @@ export abstract class Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise ): void; + /** @deprecated Pass the method string instead. */ setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ContextT) => Result | Promise @@ -1094,6 +1096,7 @@ export abstract class Protocol { method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise ): void; + /** @deprecated Pass the method string instead. */ setNotificationHandler( notificationSchema: T, handler: (notification: ReturnType) => void | Promise diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 9327ac1f1..145957e10 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -230,6 +230,7 @@ export class Server extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise ): void; + /** @deprecated Pass the method string instead. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ServerContext) => Result | Promise diff --git a/packages/server/src/validators/cfWorker.ts b/packages/server/src/validators/cfWorker.ts index 9a3a88405..e04436dbd 100644 --- a/packages/server/src/validators/cfWorker.ts +++ b/packages/server/src/validators/cfWorker.ts @@ -6,5 +6,5 @@ * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; * ``` */ -export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core'; +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; From 0a932fe621f6904ab891609c935e279df48bead3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 21:28:32 +0000 Subject: [PATCH 04/14] fix: schema-arg setRequestHandler bypassed per-method wrapping in Client/Server Normalize schema-arg to method string + parse-wrapped handler, then fall through to the per-method dispatch (tools/call task validation, elicitation/create capability checks). Previously the schema form short-circuited via _registerCompatRequestHandler, so e.g. setRequestHandler(CallToolRequestSchema, h) and setRequestHandler('tools/call', h) had different runtime behavior. Also reword @deprecated message: schema form is not deprecated for non-spec methods (the method-string overload is constrained to RequestMethod), so the advice now reads 'For spec methods, pass the method string instead.' --- packages/client/src/client/client.ts | 21 +++++--- packages/core/src/shared/protocol.ts | 4 +- packages/server/src/server/server.ts | 21 +++++--- .../setRequestHandlerSchemaParity.test.ts | 49 +++++++++++++++++++ 4 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 packages/server/test/server/setRequestHandlerSchemaParity.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 52dd1a25e..8e3556846 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -51,6 +51,7 @@ import { ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, + extractMethodLiteral, extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, @@ -342,19 +343,23 @@ export class Client extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise ): void; - /** @deprecated Pass the method string instead. */ + /** @deprecated For spec methods, pass the method string instead. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ClientContext) => Result | Promise ): void; - public override setRequestHandler(method: string | ZodLikeRequestSchema, schemaHandler: unknown): void { - if (isZodLikeSchema(method)) { - return this._registerCompatRequestHandler( - method, - schemaHandler as (request: unknown, ctx: ClientContext) => Result | Promise - ); + public override setRequestHandler(methodOrSchema: string | ZodLikeRequestSchema, schemaHandler: unknown): void { + let method: string; + let handler: (request: Request, ctx: ClientContext) => ClientResult | Promise; + if (isZodLikeSchema(methodOrSchema)) { + const schema = methodOrSchema; + const userHandler = schemaHandler as (request: unknown, ctx: ClientContext) => Result | Promise; + method = extractMethodLiteral(schema); + handler = (req, ctx) => userHandler(schema.parse(req), ctx); + } else { + method = methodOrSchema; + handler = schemaHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise; } - const handler = schemaHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise; if (method === 'elicitation/create') { const wrappedHandler = async (request: Request, ctx: ClientContext): Promise => { const validatedRequest = parseSchema(ElicitRequestSchema, request); diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 1a61f7fb3..d39674e3d 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1030,7 +1030,7 @@ export abstract class Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise ): void; - /** @deprecated Pass the method string instead. */ + /** @deprecated For spec methods, pass the method string instead. */ setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ContextT) => Result | Promise @@ -1096,7 +1096,7 @@ export abstract class Protocol { method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise ): void; - /** @deprecated Pass the method string instead. */ + /** @deprecated For spec methods, pass the method string instead. */ setNotificationHandler( notificationSchema: T, handler: (notification: ReturnType) => void | Promise diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 145957e10..c6cbc1bb8 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -46,6 +46,7 @@ import { CreateTaskResultSchema, ElicitResultSchema, EmptyResultSchema, + extractMethodLiteral, extractTaskManagerOptions, isZodLikeSchema, LATEST_PROTOCOL_VERSION, @@ -230,19 +231,23 @@ export class Server extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise ): void; - /** @deprecated Pass the method string instead. */ + /** @deprecated For spec methods, pass the method string instead. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ServerContext) => Result | Promise ): void; - public override setRequestHandler(method: string | ZodLikeRequestSchema, schemaHandler: unknown): void { - if (isZodLikeSchema(method)) { - return this._registerCompatRequestHandler( - method, - schemaHandler as (request: unknown, ctx: ServerContext) => Result | Promise - ); + public override setRequestHandler(methodOrSchema: string | ZodLikeRequestSchema, schemaHandler: unknown): void { + let method: string; + let handler: (request: Request, ctx: ServerContext) => ServerResult | Promise; + if (isZodLikeSchema(methodOrSchema)) { + const schema = methodOrSchema; + const userHandler = schemaHandler as (request: unknown, ctx: ServerContext) => Result | Promise; + method = extractMethodLiteral(schema); + handler = (req, ctx) => userHandler(schema.parse(req), ctx); + } else { + method = methodOrSchema; + handler = schemaHandler as (request: Request, ctx: ServerContext) => ServerResult | Promise; } - const handler = schemaHandler as (request: Request, ctx: ServerContext) => ServerResult | Promise; if (method === 'tools/call') { const wrappedHandler = async (request: Request, ctx: ServerContext): Promise => { const validatedRequest = parseSchema(CallToolRequestSchema, request); diff --git a/packages/server/test/server/setRequestHandlerSchemaParity.test.ts b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts new file mode 100644 index 000000000..fa9ccb6f5 --- /dev/null +++ b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { CallToolRequestSchema, InMemoryTransport } from '@modelcontextprotocol/core'; + +import { Server } from '../../src/server/server.js'; + +/** + * Regression test: setRequestHandler(CallToolRequestSchema, h) and + * setRequestHandler('tools/call', h) must apply the same per-method + * wrapping (task-result validation when params.task is set). + */ +describe('Server.setRequestHandler — Zod-schema form parity', () => { + async function setup(register: (s: Server) => void) { + const server = new Server({ name: 't', version: '1.0' }, { capabilities: { tools: {} } }); + register(server); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await server.connect(st); + await ct.start(); + return { ct }; + } + + async function callToolWithTask(ct: InMemoryTransport): Promise<{ result?: unknown; error?: unknown }> { + return await new Promise(resolve => { + ct.onmessage = m => { + const msg = m as { result?: unknown; error?: unknown }; + if ('result' in msg || 'error' in msg) resolve(msg); + }; + ct.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'x', arguments: {}, task: { ttl: 1000 } } + }); + }); + } + + it('schema form gets the same task-result validation as string form', async () => { + const invalidTaskResult = { content: [{ type: 'text' as const, text: 'not a task result' }] }; + + const viaString = await setup(s => s.setRequestHandler('tools/call', () => invalidTaskResult)); + const viaSchema = await setup(s => s.setRequestHandler(CallToolRequestSchema, () => invalidTaskResult)); + + const stringRes = await callToolWithTask(viaString.ct); + const schemaRes = await callToolWithTask(viaSchema.ct); + + expect(stringRes.error).toBeDefined(); + expect(schemaRes.error).toEqual(stringRes.error); + }); +}); From c10349ddc5c193637b518bed147f8f95909b28b2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 22:12:39 +0000 Subject: [PATCH 05/14] fix(core): guard spec-schema parse for non-spec methods in handler registration The normalize-then-fall-through change routes custom methods through _setRequestHandlerByMethod, which previously assumed a spec method (unconditional getRequestSchema(method).parse). Guard with schema?. so custom methods registered via Server/Client overrides do not crash. --- packages/core/src/shared/protocol.ts | 4 ++-- .../server/setRequestHandlerSchemaParity.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d39674e3d..6dc2b1a10 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1064,7 +1064,7 @@ export abstract class Protocol { this.assertRequestHandlerCapability(method); const schema = getRequestSchema(method as RequestMethod); this._requestHandlers.set(method, (request, ctx) => { - const parsed = schema.parse(request) as Request; + const parsed = schema ? (schema.parse(request) as Request) : request; return Promise.resolve(handler(parsed, ctx)); }); } @@ -1112,7 +1112,7 @@ export abstract class Protocol { } const schema = getNotificationSchema(method as NotificationMethod); this._notificationHandlers.set(method, notification => { - const parsed = schema.parse(notification); + const parsed = schema ? schema.parse(notification) : notification; return Promise.resolve(handler(parsed)); }); } diff --git a/packages/server/test/server/setRequestHandlerSchemaParity.test.ts b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts index fa9ccb6f5..bd24c3ea8 100644 --- a/packages/server/test/server/setRequestHandlerSchemaParity.test.ts +++ b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { CallToolRequestSchema, InMemoryTransport } from '@modelcontextprotocol/core'; @@ -46,4 +47,17 @@ describe('Server.setRequestHandler — Zod-schema form parity', () => { expect(stringRes.error).toBeDefined(); expect(schemaRes.error).toEqual(stringRes.error); }); + + it('schema form handles non-spec methods through Server (no spec-schema crash)', async () => { + const Echo = z.object({ method: z.literal('acme/echo'), params: z.object({ msg: z.string() }) }); + const { ct } = await setup(s => s.setRequestHandler(Echo, req => ({ reply: req.params.msg }))); + const res = await new Promise<{ result?: unknown; error?: unknown }>(resolve => { + ct.onmessage = m => { + const msg = m as { result?: unknown; error?: unknown }; + if ('result' in msg || 'error' in msg) resolve(msg); + }; + ct.send({ jsonrpc: '2.0', id: 1, method: 'acme/echo', params: { msg: 'hi' } }); + }); + expect(res.result).toEqual({ reply: 'hi' }); + }); }); From dd2dc3cb7999ace6df3d165eeabfae0c38eceb3a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 23:37:43 +0000 Subject: [PATCH 06/14] docs(core): align request(req,resultSchema) @deprecated wording with other schema-arg overloads --- packages/core/src/shared/protocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 6dc2b1a10..e5150c0af 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -791,7 +791,7 @@ export abstract class Protocol { request: { method: M; params?: Record }, options?: RequestOptions ): Promise; - /** @deprecated The result schema is resolved internally; use `request(req)`. */ + /** @deprecated For spec methods, the result schema is resolved automatically; use `request(req)`. */ request(request: Request, resultSchema: T, options?: RequestOptions): Promise>; request(request: Request, optionsOrSchema?: RequestOptions | AnySchema, maybeOptions?: RequestOptions): Promise { if (optionsOrSchema && '~standard' in optionsOrSchema) { From 52843d35fdd0bcfb1b4642b72d7be313b5ae06bf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 08:12:37 +0000 Subject: [PATCH 07/14] feat(core): mcpReq.send accepts (req, resultSchema, opts) for non-spec methods Mirrors Protocol.request()'s schema overload so handlers can send custom-method requests via ctx.mcpReq.send. Also corrects the _registerCompatRequestHandler JSDoc (called by Protocol's own overload dispatch, not Client/Server overrides). --- packages/core/src/shared/protocol.ts | 33 +++++++++++++++++++--------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index e5150c0af..510d16fa4 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -201,11 +201,21 @@ export type BaseContext = { * Sends a request that relates to the current request being handled. * * This is used by certain transports to correctly associate related messages. + * + * Two call forms (mirrors {@linkcode Protocol.request | request()}): + * - **Spec method** — `send({ method: 'sampling/createMessage', params }, options?)`. + * The result schema is resolved from the method name and the return is typed by it. + * - **With explicit result schema** — `send({ method, params }, resultSchema, options?)` + * for non-spec methods or custom result shapes. */ - send: ( - request: { method: M; params?: Record }, - options?: TaskRequestOptions - ) => Promise; + send: { + ( + request: { method: M; params?: Record }, + options?: TaskRequestOptions + ): Promise; + /** @deprecated For spec methods, the result schema is resolved automatically; use `send(req)`. */ + (request: Request, resultSchema: T, options?: TaskRequestOptions): Promise>; + }; /** * Sends a notification that relates to the current request being handled. @@ -600,10 +610,13 @@ export abstract class Protocol { method: request.method, _meta: request.params?._meta, signal: abortController.signal, - send: (r: { method: M; params?: Record }, options?: TaskRequestOptions) => { - const resultSchema = getResultSchema(r.method); - return sendRequest(r as Request, resultSchema, options) as Promise; - }, + send: ((r: Request, optionsOrSchema?: TaskRequestOptions | AnySchema, maybeOptions?: TaskRequestOptions) => { + if (optionsOrSchema && '~standard' in optionsOrSchema) { + return sendRequest(r, optionsOrSchema, maybeOptions); + } + const resultSchema = getResultSchema(r.method as RequestMethod); + return sendRequest(r, resultSchema, optionsOrSchema); + }) as BaseContext['mcpReq']['send'], notify: sendNotification }, http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined, @@ -1044,8 +1057,8 @@ export abstract class Protocol { /** * Dispatches the Zod-schema form of `setRequestHandler` — extracts the method literal from the - * schema and registers a handler that parses the full request through it. Called by - * `Client`/`Server` overrides to avoid forwarding through their own overload set. + * schema and registers a handler that parses the full request through it. Called by the base + * {@linkcode Protocol.setRequestHandler} overload dispatcher for the schema-first signature. */ protected _registerCompatRequestHandler( requestSchema: ZodLikeRequestSchema, From dbba3a7fb7a8b18930f532939a8d532a8e5b8f27 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 08:27:21 +0000 Subject: [PATCH 08/14] refactor(core): un-deprecate schema-arg overloads; inline _registerCompatRequestHandler The schema-arg forms of request()/setRequestHandler()/setNotificationHandler() are a parallel supported style, not superseded: request(req, schema) is the only typed form for non-spec method results, and the schema-first handler form gives full-envelope validation. Replaces the @deprecated tags with plain JSDoc guidance so the PR's own custom-method examples no longer render with strikethrough. Also inlines _registerCompatRequestHandler (single callsite) and fixes the stale 'schema parameter removed' note in migration.md. --- docs/migration.md | 2 +- packages/client/src/client/client.ts | 4 ++-- packages/core/src/shared/protocol.ts | 30 ++++++++++------------------ packages/server/src/server/server.ts | 2 +- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index b9625b70e..0cc176611 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -903,7 +903,7 @@ import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/valida The following APIs are unchanged between v1 and v2 (only the import paths changed): -- `Client` constructor and most client methods (`connect`, `listTools`, `listPrompts`, `listResources`, `readResource`, etc.) — note: `callTool()` signature changed (schema parameter removed) +- `Client` constructor and most client methods (`connect`, `listTools`, `listPrompts`, `listResources`, `readResource`, etc.) — note: `callTool()` schema parameter is now optional - `McpServer` constructor, `server.connect(transport)`, `server.close()` - `Server` (low-level) constructor and all methods - `StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport` constructors and options diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8e3556846..e1ae948d7 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -343,7 +343,7 @@ export class Client extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise ): void; - /** @deprecated For spec methods, pass the method string instead. */ + /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ClientContext) => Result | Promise @@ -891,7 +891,7 @@ export class Client extends Protocol { * ``` */ async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise; - /** @deprecated The result schema is resolved internally; use `callTool(params)`. The second argument is accepted for v1 source compatibility and ignored. */ + /** The `resultSchema` argument is accepted for v1 source compatibility and ignored; output validation uses the tool's declared `outputSchema`. Prefer `callTool(params, options)`. */ async callTool(params: CallToolRequest['params'], resultSchema: unknown, options?: RequestOptions): Promise; async callTool( params: CallToolRequest['params'], diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 510d16fa4..48fba8970 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -213,7 +213,7 @@ export type BaseContext = { request: { method: M; params?: Record }, options?: TaskRequestOptions ): Promise; - /** @deprecated For spec methods, the result schema is resolved automatically; use `send(req)`. */ + /** For spec methods the one-argument form is more concise; this overload is the supported call form for non-spec methods or custom result shapes. */ (request: Request, resultSchema: T, options?: TaskRequestOptions): Promise>; }; @@ -804,7 +804,7 @@ export abstract class Protocol { request: { method: M; params?: Record }, options?: RequestOptions ): Promise; - /** @deprecated For spec methods, the result schema is resolved automatically; use `request(req)`. */ + /** For spec methods the one-argument form is more concise; this overload is the supported call form for non-spec methods or custom result shapes. */ request(request: Request, resultSchema: T, options?: RequestOptions): Promise>; request(request: Request, optionsOrSchema?: RequestOptions | AnySchema, maybeOptions?: RequestOptions): Promise { if (optionsOrSchema && '~standard' in optionsOrSchema) { @@ -1043,32 +1043,24 @@ export abstract class Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise ): void; - /** @deprecated For spec methods, pass the method string instead. */ + /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ContextT) => Result | Promise ): void; setRequestHandler(method: string | ZodLikeRequestSchema, handler: (request: Request, ctx: ContextT) => Result | Promise): void { if (isZodLikeSchema(method)) { - return this._registerCompatRequestHandler(method, handler as (request: unknown, ctx: ContextT) => Result | Promise); + const requestSchema = method; + const methodStr = extractMethodLiteral(requestSchema); + this.assertRequestHandlerCapability(methodStr); + this._requestHandlers.set(methodStr, (request, ctx) => + Promise.resolve((handler as (req: unknown, ctx: ContextT) => Result | Promise)(requestSchema.parse(request), ctx)) + ); + return; } this._setRequestHandlerByMethod(method, handler); } - /** - * Dispatches the Zod-schema form of `setRequestHandler` — extracts the method literal from the - * schema and registers a handler that parses the full request through it. Called by the base - * {@linkcode Protocol.setRequestHandler} overload dispatcher for the schema-first signature. - */ - protected _registerCompatRequestHandler( - requestSchema: ZodLikeRequestSchema, - handler: (request: unknown, ctx: ContextT) => Result | Promise - ): void { - const methodStr = extractMethodLiteral(requestSchema); - this.assertRequestHandlerCapability(methodStr); - this._requestHandlers.set(methodStr, (request, ctx) => Promise.resolve(handler(requestSchema.parse(request), ctx))); - } - /** * Registers a request handler by method string, bypassing the public overload set. * Used by `Client`/`Server` overrides to forward without `as RequestMethod` casts. @@ -1109,7 +1101,7 @@ export abstract class Protocol { method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise ): void; - /** @deprecated For spec methods, pass the method string instead. */ + /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ setNotificationHandler( notificationSchema: T, handler: (notification: ReturnType) => void | Promise diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index c6cbc1bb8..a4690b7f4 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -231,7 +231,7 @@ export class Server extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise ): void; - /** @deprecated For spec methods, pass the method string instead. */ + /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ServerContext) => Result | Promise From c1f677824307a15c6673ea7948397c692c85a67a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 10:08:23 +0000 Subject: [PATCH 09/14] docs: align mcpReq.send() schema-param wording with 260fae6b overload --- docs/migration-SKILL.md | 12 ++++++------ docs/migration.md | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index d9ffabc61..e6be613aa 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -416,9 +416,9 @@ Request/notification params remain fully typed. Remove unused schema imports aft | `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler | | `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler | -## 11. Schema parameter on `request()` / `callTool()` is optional; removed from `send()` +## 11. Schema parameter on `request()` / `callTool()` / `mcpReq.send()` is optional -`Protocol.request()` and `Client.callTool()` still accept a Zod result schema as the second argument (the v1 form), but it is optional for spec methods — the SDK resolves the schema internally from the method name. `BaseContext.mcpReq.send()` no longer takes a schema. +`Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` still accept a result schema as the second argument (the v1 form), but for spec methods it is optional — the SDK resolves the schema internally from the method name. The schema argument remains the supported call form for custom (non-spec) methods. ```typescript // v1: schema required @@ -427,7 +427,7 @@ const result = await client.request({ method: 'tools/call', params: { ... } }, C const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { ... } }, ElicitResultSchema); const tool = await client.callTool({ name: 'my-tool', arguments: {} }, CompatibilityCallToolResultSchema); -// v2: schema optional on request()/callTool(); removed from mcpReq.send() +// v2: schema optional on request()/callTool()/mcpReq.send() for spec methods const result = await client.request({ method: 'tools/call', params: { ... } }); const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { ... } }); const tool = await client.callTool({ name: 'my-tool', arguments: {} }); @@ -437,12 +437,12 @@ const tool = await client.callTool({ name: 'my-tool', arguments: {} }); | ------------------------------------------------------------ | ---------------------------------------------- | | `client.request(req, ResultSchema)` | unchanged (schema optional), or `client.request(req)` | | `client.request(req, ResultSchema, options)` | unchanged, or `client.request(req, options)` | -| `ctx.mcpReq.send(req, ResultSchema)` | `ctx.mcpReq.send(req)` | -| `ctx.mcpReq.send(req, ResultSchema, options)` | `ctx.mcpReq.send(req, options)` | +| `ctx.mcpReq.send(req, ResultSchema)` | unchanged (schema optional), or `ctx.mcpReq.send(req)` | +| `ctx.mcpReq.send(req, ResultSchema, options)` | unchanged, or `ctx.mcpReq.send(req, options)` | | `client.callTool(params, CompatibilityCallToolResultSchema)` | unchanged (schema ignored), or `client.callTool(params)` | | `client.callTool(params, schema, options)` | unchanged, or `client.callTool(params, options)` | -Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. +For spec methods you can drop now-unused schema imports (`CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc.) when they were only used in `request()`/`send()`/`callTool()` calls. If `CallToolResultSchema` was used for **runtime validation** (not just as a `request()` argument), replace with the `isCallToolResult` type guard: diff --git a/docs/migration.md b/docs/migration.md index 0cc176611..17a3a7f76 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -397,10 +397,10 @@ server.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] const result = await client.request({ method: 'acme/search', params: { query: 'x' } }, SearchResult); ``` -### `Protocol.request()` and `Client.callTool()` schema parameter is now optional +### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` schema parameter is now optional -The public `Protocol.request()` and `Client.callTool()` methods still accept a result schema argument, but for spec methods it is optional — the SDK resolves the correct schema internally from the method name. You no longer need to import result schemas -like `CallToolResultSchema` or `ElicitResultSchema` when making requests. (`BaseContext.mcpReq.send()` no longer accepts a schema; drop it.) +The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods still accept a result schema argument, but for spec methods it is optional — the SDK resolves the correct schema internally from the method name. You no longer need to import result schemas +like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests. The schema argument remains the supported call form for custom (non-spec) methods. **`client.request()` — Before (v1):** From 6e630f48d0f0ba91c4ce16f0df8f6e5d42449427 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 10:28:26 +0000 Subject: [PATCH 10/14] =?UTF-8?q?fix(examples,docs):=20spawn=20server=20vi?= =?UTF-8?q?a=20tsx;=20reword=20=C2=A79=20intro=20to=20reflect=20both=20for?= =?UTF-8?q?ms=20supported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/migration-SKILL.md | 2 +- docs/migration.md | 2 +- examples/client/src/customMethodExample.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index e6be613aa..2c753f4ab 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -340,7 +340,7 @@ The server package now exports framework-agnostic alternatives: `validateHostHea ## 9. `setRequestHandler` / `setNotificationHandler` API -The low-level handler registration methods now take a method string instead of a Zod schema. +The low-level handler registration methods now accept a method string in addition to the v1 Zod-schema form (both are supported). ```typescript // v1: schema-based diff --git a/docs/migration.md b/docs/migration.md index 17a3a7f76..7be893290 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -324,7 +324,7 @@ Note: the v2 signature takes a plain `string[]` instead of an options object. ### `setRequestHandler` and `setNotificationHandler` use method strings -The low-level `setRequestHandler` and `setNotificationHandler` methods on `Client`, `Server`, and `Protocol` now take a method string instead of a Zod schema. +The low-level `setRequestHandler` and `setNotificationHandler` methods on `Client`, `Server`, and `Protocol` now accept a method string in addition to the v1 Zod-schema form (both are supported). **Before (v1):** diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts index f9ed3ba52..d0ce0e994 100644 --- a/examples/client/src/customMethodExample.ts +++ b/examples/client/src/customMethodExample.ts @@ -25,7 +25,7 @@ client.setNotificationHandler(ProgressNotification, n => { console.log(`[client] progress: ${n.params.stage} ${n.params.pct}%`); }); -await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] })); +await client.connect(new StdioClientTransport({ command: 'npx', args: ['tsx', '../server/src/customMethodExample.ts'] })); const r = await client.request({ method: 'acme/search', params: { query: 'widgets' } }, SearchResult); console.log('[client] hits=' + JSON.stringify(r.hits)); From ddd6776903807106b9859dd3630eb1c0f7f41aa5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 12:15:33 +0000 Subject: [PATCH 11/14] fix: revert out-of-scope SdkError, add ctx.mcpReq.send to changeset + tests --- .changeset/custom-method-overloads.md | 2 +- packages/core/src/shared/protocol.ts | 2 +- .../core/test/shared/customMethods.test.ts | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.changeset/custom-method-overloads.md b/.changeset/custom-method-overloads.md index 4077f484c..0f416940e 100644 --- a/.changeset/custom-method-overloads.md +++ b/.changeset/custom-method-overloads.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/server': minor --- -`setRequestHandler`/`setNotificationHandler` accept the v1 `(ZodSchema, handler)` form as a first-class alternative to `(methodString, handler)`. `request()` accepts an explicit result schema (`request(req, resultSchema, options?)`) and has a method-keyed return type for spec methods. `callTool(params, resultSchema?)` accepts the v1 schema arg (ignored). `removeRequestHandler`/`removeNotificationHandler`/`assertCanSetRequestHandler` accept any method string. +`setRequestHandler`/`setNotificationHandler` accept the v1 `(ZodSchema, handler)` form as a first-class alternative to `(methodString, handler)`. `request()` and `ctx.mcpReq.send()` accept an explicit result schema (`request(req, resultSchema, options?)`) and have method-keyed return types for spec methods. `callTool(params, resultSchema?)` accepts the v1 schema arg (ignored). `removeRequestHandler`/`removeNotificationHandler`/`assertCanSetRequestHandler` accept any method string. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 48fba8970..2508cb51e 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -837,7 +837,7 @@ export abstract class Protocol { }; if (!this._transport) { - earlyReject(new SdkError(SdkErrorCode.NotConnected, 'Not connected')); + earlyReject(new Error('Not connected')); return; } diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 22d1565cd..0868b5805 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -108,6 +108,32 @@ describe('request() — explicit result schema overload', () => { }); }); +describe('ctx.mcpReq.send() — explicit result schema overload', () => { + it('forwards to a related request and validates result via the supplied schema', async () => { + const { a, b } = await makePair(); + a.setRequestHandler(EchoRequest, req => ({ reply: req.params.msg })); + let captured: unknown; + b.setRequestHandler(z.object({ method: z.literal('acme/outer') }), async (_req, ctx) => { + captured = await ctx.mcpReq.send({ method: 'acme/echo', params: { msg: 'via-send' } }, z.object({ reply: z.string() })); + return {}; + }); + await a.request({ method: 'acme/outer' }, z.object({})); + expect(captured).toEqual({ reply: 'via-send' }); + }); + + it('spec-method form (no schema) uses method-keyed return', async () => { + const { a, b } = await makePair(); + a.setRequestHandler('ping', () => ({})); + let pingResult: unknown; + b.setRequestHandler(z.object({ method: z.literal('acme/outer') }), async (_req, ctx) => { + pingResult = await ctx.mcpReq.send({ method: 'ping' }); + return {}; + }); + await a.request({ method: 'acme/outer' }, z.object({})); + expect(pingResult).toEqual({}); + }); +}); + describe('notification() mock-assignability', () => { it('single-signature notification() is assignable from a simple mock (compile-time check)', () => { const p = new TestProtocol(); From d2e046d6c69bc2568e67526da5aac0cc00cd2f3a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 13:13:02 +0000 Subject: [PATCH 12/14] test(server): make schema-parity test reach the wrapper; use stderr in stdio example The parity test sent params.task without declaring tasks capability, so assertTaskHandlerCapability threw before the tools/call wrapper ran and both forms produced the same capability error rather than the wrapper's task-result-validation error. Declare tasks.requests.tools.call and assert on the wrapper's 'Invalid task creation result' message so the test cannot pass for the wrong reason. Also: console.log over StdioServerTransport writes to the JSON-RPC channel (stdout); use console.error so debug output reaches the user. --- examples/server/src/customMethodExample.ts | 4 ++-- .../test/server/setRequestHandlerSchemaParity.test.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts index 3910cfa5d..b8b2e222f 100644 --- a/examples/server/src/customMethodExample.ts +++ b/examples/server/src/customMethodExample.ts @@ -28,7 +28,7 @@ const TickNotification = z.object({ const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); server.setRequestHandler(SearchRequest, async (request, ctx) => { - console.log('[server] acme/search query=' + request.params.query); + console.error('[server] acme/search query=' + request.params.query); await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); const hits = [request.params.query, request.params.query + '-result']; await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 100 } }); @@ -36,7 +36,7 @@ server.setRequestHandler(SearchRequest, async (request, ctx) => { }); server.setNotificationHandler(TickNotification, n => { - console.log('[server] acme/tick n=' + n.params.n); + console.error('[server] acme/tick n=' + n.params.n); }); await server.connect(new StdioServerTransport()); diff --git a/packages/server/test/server/setRequestHandlerSchemaParity.test.ts b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts index bd24c3ea8..313cd0e8e 100644 --- a/packages/server/test/server/setRequestHandlerSchemaParity.test.ts +++ b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts @@ -12,7 +12,10 @@ import { Server } from '../../src/server/server.js'; */ describe('Server.setRequestHandler — Zod-schema form parity', () => { async function setup(register: (s: Server) => void) { - const server = new Server({ name: 't', version: '1.0' }, { capabilities: { tools: {} } }); + const server = new Server( + { name: 't', version: '1.0' }, + { capabilities: { tools: {}, tasks: { requests: { tools: { call: {} } } } } } + ); register(server); const [ct, st] = InMemoryTransport.createLinkedPair(); await server.connect(st); @@ -44,7 +47,7 @@ describe('Server.setRequestHandler — Zod-schema form parity', () => { const stringRes = await callToolWithTask(viaString.ct); const schemaRes = await callToolWithTask(viaSchema.ct); - expect(stringRes.error).toBeDefined(); + expect((stringRes.error as { message: string }).message).toContain('Invalid task creation result'); expect(schemaRes.error).toEqual(stringRes.error); }); From fefb6e0f622caa9deaa153d69c1f51df86ca3bbc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 27 Apr 2026 12:58:23 +0000 Subject: [PATCH 13/14] feat(compat): mark setRequestHandler/setNotificationHandler Zod-schema overloads @deprecated; address review - @deprecated on the Zod-schema overloads of setRequestHandler / setNotificationHandler (Protocol + Client + Server). The 3-arg (method, paramsSchema, handler) form is the non-deprecated path for custom methods. request()/mcpReq.send()/callTool schema overloads are NOT deprecated (only typed form for custom-method results). - Unify positional-schema detection via isResultSchemaLike() (~standard or .parse) used by request(), mcpReq.send() and callTool(). - request()/mcpReq.send() now reject with a clear TypeError when a non-spec method is called without a result schema (was: undefined schema crashed in parseSchema downstream). - Avoid redundant spec-schema parses: _setRequestHandlerByMethod() takes skipSpecParse; Server/Client overrides pass true for their per-method wrappers and for the Zod-schema form. Aligns Protocol with subclasses (both skip when user supplied a schema). - compatSchema.ts: drop @internal from file header (ZodLikeRequestSchema is publicly exported); add isResultSchemaLike helper. - Tests: client setRequestHandlerSchemaParity (elicitation+sampling), compatSchema helpers, runtime-guard cases for request()/mcpReq.send(). --- packages/client/src/client/client.ts | 20 ++--- .../setRequestHandlerSchemaParity.test.ts | 74 +++++++++++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/shared/protocol.ts | 50 ++++++++++--- packages/core/src/util/compatSchema.ts | 21 ++++-- .../core/test/shared/customMethods.test.ts | 17 +++++ packages/core/test/util/compatSchema.test.ts | 40 ++++++++++ packages/server/src/server/server.ts | 13 ++-- 8 files changed, 204 insertions(+), 33 deletions(-) create mode 100644 packages/client/test/client/setRequestHandlerSchemaParity.test.ts create mode 100644 packages/core/test/util/compatSchema.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index e1ae948d7..e8a26f288 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -55,6 +55,7 @@ import { extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, + isResultSchemaLike, isZodLikeSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, @@ -343,7 +344,7 @@ export class Client extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise ): void; - /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ + /** @deprecated Use the 3-arg `(method, paramsSchema, handler)` form for custom methods, or the method-string form for spec methods. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ClientContext) => Result | Promise @@ -351,7 +352,8 @@ export class Client extends Protocol { public override setRequestHandler(methodOrSchema: string | ZodLikeRequestSchema, schemaHandler: unknown): void { let method: string; let handler: (request: Request, ctx: ClientContext) => ClientResult | Promise; - if (isZodLikeSchema(methodOrSchema)) { + const fromSchema = isZodLikeSchema(methodOrSchema); + if (fromSchema) { const schema = methodOrSchema; const userHandler = schemaHandler as (request: unknown, ctx: ClientContext) => Result | Promise; method = extractMethodLiteral(schema); @@ -426,8 +428,8 @@ export class Client extends Protocol { return validatedResult; }; - // Install the wrapped handler - return this._setRequestHandlerByMethod(method, wrappedHandler); + // wrappedHandler validates with the spec schema itself; skip the extra parse in the base helper. + return this._setRequestHandlerByMethod(method, wrappedHandler, true); } if (method === 'sampling/createMessage') { @@ -469,12 +471,12 @@ export class Client extends Protocol { return validationResult.data; }; - // Install the wrapped handler - return this._setRequestHandlerByMethod(method, wrappedHandler); + // wrappedHandler validates with the spec schema itself; skip the extra parse in the base helper. + return this._setRequestHandlerByMethod(method, wrappedHandler, true); } - // Other handlers use default behavior - return this._setRequestHandlerByMethod(method, handler); + // Other methods: skip the spec parse only when the user supplied their own schema (it is the source of truth). + return this._setRequestHandlerByMethod(method, handler, fromSchema); } protected assertCapability(capability: keyof ServerCapabilities, method: string): void { @@ -898,7 +900,7 @@ export class Client extends Protocol { optionsOrSchema?: RequestOptions | unknown, maybeOptions?: RequestOptions ): Promise { - const arg2IsSchema = optionsOrSchema != null && typeof optionsOrSchema === 'object' && 'parse' in optionsOrSchema; + const arg2IsSchema = isResultSchemaLike(optionsOrSchema); // v1 allowed `callTool(params, undefined, opts)` (resultSchema was optional-with-default); // when arg2 is not a schema, prefer arg3 if present so opts aren't dropped. const options: RequestOptions | undefined = arg2IsSchema diff --git a/packages/client/test/client/setRequestHandlerSchemaParity.test.ts b/packages/client/test/client/setRequestHandlerSchemaParity.test.ts new file mode 100644 index 000000000..4e9013aec --- /dev/null +++ b/packages/client/test/client/setRequestHandlerSchemaParity.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { CreateMessageRequestSchema, ElicitRequestSchema, InMemoryTransport } from '@modelcontextprotocol/core'; + +import { Client } from '../../src/client/client.js'; + +/** + * Mirrors the server-side parity test: registering with the Zod-schema form must + * route through the same per-method wrapper (result-shape validation) as the + * method-string form. + */ +describe('Client.setRequestHandler — Zod-schema form parity', () => { + async function setup(register: (c: Client) => void) { + const client = new Client({ name: 't', version: '1.0' }, { capabilities: { sampling: {}, elicitation: {} } }); + register(client); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await ct.start(); + // Minimal server-side stub on ct so Client.connect's initialize handshake completes. + ct.onmessage = m => { + const msg = m as { id?: number; method?: string }; + if (msg.method === 'initialize') { + void ct.send({ + jsonrpc: '2.0', + id: msg.id!, + result: { protocolVersion: '2025-06-18', serverInfo: { name: 's', version: '1.0' }, capabilities: {} } + }); + } + }; + await client.connect(st); + return { ct }; + } + + async function send( + ct: InMemoryTransport, + method: string, + params: Record + ): Promise<{ result?: unknown; error?: unknown }> { + return await new Promise(resolve => { + ct.onmessage = m => { + const msg = m as { id?: number; result?: unknown; error?: unknown }; + if (msg.id === 99 && ('result' in msg || 'error' in msg)) resolve(msg); + }; + void ct.send({ jsonrpc: '2.0', id: 99, method, params }); + }); + } + + it('elicitation/create — schema form gets the same result-validation as string form', async () => { + const invalidElicitResult = { action: 'nope' }; + const params = { mode: 'form', message: 'q', requestedSchema: { type: 'object', properties: {} } }; + + const viaString = await setup(c => c.setRequestHandler('elicitation/create', () => invalidElicitResult as never)); + const viaSchema = await setup(c => c.setRequestHandler(ElicitRequestSchema, () => invalidElicitResult as never)); + + const stringRes = await send(viaString.ct, 'elicitation/create', params); + const schemaRes = await send(viaSchema.ct, 'elicitation/create', params); + + expect((stringRes.error as { message: string }).message).toContain('Invalid elicitation result'); + expect(schemaRes.error).toEqual(stringRes.error); + }); + + it('sampling/createMessage — schema form gets the same result-validation as string form', async () => { + const invalidSamplingResult = { role: 'assistant' }; + const params = { messages: [], maxTokens: 1 }; + + const viaString = await setup(c => c.setRequestHandler('sampling/createMessage', () => invalidSamplingResult as never)); + const viaSchema = await setup(c => c.setRequestHandler(CreateMessageRequestSchema, () => invalidSamplingResult as never)); + + const stringRes = await send(viaString.ct, 'sampling/createMessage', params); + const schemaRes = await send(viaSchema.ct, 'sampling/createMessage', params); + + expect((stringRes.error as { message: string }).message).toContain('Invalid'); + expect(schemaRes.error).toEqual(stringRes.error); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb39c2868..43d7e20f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,5 +49,5 @@ export * from './validators/fromJsonSchema.js'; // Core types only - implementations are exported via separate entry points export type { ZodLikeRequestSchema } from './util/compatSchema.js'; -export { extractMethodLiteral, isZodLikeSchema } from './util/compatSchema.js'; +export { extractMethodLiteral, isResultSchemaLike, isZodLikeSchema } from './util/compatSchema.js'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 2508cb51e..f215b8fd4 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -45,7 +45,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; import type { ZodLikeRequestSchema } from '../util/compatSchema.js'; -import { extractMethodLiteral, isZodLikeSchema } from '../util/compatSchema.js'; +import { extractMethodLiteral, isResultSchemaLike, isZodLikeSchema } from '../util/compatSchema.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; @@ -611,10 +611,15 @@ export abstract class Protocol { _meta: request.params?._meta, signal: abortController.signal, send: ((r: Request, optionsOrSchema?: TaskRequestOptions | AnySchema, maybeOptions?: TaskRequestOptions) => { - if (optionsOrSchema && '~standard' in optionsOrSchema) { - return sendRequest(r, optionsOrSchema, maybeOptions); + if (isResultSchemaLike(optionsOrSchema)) { + return sendRequest(r, optionsOrSchema as AnySchema, maybeOptions); } const resultSchema = getResultSchema(r.method as RequestMethod); + if (!resultSchema) { + return Promise.reject( + new TypeError(`mcpReq.send(): '${r.method}' is not a spec method; pass a result schema as the second argument.`) + ); + } return sendRequest(r, resultSchema, optionsOrSchema); }) as BaseContext['mcpReq']['send'], notify: sendNotification @@ -807,10 +812,15 @@ export abstract class Protocol { /** For spec methods the one-argument form is more concise; this overload is the supported call form for non-spec methods or custom result shapes. */ request(request: Request, resultSchema: T, options?: RequestOptions): Promise>; request(request: Request, optionsOrSchema?: RequestOptions | AnySchema, maybeOptions?: RequestOptions): Promise { - if (optionsOrSchema && '~standard' in optionsOrSchema) { - return this._requestWithSchema(request, optionsOrSchema, maybeOptions); + if (isResultSchemaLike(optionsOrSchema)) { + return this._requestWithSchema(request, optionsOrSchema as AnySchema, maybeOptions); } const schema = getResultSchema(request.method as RequestMethod); + if (!schema) { + return Promise.reject( + new TypeError(`request(): '${request.method}' is not a spec method; pass a result schema as the second argument.`) + ); + } return this._requestWithSchema(request, schema, optionsOrSchema); } @@ -1043,7 +1053,7 @@ export abstract class Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise ): void; - /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ + /** @deprecated Use the 3-arg `(method, paramsSchema, handler)` form for custom methods, or the method-string form for spec methods. */ setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ContextT) => Result | Promise @@ -1052,9 +1062,14 @@ export abstract class Protocol { if (isZodLikeSchema(method)) { const requestSchema = method; const methodStr = extractMethodLiteral(requestSchema); - this.assertRequestHandlerCapability(methodStr); - this._requestHandlers.set(methodStr, (request, ctx) => - Promise.resolve((handler as (req: unknown, ctx: ContextT) => Result | Promise)(requestSchema.parse(request), ctx)) + // The user's schema is the source of truth; spec-schema validation is skipped to avoid double-parsing. + this._setRequestHandlerByMethod( + methodStr, + (request, ctx) => + Promise.resolve( + (handler as (req: unknown, ctx: ContextT) => Result | Promise)(requestSchema.parse(request), ctx) + ), + true ); return; } @@ -1064,9 +1079,22 @@ export abstract class Protocol { /** * Registers a request handler by method string, bypassing the public overload set. * Used by `Client`/`Server` overrides to forward without `as RequestMethod` casts. + * + * @param skipSpecParse - When `true`, the handler is stored directly without + * wrapping it in spec-schema validation. Set this when the caller has already + * applied its own validation (Zod-schema form, or a method-specific wrapper) + * to avoid parsing the same request multiple times. */ - protected _setRequestHandlerByMethod(method: string, handler: (request: Request, ctx: ContextT) => Result | Promise): void { + protected _setRequestHandlerByMethod( + method: string, + handler: (request: Request, ctx: ContextT) => Result | Promise, + skipSpecParse = false + ): void { this.assertRequestHandlerCapability(method); + if (skipSpecParse) { + this._requestHandlers.set(method, (request, ctx) => Promise.resolve(handler(request, ctx))); + return; + } const schema = getRequestSchema(method as RequestMethod); this._requestHandlers.set(method, (request, ctx) => { const parsed = schema ? (schema.parse(request) as Request) : request; @@ -1101,7 +1129,7 @@ export abstract class Protocol { method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise ): void; - /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ + /** @deprecated Use the 3-arg `(method, paramsSchema, handler)` form for custom methods, or the method-string form for spec methods. */ setNotificationHandler( notificationSchema: T, handler: (notification: ReturnType) => void | Promise diff --git a/packages/core/src/util/compatSchema.ts b/packages/core/src/util/compatSchema.ts index 63956c97b..20e528538 100644 --- a/packages/core/src/util/compatSchema.ts +++ b/packages/core/src/util/compatSchema.ts @@ -4,15 +4,14 @@ * v1 accepted a Zod object whose `.shape.method` is `z.literal('')`. * v2 also accepts the method string directly. These helpers detect the schema * form and extract the literal so the dispatcher can route to the correct path. - * - * @internal */ /** - * Minimal structural type for a Zod object schema. The `method` literal is - * checked at runtime by `extractMethodLiteral`; the type-level constraint - * is intentionally loose because zod v4's `ZodLiteral` doesn't surface `.value` - * in its declared type (only at runtime). + * Minimal structural type for a v1-style Zod request/notification schema: an + * object schema whose `.shape.method` is a string literal. The `method` literal + * is checked at runtime; the type-level constraint is intentionally loose + * because zod v4's `ZodLiteral` doesn't surface `.value` in its declared type + * (only at runtime). */ export interface ZodLikeRequestSchema { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -39,3 +38,13 @@ export function extractMethodLiteral(schema: ZodLikeRequestSchema): string { } return value; } + +/** + * True if `arg` looks like a result schema passed positionally to + * `request()` / `callTool()` / `mcpReq.send()`. Detects either the + * Standard Schema marker (`~standard`) or a Zod-style `parse` function so the + * v1 schema-argument form is recognised regardless of zod major version. + */ +export function isResultSchemaLike(arg: unknown): arg is object { + return arg != null && typeof arg === 'object' && ('~standard' in arg || typeof (arg as { parse?: unknown }).parse === 'function'); +} diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 0868b5805..5b3cd46ba 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -106,6 +106,13 @@ describe('request() — explicit result schema overload', () => { const r = await a.request({ method: 'ping' }); expect(r).toEqual({}); }); + + it('throws a clear error for a non-spec method without a result schema', async () => { + const { a, b } = await makePair(); + b.setRequestHandler(EchoRequest, req => ({ reply: req.params.msg })); + // @ts-expect-error — bypassing the overload set to exercise the runtime guard + await expect(a.request({ method: 'acme/echo', params: { msg: 'x' } })).rejects.toThrow(/'acme\/echo' is not a spec method/); + }); }); describe('ctx.mcpReq.send() — explicit result schema overload', () => { @@ -132,6 +139,16 @@ describe('ctx.mcpReq.send() — explicit result schema overload', () => { await a.request({ method: 'acme/outer' }, z.object({})); expect(pingResult).toEqual({}); }); + + it('throws a clear error for a non-spec method without a result schema', async () => { + const { a, b } = await makePair(); + b.setRequestHandler(z.object({ method: z.literal('acme/outer') }), async (_req, ctx) => { + // @ts-expect-error — bypassing the overload set to exercise the runtime guard + await ctx.mcpReq.send({ method: 'acme/nope' }); + return {}; + }); + await expect(a.request({ method: 'acme/outer' }, z.object({}))).rejects.toThrow(/'acme\/nope' is not a spec method/); + }); }); describe('notification() mock-assignability', () => { diff --git a/packages/core/test/util/compatSchema.test.ts b/packages/core/test/util/compatSchema.test.ts new file mode 100644 index 000000000..089a4cbb5 --- /dev/null +++ b/packages/core/test/util/compatSchema.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { extractMethodLiteral, isResultSchemaLike, isZodLikeSchema } from '../../src/util/compatSchema.js'; + +describe('compatSchema helpers', () => { + describe('isZodLikeSchema', () => { + it('detects a Zod object schema', () => { + expect(isZodLikeSchema(z.object({ method: z.literal('x') }))).toBe(true); + }); + it('rejects strings, plain objects, and null', () => { + expect(isZodLikeSchema('tools/call')).toBe(false); + expect(isZodLikeSchema({ shape: {} })).toBe(false); + expect(isZodLikeSchema(null)).toBe(false); + }); + }); + + describe('extractMethodLiteral', () => { + it('reads the method literal from a Zod object schema', () => { + expect(extractMethodLiteral(z.object({ method: z.literal('acme/echo') }))).toBe('acme/echo'); + }); + it('throws when no string method literal is present', () => { + expect(() => extractMethodLiteral(z.object({ method: z.string() }))).toThrow(TypeError); + expect(() => extractMethodLiteral(z.object({}))).toThrow(TypeError); + }); + }); + + describe('isResultSchemaLike', () => { + it('detects Standard Schema (~standard) and Zod-style parse()', () => { + expect(isResultSchemaLike(z.string())).toBe(true); + expect(isResultSchemaLike({ '~standard': { version: 1, vendor: 't', validate: () => ({ value: 1 }) } })).toBe(true); + expect(isResultSchemaLike({ parse: (v: unknown) => v })).toBe(true); + }); + it('rejects RequestOptions-shaped objects, undefined, and primitives', () => { + expect(isResultSchemaLike({ timeout: 100 })).toBe(false); + expect(isResultSchemaLike(undefined)).toBe(false); + expect(isResultSchemaLike('x')).toBe(false); + }); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index a4690b7f4..28ae452e8 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -231,7 +231,7 @@ export class Server extends Protocol { method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise ): void; - /** For spec methods the method-string form is more concise; this overload is the supported call form for non-spec methods or when you want full-envelope validation. */ + /** @deprecated Use the 3-arg `(method, paramsSchema, handler)` form for custom methods, or the method-string form for spec methods. */ public override setRequestHandler( requestSchema: T, handler: (request: ReturnType, ctx: ServerContext) => Result | Promise @@ -239,7 +239,8 @@ export class Server extends Protocol { public override setRequestHandler(methodOrSchema: string | ZodLikeRequestSchema, schemaHandler: unknown): void { let method: string; let handler: (request: Request, ctx: ServerContext) => ServerResult | Promise; - if (isZodLikeSchema(methodOrSchema)) { + const fromSchema = isZodLikeSchema(methodOrSchema); + if (fromSchema) { const schema = methodOrSchema; const userHandler = schemaHandler as (request: unknown, ctx: ServerContext) => Result | Promise; method = extractMethodLiteral(schema); @@ -285,12 +286,12 @@ export class Server extends Protocol { return validationResult.data; }; - // Install the wrapped handler - return this._setRequestHandlerByMethod(method, wrappedHandler); + // wrappedHandler validates with the spec schema itself; skip the extra parse in the base helper. + return this._setRequestHandlerByMethod(method, wrappedHandler, true); } - // Other handlers use default behavior - return this._setRequestHandlerByMethod(method, handler); + // Other methods: skip the spec parse only when the user supplied their own schema (it is the source of truth). + return this._setRequestHandlerByMethod(method, handler, fromSchema); } protected assertCapabilityForMethod(method: RequestMethod): void { From 274ca812db7a5eb306bb39b9278a6a30014f6e43 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 27 Apr 2026 14:40:36 +0000 Subject: [PATCH 14/14] fix(server,client): pass spec-parsed request to user handler in wrappedHandler The skipSpecParse optimization correctly skipped the redundant parse in the base helper, but wrappedHandler was forwarding the raw JSONRPCRequest to the user's handler instead of the validatedRequest.data it had already produced. For the method-string form this meant the user handler received {jsonrpc, id, method, params} instead of the spec-parsed CallToolRequest. Forward validatedRequest.data in all three wrappers (server tools/call, client elicitation/create, client sampling/createMessage). Adds a regression test asserting the handler receives the parsed shape. --- packages/client/src/client/client.ts | 10 ++++++---- packages/server/src/server/server.ts | 5 +++-- .../setRequestHandlerSchemaParity.test.ts | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index e8a26f288..69409546d 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -384,7 +384,7 @@ export class Client extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); } - const result = await Promise.resolve(handler(request, ctx)); + const result = await Promise.resolve(handler(validatedRequest.data, ctx)); // When task creation is requested, validate and return CreateTaskResult if (params.task) { @@ -428,7 +428,8 @@ export class Client extends Protocol { return validatedResult; }; - // wrappedHandler validates with the spec schema itself; skip the extra parse in the base helper. + // wrappedHandler validates the request itself and forwards the parsed form to the user handler; + // skip the extra parse in the base helper. return this._setRequestHandlerByMethod(method, wrappedHandler, true); } @@ -443,7 +444,7 @@ export class Client extends Protocol { const { params } = validatedRequest.data; - const result = await Promise.resolve(handler(request, ctx)); + const result = await Promise.resolve(handler(validatedRequest.data, ctx)); // When task creation is requested, validate and return CreateTaskResult if (params.task) { @@ -471,7 +472,8 @@ export class Client extends Protocol { return validationResult.data; }; - // wrappedHandler validates with the spec schema itself; skip the extra parse in the base helper. + // wrappedHandler validates the request itself and forwards the parsed form to the user handler; + // skip the extra parse in the base helper. return this._setRequestHandlerByMethod(method, wrappedHandler, true); } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 28ae452e8..1ae7b924b 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -260,7 +260,7 @@ export class Server extends Protocol { const { params } = validatedRequest.data; - const result = await Promise.resolve(handler(request, ctx)); + const result = await Promise.resolve(handler(validatedRequest.data, ctx)); // When task creation is requested, validate and return CreateTaskResult if (params.task) { @@ -286,7 +286,8 @@ export class Server extends Protocol { return validationResult.data; }; - // wrappedHandler validates with the spec schema itself; skip the extra parse in the base helper. + // wrappedHandler validates the request itself and forwards the parsed form to the user handler; + // skip the extra parse in the base helper. return this._setRequestHandlerByMethod(method, wrappedHandler, true); } diff --git a/packages/server/test/server/setRequestHandlerSchemaParity.test.ts b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts index 313cd0e8e..47f74366e 100644 --- a/packages/server/test/server/setRequestHandlerSchemaParity.test.ts +++ b/packages/server/test/server/setRequestHandlerSchemaParity.test.ts @@ -51,6 +51,25 @@ describe('Server.setRequestHandler — Zod-schema form parity', () => { expect(schemaRes.error).toEqual(stringRes.error); }); + it('method-string form handler receives spec-parsed request (not raw JSONRPCRequest)', async () => { + let received: unknown; + const { ct } = await setup(s => + s.setRequestHandler('tools/call', req => { + received = req; + return { content: [{ type: 'text' as const, text: 'ok' }] }; + }) + ); + await new Promise(resolve => { + ct.onmessage = m => { + if ('result' in (m as object) || 'error' in (m as object)) resolve(); + }; + ct.send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'x', arguments: {} } }); + }); + expect(received).not.toHaveProperty('jsonrpc'); + expect(received).not.toHaveProperty('id'); + expect(received).toMatchObject({ method: 'tools/call', params: { name: 'x', arguments: {} } }); + }); + it('schema form handles non-spec methods through Server (no spec-schema crash)', async () => { const Echo = z.object({ method: z.literal('acme/echo'), params: z.object({ msg: z.string() }) }); const { ct } = await setup(s => s.setRequestHandler(Echo, req => ({ reply: req.params.msg })));