From 5553342652edff4f03ad830fea8246129fe13b32 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 16 Apr 2026 17:57:38 +0000 Subject: [PATCH 01/12] 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/12] =?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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] =?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/12] 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/12] 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); });