From e0059aae1d47d39a31ceece42547664a7ad17718 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 12:20:55 +0000 Subject: [PATCH 1/5] feat(core): make Protocol concrete and exported with custom-method overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Protocol becomes concrete: 5 assert*Capability abstracts → no-op virtuals, buildContext → virtual with default. Client/Server override to enforce. - setRequestHandler/setNotificationHandler gain a 3-arg (method: string, paramsSchema, handler) overload alongside the existing 2-arg spec-typed form. One method, any method name. - request() gains string-form (method, params, resultSchema) overload for custom methods (object-form already returns method-keyed ResultTypeMap[M]). - notification() gains (method, params, paramsSchema?) overload. - removeRequestHandler/removeNotificationHandler accept string. - isRequestMethod/isNotificationMethod predicates added. - Protocol and mergeCapabilities exported from public surface. Enables vendor-prefixed extension methods (which the MCP spec permits) without a separate setCustom* API. --- packages/client/src/client/client.ts | 75 +++-- packages/core/src/exports/public/index.ts | 6 +- packages/core/src/shared/protocol.ts | 280 ++++++++++++++---- packages/core/src/types/schemas.ts | 14 + packages/core/src/util/schema.ts | 37 ++- packages/core/src/util/standardSchema.ts | 6 +- .../core/test/shared/customMethods.test.ts | 242 +++++++++++++++ packages/core/test/shared/protocol.test.ts | 30 +- .../shared/protocolTransportHandling.test.ts | 16 +- packages/server/src/server/server.ts | 49 ++- 10 files changed, 627 insertions(+), 128 deletions(-) create mode 100644 packages/core/test/shared/customMethods.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 21a43bd15..ba7d42c64 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,5 +1,6 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; import type { + AnySchema, BaseContext, CallToolRequest, ClientCapabilities, @@ -23,11 +24,15 @@ import type { MessageExtraInfo, NotificationMethod, ProtocolOptions, + ProtocolSpec, ReadResourceRequest, + Request, RequestMethod, RequestOptions, RequestTypeMap, + Result, ResultTypeMap, + SchemaOutput, ServerCapabilities, SubscribeRequest, TaskManagerOptions, @@ -201,7 +206,7 @@ export type ClientOptions = ProtocolOptions & { * The client will automatically begin the initialization flow with the server when {@linkcode connect} is called. * */ -export class Client extends Protocol { +export class Client extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; private _negotiatedProtocolVersion?: string; @@ -255,23 +260,23 @@ export class Client extends Protocol { * Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability. * @internal */ - private _setupListChangedHandlers(config: ListChangedHandlers): void { + private async _setupListChangedHandlers(config: ListChangedHandlers): Promise { if (config.tools && this._serverCapabilities?.tools?.listChanged) { - this._setupListChangedHandler('tools', 'notifications/tools/list_changed', config.tools, async () => { + await this._setupListChangedHandler('tools', 'notifications/tools/list_changed', config.tools, async () => { const result = await this.listTools(); return result.tools; }); } if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { - this._setupListChangedHandler('prompts', 'notifications/prompts/list_changed', config.prompts, async () => { + await this._setupListChangedHandler('prompts', 'notifications/prompts/list_changed', config.prompts, async () => { const result = await this.listPrompts(); return result.prompts; }); } if (config.resources && this._serverCapabilities?.resources?.listChanged) { - this._setupListChangedHandler('resources', 'notifications/resources/list_changed', config.resources, async () => { + await this._setupListChangedHandler('resources', 'notifications/resources/list_changed', config.resources, async () => { const result = await this.listResources(); return result.resources; }); @@ -336,10 +341,28 @@ export class Client extends Protocol { public override setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise + ): void; + public override setRequestHandler

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

, ctx: ClientContext) => Result | Promise + ): void; + public override setRequestHandler( + method: string, + schemaOrHandler: unknown, + maybeHandler?: (params: unknown, ctx: ClientContext) => unknown ): void { + if (maybeHandler !== undefined) { + return super.setRequestHandler( + method, + schemaOrHandler as AnySchema, + maybeHandler as (params: unknown, ctx: ClientContext) => Result | Promise + ); + } + const handler = schemaOrHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise; if (method === 'elicitation/create') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { - const validatedRequest = parseSchema(ElicitRequestSchema, request); + const wrappedHandler = async (request: Request, ctx: ClientContext): Promise => { + const validatedRequest = await parseSchema(ElicitRequestSchema, request); if (!validatedRequest.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -363,7 +386,7 @@ export class Client extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + const taskValidationResult = await parseSchema(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -375,7 +398,7 @@ export class Client extends Protocol { } // For non-task requests, validate against ElicitResultSchema - const validationResult = parseSchema(ElicitResultSchema, result); + const validationResult = await parseSchema(ElicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -404,12 +427,12 @@ export class Client extends Protocol { }; // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + return super.setRequestHandler(method as RequestMethod, wrappedHandler); } if (method === 'sampling/createMessage') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + const wrappedHandler = async (request: Request, ctx: ClientContext): Promise => { + const validatedRequest = await parseSchema(CreateMessageRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); @@ -422,7 +445,7 @@ export class Client extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + const taskValidationResult = await parseSchema(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -436,7 +459,7 @@ export class Client extends Protocol { // For non-task requests, validate against appropriate schema based on tools presence const hasTools = params.tools || params.toolChoice; const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; - const validationResult = parseSchema(resultSchema, result); + const validationResult = await parseSchema(resultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); @@ -447,11 +470,11 @@ export class Client extends Protocol { }; // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + return super.setRequestHandler(method as RequestMethod, wrappedHandler); } // Other handlers use default behavior - return super.setRequestHandler(method, handler); + return super.setRequestHandler(method as RequestMethod, handler); } protected assertCapability(capability: keyof ServerCapabilities, method: string): void { @@ -538,7 +561,7 @@ export class Client extends Protocol { // Set up list changed handlers now that we know server capabilities if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); + await this._setupListChangedHandlers(this._pendingListChangedConfig); this._pendingListChangedConfig = undefined; } } catch (error) { @@ -578,7 +601,7 @@ export class Client extends Protocol { return this._instructions; } - protected assertCapabilityForMethod(method: RequestMethod): void { + protected override assertCapabilityForMethod(method: RequestMethod): void { switch (method as ClientRequest['method']) { case 'logging/setLevel': { if (!this._serverCapabilities?.logging) { @@ -641,7 +664,7 @@ export class Client extends Protocol { } } - protected assertNotificationCapability(method: NotificationMethod): void { + protected override assertNotificationCapability(method: NotificationMethod): void { switch (method as ClientNotification['method']) { case 'notifications/roots/list_changed': { if (!this._capabilities.roots?.listChanged) { @@ -670,7 +693,7 @@ export class Client extends Protocol { } } - protected assertRequestHandlerCapability(method: string): void { + protected override assertRequestHandlerCapability(method: string): void { switch (method) { case 'sampling/createMessage': { if (!this._capabilities.sampling) { @@ -709,11 +732,11 @@ export class Client extends Protocol { } } - protected assertTaskCapability(method: string): void { + protected override assertTaskCapability(method: string): void { assertToolsCallTaskCapability(this._serverCapabilities?.tasks?.requests, method, 'Server'); } - protected assertTaskHandlerCapability(method: string): void { + protected override assertTaskHandlerCapability(method: string): void { assertClientRequestTaskCapability(this._capabilities?.tasks?.requests, method, 'Client'); } @@ -1005,14 +1028,14 @@ export class Client extends Protocol { * Set up a single list changed handler. * @internal */ - private _setupListChangedHandler( + private async _setupListChangedHandler( listType: string, notificationMethod: NotificationMethod, options: ListChangedOptions, fetcher: () => Promise - ): void { - // Validate options using Zod schema (validates autoRefresh and debounceMs) - const parseResult = parseSchema(ListChangedOptionsBaseSchema, options); + ): Promise { + // Validate options (autoRefresh and debounceMs) + const parseResult = await parseSchema(ListChangedOptionsBaseSchema, options); if (!parseResult.success) { throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); } diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 2dc1e13a8..4bd702d82 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -38,7 +38,11 @@ export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/aut // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils.js'; -// Protocol types (NOT the Protocol class itself or mergeCapabilities) +// Role-agnostic Protocol class (concrete; Client/Server extend it). NOT mergeCapabilities. +export type { McpSpec, ProtocolSpec, SpecNotifications, SpecRequests } from '../../shared/protocol.js'; +export { Protocol } from '../../shared/protocol.js'; +export { InMemoryTransport } from '../../util/inMemory.js'; +// Protocol types export type { BaseContext, ClientContext, diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab6932..d7acb9f32 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -46,6 +46,7 @@ import { } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport, TransportSendOptions } from './transport.js'; @@ -291,11 +292,55 @@ type TimeoutInfo = { onTimeout: () => void; }; +/** + * Declares the request and notification vocabulary a {@linkcode Protocol} instance speaks. + * + * Supplying a concrete `ProtocolSpec` to `Protocol` gives method-name autocomplete and + * params/result correlation on the typed overloads of `setRequestHandler`, `request`, + * `setNotificationHandler` and `notification`. The default leaves all four string-keyed and + * untyped. + */ +export type ProtocolSpec = { + requests?: Record; + notifications?: Record; +}; + +/** + * The {@linkcode ProtocolSpec} that describes the standard MCP method vocabulary, derived from + * {@linkcode RequestTypeMap}, {@linkcode ResultTypeMap} and {@linkcode NotificationTypeMap}. + * + * `Client` and `Server` extend `Protocol` (default `S`), so the typed-`S` overloads do not + * fire on them — spec methods use the 2-arg form, custom methods use the 3-arg string form. + */ +export type McpSpec = { + requests: { [M in RequestMethod]: { params: RequestTypeMap[M]['params']; result: ResultTypeMap[M] } }; + notifications: { [M in NotificationMethod]: { params: NotificationTypeMap[M]['params'] } }; +}; + +type _Requests = NonNullable; +type _Notifications = NonNullable; + +/** + * Method-name keys from a {@linkcode ProtocolSpec}'s `requests` map, or `never` for the + * unconstrained default `ProtocolSpec`. Making the keys `never` for the default disables the + * spec-typed overloads on {@linkcode Protocol.setRequestHandler}/{@linkcode Protocol.request} + * until the caller supplies a concrete `S`. + */ +export type SpecRequests = string extends keyof _Requests ? never : keyof _Requests & string; + +/** See {@linkcode SpecRequests}. */ +export type SpecNotifications = string extends keyof _Notifications ? never : keyof _Notifications & string; + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. + * + * `Protocol` is concrete and role-agnostic: `new Protocol()` yields a JSON-RPC peer with no + * MCP capability or direction enforcement. `Client` and `Server` extend it with role-specific + * capability checks. Subclasses (such as MCP-dialect protocols like MCP Apps) can supply a + * {@linkcode ProtocolSpec} type argument to get method-name autocomplete on their own vocabulary. */ -export abstract class Protocol { +export class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); @@ -390,10 +435,17 @@ 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. + * + * The base implementation returns the {@linkcode BaseContext} unchanged. Subclasses override to + * enrich it (e.g. `Server` adds `http` and `mcpReq.log` to produce `ServerContext`). + * + * **Note:** if you supply a `ContextT` other than `BaseContext`, you must override this method + * to construct it. The default cast is only sound for `ContextT = BaseContext`. */ - protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + protected buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ContextT { + return ctx as ContextT; + } private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { @@ -736,54 +788,82 @@ export abstract class Protocol { } /** - * A method to check if a capability is supported by the remote side, for the given method to be called. - * - * This should be implemented by subclasses. + * Checks that the remote side supports the capability required for the given outbound request + * method. The base implementation is a no-op (role-agnostic peers do not enforce capabilities). + * `Client` and `Server` override this to enforce MCP capability negotiation. */ - protected abstract assertCapabilityForMethod(method: RequestMethod): void; + protected assertCapabilityForMethod(_method: string): void {} /** - * A method to check if a notification is supported by the local side, for the given method to be sent. - * - * This should be implemented by subclasses. + * Checks that this side may send the given notification method. The base implementation is a + * no-op; `Client` and `Server` override to enforce MCP capability negotiation. */ - protected abstract assertNotificationCapability(method: NotificationMethod): void; + protected assertNotificationCapability(_method: string): void {} /** - * A method to check if a request handler is supported by the local side, for the given method to be handled. - * - * This should be implemented by subclasses. + * Checks that this side may register a handler for the given request method. The base + * implementation is a no-op; `Client` and `Server` override to enforce MCP capability + * negotiation. */ - protected abstract assertRequestHandlerCapability(method: string): void; + protected 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). - * This should be implemented by subclasses. + * Checks that the remote side supports task creation for the given method. Called when sending + * a task-augmented outbound request under `enforceStrictCapabilities`. The base implementation + * is a no-op. */ - protected abstract assertTaskCapability(method: string): void; + protected 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. - * This should be implemented by subclasses. + * Checks that this side supports handling task creation for the given method. Called when + * receiving a task-augmented inbound request. The base implementation is a no-op. */ - protected abstract assertTaskHandlerCapability(method: string): void; + protected 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. + * + * Two call forms: + * - **Spec method, object form** — `request({ method: 'tools/call', params }, options?)`. The + * result schema is resolved automatically from the method name. + * - **String form** — `request('ui/open-link', params, resultSchema, options?)`. Accepts any + * method string and validates the result against the supplied schema. When `method` is listed + * in this instance's {@linkcode ProtocolSpec}, params and result are typed accordingly. * - * Do not use this method to emit notifications! Use {@linkcode Protocol.notification | notification()} instead. + * Do not use this method to emit notifications! Use + * {@linkcode Protocol.notification | notification()} instead. */ + request, R extends StandardSchemaV1<_Requests[K]['result']>>( + method: K, + params: _Requests[K]['params'], + resultSchema: R, + options?: RequestOptions + ): Promise>; 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( + method: string, + params: Record | undefined, + resultSchema: R, + options?: RequestOptions + ): Promise>; + request( + requestOrMethod: { method: RequestMethod; params?: Record } | string, + optionsOrParams?: RequestOptions | Record, + resultSchema?: AnySchema, + options?: RequestOptions + ): Promise { + if (typeof requestOrMethod === 'string') { + return this._requestWithSchema( + { method: requestOrMethod, params: optionsOrParams as Record | undefined } as Request, + resultSchema!, + options + ); + } + const schema = getResultSchema(requestOrMethod.method); + return this._requestWithSchema(requestOrMethod as Request, schema, optionsOrParams as RequestOptions | undefined); } /** @@ -809,7 +889,7 @@ export abstract class Protocol { }; if (!this._transport) { - earlyReject(new Error('Not connected')); + earlyReject(new SdkError(SdkErrorCode.NotConnected, 'Not connected')); return; } @@ -865,7 +945,7 @@ export abstract class Protocol { reject(error); }; - this._responseHandlers.set(messageId, response => { + this._responseHandlers.set(messageId, async response => { if (options?.signal?.aborted) { return; } @@ -875,7 +955,7 @@ export abstract class Protocol { } try { - const parseResult = parseSchema(resultSchema, response.result); + const parseResult = await parseSchema(resultSchema, response.result); if (parseResult.success) { resolve(parseResult.data as SchemaOutput); } else { @@ -944,8 +1024,33 @@ export abstract class Protocol { /** * Emits a notification, which is a one-way message that does not expect a response. + * + * Two call forms: `notification({ method, params }, options?)` (spec methods, capability check + * applies) and `notification(method, params?, options?)` (any method string; when `method` is + * listed in this instance's {@linkcode ProtocolSpec}, params is typed accordingly). */ - async notification(notification: Notification, options?: NotificationOptions): Promise { + notification>( + method: K, + params: _Notifications[K]['params'], + options?: NotificationOptions + ): Promise; + notification(notification: Notification, options?: NotificationOptions): Promise; + notification(method: string, params?: Record, options?: NotificationOptions): Promise; + notification( + notificationOrMethod: Notification | string, + optionsOrParams?: NotificationOptions | Record, + maybeOptions?: NotificationOptions + ): Promise { + if (typeof notificationOrMethod === 'string') { + return this._sendNotification( + { method: notificationOrMethod, params: optionsOrParams as Record | undefined }, + maybeOptions + ); + } + return this._sendNotification(notificationOrMethod, optionsOrParams as NotificationOptions | undefined); + } + + private async _sendNotification(notification: Notification, options?: NotificationOptions): Promise { if (!this._transport) { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } @@ -1001,60 +1106,133 @@ 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. + * Three call forms: + * - **Spec method, two args** — `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. + * - **Three args, typed via `S`** — `setRequestHandler('ui/open-link', ParamsSchema, (params, ctx) => …)`. + * For methods listed in this instance's {@linkcode ProtocolSpec}, `params` and the result + * are typed accordingly. The supplied schema validates incoming `params`. + * - **Three args, untyped fallback** — same shape, any method string, `params` typed by + * `paramsSchema`. Absent or undefined `params` are normalized to `{}` (after stripping + * `_meta`) before validation, so for no-params methods use `z.object({})`. */ + setRequestHandler, P extends StandardSchemaV1<_Requests[K]['params']>>( + method: K, + paramsSchema: P, + handler: (params: SchemaOutput

, ctx: ContextT) => _Requests[K]['result'] | Promise<_Requests[K]['result']> + ): void; setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise + ): void; + setRequestHandler

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

, ctx: ContextT) => Result | Promise + ): void; + setRequestHandler( + method: string, + schemaOrHandler: AnySchema | ((request: Request, ctx: ContextT) => Result | Promise), + maybeHandler?: (params: unknown, ctx: ContextT) => unknown ): void { this.assertRequestHandlerCapability(method); - const schema = getRequestSchema(method); - this._requestHandlers.set(method, (request, ctx) => { - const parsed = schema.parse(request) as RequestTypeMap[M]; - return Promise.resolve(handler(parsed, ctx)); + if (maybeHandler === undefined) { + const handler = schemaOrHandler as (request: Request, ctx: ContextT) => Result | Promise; + const schema = getRequestSchema(method as RequestMethod); + this._requestHandlers.set(method, (request, ctx) => { + const parsed = schema.parse(request) as Request; + return Promise.resolve(handler(parsed, ctx)); + }); + return; + } + + const paramsSchema = schemaOrHandler as AnySchema; + this._requestHandlers.set(method, async (request, ctx) => { + const { _meta, ...userParams } = (request.params ?? {}) as Record; + void _meta; + const parsed = await parseSchema(paramsSchema, userParams); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + return maybeHandler(parsed.data, ctx) as Result | Promise; }); } /** * 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 two-arg spec-method form (handler receives the full + * notification object) and a three-arg form with a `paramsSchema` (handler receives validated + * `params`). The three-arg form accepts any method string; when `method` is listed in this + * instance's {@linkcode ProtocolSpec} the params type is inferred from `S`. */ + setNotificationHandler, P extends StandardSchemaV1<_Notifications[K]['params']>>( + method: K, + paramsSchema: P, + handler: (params: SchemaOutput

) => void | Promise + ): void; setNotificationHandler( method: M, handler: (notification: NotificationTypeMap[M]) => void | Promise + ): void; + setNotificationHandler

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

) => void | Promise + ): void; + setNotificationHandler( + method: string, + schemaOrHandler: AnySchema | ((notification: Notification) => void | Promise), + maybeHandler?: (params: unknown) => void | Promise ): void { - const schema = getNotificationSchema(method); + if (maybeHandler === undefined) { + const handler = schemaOrHandler as (notification: Notification) => void | Promise; + const schema = getNotificationSchema(method as NotificationMethod); + this._notificationHandlers.set(method, notification => { + const parsed = schema.parse(notification); + return Promise.resolve(handler(parsed)); + }); + return; + } - this._notificationHandlers.set(method, notification => { - const parsed = schema.parse(notification); - return Promise.resolve(handler(parsed)); + const paramsSchema = schemaOrHandler as AnySchema; + this._notificationHandlers.set(method, async notification => { + const { _meta, ...userParams } = (notification.params ?? {}) as Record; + void _meta; + const parsed = await parseSchema(paramsSchema, userParams); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + return maybeHandler(parsed.data); }); } /** * 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/types/schemas.ts b/packages/core/src/types/schemas.ts index 86acf11d7..4743f4f25 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -2209,6 +2209,20 @@ const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, NotificationSchemaType >; +/** + * Type predicate: returns true if `method` is a standard MCP request method. + */ +export function isRequestMethod(method: string): method is RequestMethod { + return Object.hasOwn(requestSchemas, method); +} + +/** + * Type predicate: returns true if `method` is a standard MCP notification method. + */ +export function isNotificationMethod(method: string): method is NotificationMethod { + return Object.hasOwn(notificationSchemas, method); +} + /** * Gets the Zod schema for a given request method. * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index 9676674b8..07424bd88 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -1,32 +1,47 @@ /** - * Internal Zod schema utilities for protocol handling. + * Standard Schema utilities for protocol handling. * These are used internally by the SDK for protocol message validation. */ -import * as z from 'zod/v4'; +import type * as z from 'zod/v4'; + +import type { StandardSchemaV1 } from './standardSchema.js'; +import { validateStandardSchema } from './standardSchema.js'; /** - * Base type for any Zod schema. + * Base type for any schema accepted by the SDK's user-facing schema parameters. + * + * This is the Standard Schema interface (https://standardschema.dev), which Zod, Valibot, ArkType + * and others implement. Zod schemas satisfy this constraint natively. */ -export type AnySchema = z.core.$ZodType; +export type AnySchema = StandardSchemaV1; /** * A Zod schema for objects specifically. + * + * Retained for internal use where the SDK needs Zod-specific introspection (e.g. converting a tool + * input schema to JSON Schema). Not used for user-facing schema parameters. */ export type AnyObjectSchema = z.core.$ZodObject; /** - * Extracts the output type from a Zod schema. + * Extracts the output type from a Standard Schema. */ -export type SchemaOutput = z.output; +export type SchemaOutput = StandardSchemaV1.InferOutput; /** - * Parses data against a Zod schema (synchronous). - * Returns a discriminated union with success/error. + * Parses data against a Standard Schema. + * + * Returns a discriminated union with success/error. The error is a plain `Error` whose `message` + * is a comma-separated list of issues, so callers can interpolate it directly. */ -export function parseSchema( +export async function parseSchema( schema: T, data: unknown -): { success: true; data: z.output } | { success: false; error: z.core.$ZodError } { - return z.safeParse(schema, data); +): Promise<{ success: true; data: SchemaOutput } | { success: false; error: Error }> { + const result = await validateStandardSchema(schema, data); + if (result.success) { + return result; + } + return { success: false, error: new Error(result.error) }; } diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 9817dc39a..1f2b12672 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -169,15 +169,15 @@ function formatIssue(issue: StandardSchemaV1.Issue): string { return `${path}: ${issue.message}`; } -export async function validateStandardSchema( +export async function validateStandardSchema( schema: T, data: unknown -): Promise>> { +): Promise>> { const result = await schema['~standard'].validate(data); if (result.issues && result.issues.length > 0) { return { success: false, error: result.issues.map(i => formatIssue(i)).join(', ') }; } - return { success: true, data: (result as StandardSchemaV1.SuccessResult).value as StandardSchemaWithJSON.InferOutput }; + return { success: true, data: (result as StandardSchemaV1.SuccessResult).value as StandardSchemaV1.InferOutput }; } // Prompt argument extraction diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts new file mode 100644 index 000000000..ddddeca68 --- /dev/null +++ b/packages/core/test/shared/customMethods.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import type { BaseContext, ProtocolSpec, SpecRequests } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import type { StandardSchemaV1 } from '../../src/util/standardSchema.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +async function makePair() { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const a = new Protocol(); + const b = new Protocol(); + await a.connect(t1); + await b.connect(t2); + return { a, b }; +} + +describe('Protocol — concrete and role-agnostic', () => { + it('can be instantiated directly', () => { + const p = new Protocol(); + expect(p).toBeInstanceOf(Protocol); + expect(p.transport).toBeUndefined(); + }); + + it('connects, exchanges ping, and closes', async () => { + const { a, b } = await makePair(); + await expect(a.request({ method: 'ping' })).resolves.toEqual({}); + await a.close(); + await b.close(); + }); +}); + +describe('setRequestHandler — three-arg paramsSchema form', () => { + it('round-trips a custom request with validated params', async () => { + const { a, b } = await makePair(); + b.setRequestHandler('acme/echo', z.object({ msg: z.string() }), params => ({ reply: params.msg.toUpperCase() })); + const result = await a.request('acme/echo', { msg: 'hi' }, z.object({ reply: z.string() })); + expect(result).toEqual({ reply: 'HI' }); + }); + + it('rejects invalid params with InvalidParams', async () => { + const { a, b } = await makePair(); + b.setRequestHandler('acme/echo', z.object({ msg: z.string() }), p => ({ reply: p.msg })); + await expect(a.request('acme/echo', { msg: 42 } as never, z.object({ reply: z.string() }))).rejects.toThrow( + /Invalid params for acme\/echo/ + ); + }); + + it('normalizes absent params to {} and strips _meta', async () => { + const { a, b } = await makePair(); + let seen: unknown; + b.setRequestHandler('acme/noop', z.object({}).strict(), p => { + seen = p; + return {}; + }); + await a.request('acme/noop', undefined, z.object({})); + expect(seen).toEqual({}); + }); + + it('passes ctx (signal) to the handler', async () => { + const { a, b } = await makePair(); + let ctx: BaseContext | undefined; + b.setRequestHandler('acme/ctx', z.object({}), (_p, c) => { + ctx = c; + return {}; + }); + await a.request('acme/ctx', {}, z.object({})); + expect(ctx?.mcpReq.signal).toBeInstanceOf(AbortSignal); + }); + + it('removeRequestHandler works for any method string', async () => { + const { a, b } = await makePair(); + b.setRequestHandler('acme/tmp', z.object({}), () => ({})); + await expect(a.request('acme/tmp', {}, z.object({}))).resolves.toEqual({}); + b.removeRequestHandler('acme/tmp'); + await expect(a.request('acme/tmp', {}, z.object({}))).rejects.toThrow(/Method not found/); + }); + + it('two-arg spec-method form still works on bare Protocol', 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 — three-arg paramsSchema form', () => { + it('receives a custom notification', async () => { + const { a, b } = await makePair(); + const received: unknown[] = []; + b.setNotificationHandler('acme/tick', z.object({ n: z.number() }), p => { + received.push(p); + }); + await a.notification('acme/tick', { n: 1 }); + await a.notification('acme/tick', { n: 2 }); + await new Promise(r => setTimeout(r, 0)); + expect(received).toEqual([{ n: 1 }, { n: 2 }]); + }); + + it('object-form notification 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('ProtocolSpec typing', () => { + type AppSpec = { + requests: { + 'ui/open-link': { params: { url: string }; result: { opened: boolean } }; + 'tools/call': { params: { name: string; arguments?: Record }; result: Record }; + }; + notifications: { + 'ui/size-changed': { params: { width: number; height: number } }; + }; + }; + + type _Assert = T; + type _Eq = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + type _t1 = _Assert<_Eq, 'ui/open-link' | 'tools/call'>>; + type _t2 = _Assert<_Eq, never>>; + void (undefined as unknown as [_t1, _t2]); + + it('typed overloads infer params/result from S; string fallback still works', async () => { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const app = new Protocol(); + const host = new Protocol(); + await app.connect(t1); + await host.connect(t2); + + host.setRequestHandler('ui/open-link', z.object({ url: z.string() }), p => { + const _p: { url: string } = p; + void _p; + return { opened: true }; + }); + const r1 = await app.request('ui/open-link', { url: 'https://x' }, z.object({ opened: z.boolean() })); + const _r1: { opened: boolean } = r1; + void _r1; + expect(r1.opened).toBe(true); + + host.setRequestHandler('not/in-spec', z.object({ n: z.number() }), p => ({ doubled: p.n * 2 })); + const r2 = await app.request('not/in-spec', { n: 3 }, z.object({ doubled: z.number() })); + expect(r2.doubled).toBe(6); + + const bare = new Protocol(); + const peer = new Protocol(); + const [t3, t4] = InMemoryTransport.createLinkedPair(); + await bare.connect(t3); + await peer.connect(t4); + peer.setRequestHandler('anything', z.object({}), () => ({ ok: true })); + const r3 = await bare.request('anything', {}, z.object({ ok: z.boolean() })); + expect(r3.ok).toBe(true); + }); + + it('Protocol instantiates and routes ui/* alongside spec-shaped methods', async () => { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const app = new Protocol(); + const host = new Protocol(); + await app.connect(t1); + await host.connect(t2); + + host.setRequestHandler('ui/open-link', z.object({ url: z.string() }), params => { + const _typed: string = params.url; + void _typed; + return { opened: true }; + }); + const r = await app.request('ui/open-link', { url: 'https://x' }, z.object({ opened: z.boolean() })); + expect(r.opened).toBe(true); + + let size: { width: number; height: number } | undefined; + host.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), p => { + size = p; + }); + await app.notification('ui/size-changed', { width: 800, height: 600 }); + await new Promise(r => setTimeout(r, 0)); + expect(size).toEqual({ width: 800, height: 600 }); + }); + + it('spec-shaped methods (tools/call) work without role enforcement', async () => { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const app = new Protocol(); + const host = new Protocol(); + await app.connect(t1); + await host.connect(t2); + + host.setRequestHandler( + 'tools/call', + z.object({ name: z.string(), arguments: z.record(z.string(), z.unknown()).optional() }), + p => ({ content: [{ type: 'text', text: `called ${p.name}` }] }) + ); + const r = await app.request('tools/call', { name: 'weather' }, z.object({ content: z.array(z.unknown()) })); + expect(r.content).toHaveLength(1); + }); +}); + +describe('non-Zod StandardSchemaV1', () => { + function makeStandardSchema(check: (v: unknown) => v is T): StandardSchemaV1 { + return { + '~standard': { + version: 1 as const, + vendor: 'test', + types: undefined as unknown as { input: T; output: T }, + validate: (v: unknown) => (check(v) ? { value: v } : { issues: [{ message: 'invalid', path: [] }] }) + } + }; + } + + it('accepts a hand-rolled StandardSchemaV1', async () => { + const { a, b } = await makePair(); + type Params = { n: number }; + const Params = makeStandardSchema((v): v is Params => typeof (v as Params)?.n === 'number'); + const Result = makeStandardSchema<{ doubled: number }>( + (v): v is { doubled: number } => typeof (v as { doubled: number })?.doubled === 'number' + ); + b.setRequestHandler('acme/double', Params, (p: Params) => ({ doubled: p.n * 2 })); + const r = await a.request('acme/double', { n: 21 }, Result); + expect(r.doubled).toBe(42); + }); + + it('typed-S overload types handler from passed schema, not S (regression)', () => { + type Spec = { requests: { 'x/y': { params: { a: string; b: string }; result: { ok: boolean } } } }; + const p = new Protocol(); + const Narrow = z.object({ a: z.string() }); + p.setRequestHandler('x/y', Narrow, params => { + const _a: string = params.a; + // @ts-expect-error -- params is SchemaOutput, has no 'b' even though Spec does + const _b: string = params.b; + void _a; + void _b; + return { ok: true }; + }); + }); +}); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376..b461f0148 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -38,13 +38,13 @@ import { ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../s import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; // Test Protocol subclass for testing -class TestProtocolImpl extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - protected buildContext(ctx: BaseContext): BaseContext { +class TestProtocolImpl extends Protocol { + protected override assertCapabilityForMethod(): void {} + protected override assertNotificationCapability(): void {} + protected override assertRequestHandlerCapability(): void {} + protected override assertTaskCapability(): void {} + protected override assertTaskHandlerCapability(): void {} + protected override buildContext(ctx: BaseContext): BaseContext { return ctx; } } @@ -174,14 +174,14 @@ function assertQueuedRequest(o?: QueuedMessage): asserts o is QueuedRequest { * use custom method names not present in RequestMethod. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function testRequest(proto: Protocol, request: Request, resultSchema: ZodType, options?: any) { +function testRequest(proto: Protocol, request: Request, resultSchema: ZodType, options?: any) { return ( proto as unknown as { _requestWithSchema: (request: Request, resultSchema: ZodType, options?: unknown) => Promise } )._requestWithSchema(request, resultSchema, options); } describe('protocol tests', () => { - let protocol: Protocol; + let protocol: Protocol; let transport: MockTransport; let sendSpy: MockInstance; @@ -1069,7 +1069,7 @@ describe('mergeCapabilities', () => { }); describe('Task-based execution', () => { - let protocol: Protocol; + let protocol: Protocol; let transport: MockTransport; let sendSpy: MockInstance; @@ -2263,7 +2263,7 @@ describe('Task-based execution', () => { }); describe('Request Cancellation vs Task Cancellation', () => { - let protocol: Protocol; + let protocol: Protocol; let transport: MockTransport; let taskStore: TaskStore; @@ -2542,7 +2542,7 @@ describe('Request Cancellation vs Task Cancellation', () => { }); describe('Progress notification support for tasks', () => { - let protocol: Protocol; + let protocol: Protocol; let transport: MockTransport; let sendSpy: MockInstance; @@ -3682,7 +3682,7 @@ describe('Message interception for task-related requests', () => { }); describe('Message Interception', () => { - let protocol: Protocol; + let protocol: Protocol; let transport: MockTransport; let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; @@ -4198,7 +4198,7 @@ describe('Message Interception', () => { }); describe('Queue lifecycle management', () => { - let protocol: Protocol; + let protocol: Protocol; let transport: MockTransport; let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; @@ -4897,7 +4897,7 @@ describe('requestStream() method', () => { }); describe('Error handling for missing resolvers', () => { - let protocol: Protocol; + let protocol: Protocol; let transport: MockTransport; let taskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; let taskMessageQueue: TaskMessageQueue; diff --git a/packages/core/test/shared/protocolTransportHandling.test.ts b/packages/core/test/shared/protocolTransportHandling.test.ts index 4e9c33e67..d650e39de 100644 --- a/packages/core/test/shared/protocolTransportHandling.test.ts +++ b/packages/core/test/shared/protocolTransportHandling.test.ts @@ -29,18 +29,18 @@ class MockTransport implements Transport { } describe('Protocol transport handling bug', () => { - let protocol: Protocol; + let protocol: Protocol; let transportA: MockTransport; let transportB: MockTransport; beforeEach(() => { - protocol = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - protected buildContext(ctx: BaseContext): BaseContext { + protocol = new (class extends Protocol { + protected override assertCapabilityForMethod(): void {} + protected override assertNotificationCapability(): void {} + protected override assertRequestHandlerCapability(): void {} + protected override assertTaskCapability(): void {} + protected override assertTaskHandlerCapability(): void {} + protected override buildContext(ctx: BaseContext): BaseContext { return ctx; } })(); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4361f3e1e..b02e970fe 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,4 +1,5 @@ import type { + AnySchema, BaseContext, ClientCapabilities, CreateMessageRequest, @@ -21,11 +22,15 @@ import type { NotificationMethod, NotificationOptions, ProtocolOptions, + ProtocolSpec, + Request, RequestMethod, RequestOptions, RequestTypeMap, ResourceUpdatedNotification, + Result, ResultTypeMap, + SchemaOutput, ServerCapabilities, ServerContext, ServerResult, @@ -96,7 +101,7 @@ export type ServerOptions = ProtocolOptions & { * * @deprecated Use {@linkcode server/mcp.McpServer | McpServer} instead for the high-level API. Only use `Server` for advanced use cases. */ -export class Server extends Protocol { +export class Server extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; private _capabilities: ServerCapabilities; @@ -145,7 +150,7 @@ export class Server extends Protocol { const transportSessionId: string | undefined = ctx.sessionId || (ctx.http?.req?.headers.get('mcp-session-id') as string) || undefined; const { level } = request.params; - const parseResult = parseSchema(LoggingLevelSchema, level); + const parseResult = await parseSchema(LoggingLevelSchema, level); if (parseResult.success) { this._loggingLevels.set(transportSessionId, parseResult.data); } @@ -225,10 +230,28 @@ export class Server extends Protocol { public override setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise + ): void; + public override setRequestHandler

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

, ctx: ServerContext) => Result | Promise + ): void; + public override setRequestHandler( + method: string, + schemaOrHandler: unknown, + maybeHandler?: (params: unknown, ctx: ServerContext) => unknown ): void { + if (maybeHandler !== undefined) { + return super.setRequestHandler( + method, + schemaOrHandler as AnySchema, + maybeHandler as (params: unknown, ctx: ServerContext) => Result | Promise + ); + } + const handler = schemaOrHandler as (request: Request, ctx: ServerContext) => ServerResult | Promise; if (method === 'tools/call') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ServerContext): Promise => { - const validatedRequest = parseSchema(CallToolRequestSchema, request); + const wrappedHandler = async (request: Request, ctx: ServerContext): Promise => { + const validatedRequest = await parseSchema(CallToolRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); @@ -241,7 +264,7 @@ export class Server extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + const taskValidationResult = await parseSchema(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -253,7 +276,7 @@ export class Server extends Protocol { } // For non-task requests, validate against CallToolResultSchema - const validationResult = parseSchema(CallToolResultSchema, result); + const validationResult = await parseSchema(CallToolResultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); @@ -264,14 +287,14 @@ export class Server extends Protocol { }; // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + return super.setRequestHandler(method as RequestMethod, wrappedHandler); } // Other handlers use default behavior - return super.setRequestHandler(method, handler); + return super.setRequestHandler(method as RequestMethod, handler); } - protected assertCapabilityForMethod(method: RequestMethod): void { + protected override assertCapabilityForMethod(method: RequestMethod): void { switch (method) { case 'sampling/createMessage': { if (!this._clientCapabilities?.sampling) { @@ -304,7 +327,7 @@ export class Server extends Protocol { } } - protected assertNotificationCapability(method: NotificationMethod): void { + protected override assertNotificationCapability(method: NotificationMethod): void { switch (method) { case 'notifications/message': { if (!this._capabilities.logging) { @@ -366,7 +389,7 @@ export class Server extends Protocol { } } - protected assertRequestHandlerCapability(method: string): void { + protected override assertRequestHandlerCapability(method: string): void { switch (method) { case 'completion/complete': { if (!this._capabilities.completions) { @@ -415,11 +438,11 @@ export class Server extends Protocol { } } - protected assertTaskCapability(method: string): void { + protected override assertTaskCapability(method: string): void { assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, 'Client'); } - protected assertTaskHandlerCapability(method: string): void { + protected override assertTaskHandlerCapability(method: string): void { assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server'); } From ebc679c8aa4ec87eb175ff96dbde0c84c4cd33a5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 12:21:01 +0000 Subject: [PATCH 2/5] docs(core): migration guidance and examples for custom-method overloads --- .changeset/protocol-concrete.md | 7 + docs/migration-SKILL.md | 13 ++ docs/migration.md | 46 ++++++ examples/client/README.md | 1 + examples/client/src/customMethodExample.ts | 47 ++++++ examples/server/README.md | 1 + examples/server/src/customMethodExample.ts | 47 ++++++ .../server/src/customMethodExtAppsExample.ts | 134 ++++++++++++++++++ 8 files changed, 296 insertions(+) create mode 100644 .changeset/protocol-concrete.md create mode 100644 examples/client/src/customMethodExample.ts create mode 100644 examples/server/src/customMethodExample.ts create mode 100644 examples/server/src/customMethodExtAppsExample.ts diff --git a/.changeset/protocol-concrete.md b/.changeset/protocol-concrete.md new file mode 100644 index 000000000..8706b10e2 --- /dev/null +++ b/.changeset/protocol-concrete.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Make `Protocol` concrete and exported. `setRequestHandler`/`setNotificationHandler`/`request`/`notification` gain a 3-arg `(method: string, paramsSchema, handler)` overload for non-standard methods alongside the spec-typed form. Optional `Protocol` generic +for declaring a typed method vocabulary. The five `assert*Capability` abstracts are now no-op virtuals (`Client`/`Server` override to enforce). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index a37b5e206..a9dd8d4c8 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -377,6 +377,19 @@ 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 — use the three-arg overloads of `setRequestHandler`/`setNotificationHandler`/`request`/`notification`, which accept any method string with a caller-supplied params/result schema. `Protocol` is now concrete and exported, so MCP-dialect protocols can subclass it directly: + +| v1 | v2 | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| `setRequestHandler(CustomReqSchema, (req, extra) => ...)` | `setRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` | +| `setNotificationHandler(CustomNotifSchema, n => ...)` | `setNotificationHandler('vendor/method', ParamsSchema, params => ...)` | +| `this.request({ method: 'vendor/x', params }, ResultSchema)` | `this.request('vendor/x', params, ResultSchema)` | +| `this.notification({ method: 'vendor/x', params })` | `this.notification('vendor/x', params)` | +| `class X extends Protocol` | `class X extends Client` (or `Server`), or compose a `Client` instance | + +The v1 schema's `.shape.params` becomes the `ParamsSchema` argument; the `method: z.literal('...')` value becomes the string argument. + + ## 10. Request Handler Context Types `RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks. diff --git a/docs/migration.md b/docs/migration.md index 7cb7d58f6..ec16f163b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -382,6 +382,52 @@ Common method string replacements: | `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` | | `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` | +### Custom (non-standard) protocol methods + +In v1, vendor-specific methods were registered the same way as spec methods (`setRequestHandler(zodSchemaWithMethodLiteral, handler)`), and `Protocol` widened the send-side type unions. + +In v2, `Protocol` is concrete and exported. Its `setRequestHandler`, `setNotificationHandler`, `request` and `notification` each have a string-method overload that accepts any method name with a caller-supplied params/result schema: + +**Before (v1):** + +```typescript +import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js'; + +class App extends Protocol { + constructor() { + super(); + this.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] })); + } + search(query: string) { + return this.request({ method: 'acme/search', params: { query } }, SearchResultSchema); + } +} +``` + +**After (v2):** + +```typescript +import { Protocol, type ProtocolSpec } from '@modelcontextprotocol/client'; + +type AppSpec = { + requests: { 'acme/search': { params: { query: string }; result: { hits: string[] } } }; +} satisfies ProtocolSpec; + +class App extends Protocol { + constructor() { + super(); + this.setRequestHandler('acme/search', SearchParams, params => ({ hits: [params.query] })); + } + search(query: string) { + return this.request('acme/search', { query }, SearchResult); + } +} +``` + +The `ProtocolSpec` type argument is optional — omit it for ad-hoc method strings without autocomplete. For a single vendor method on a stock `Client` or `Server`, call the three-arg overload directly: `server.setRequestHandler('acme/search', SearchParams, handler)`. + +The five `assert*Capability` abstract methods are now no-op virtuals on `Protocol`, so subclasses no longer need to stub them. + ### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter 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 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..c434ff025 --- /dev/null +++ b/examples/client/src/customMethodExample.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * Demonstrates calling a vendor method via the three-arg `client.request(method, params, resultSchema)` + * overload and registering a vendor notification handler with the three-arg `setNotificationHandler` + * overload. + * + * Pair with: examples/server/src/customMethodExample.ts (which contains the in-memory pair). + */ + +import { Client, InMemoryTransport, Protocol } from '@modelcontextprotocol/client'; +import { z } from 'zod'; + +const SearchParams = z.object({ query: z.string() }); +const SearchResult = z.object({ hits: z.array(z.string()) }); +const ProgressParams = z.object({ stage: z.string(), pct: z.number() }); + +async function main() { + const peer = new Protocol(); + peer.setRequestHandler('acme/search', SearchParams, async p => { + await peer.notification('acme/searchProgress', { stage: 'started', pct: 0 }); + await peer.notification('acme/searchProgress', { stage: 'done', pct: 100 }); + return { hits: [p.query, p.query + '-2'] }; + }); + // The bare Protocol must respond to MCP initialize so Client.connect() completes. + peer.setRequestHandler('initialize', req => ({ + protocolVersion: req.params.protocolVersion, + capabilities: {}, + serverInfo: { name: 'peer', version: '1.0.0' } + })); + + const client = new Client({ name: 'c', version: '1.0.0' }, { capabilities: {} }); + client.setNotificationHandler('acme/searchProgress', ProgressParams, p => { + console.log(`[client] progress: ${p.stage} ${p.pct}%`); + }); + + const [t1, t2] = InMemoryTransport.createLinkedPair(); + await peer.connect(t2); + await client.connect(t1); + + const r = await client.request('acme/search', { query: 'widgets' }, SearchResult); + console.log('[client] hits=' + JSON.stringify(r.hits)); + + await client.close(); + await peer.close(); +} + +await main(); 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..1babbfd27 --- /dev/null +++ b/examples/server/src/customMethodExample.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * Demonstrates registering vendor-specific JSON-RPC methods directly on a stock `Server`, and + * calling them from a bare `Protocol` peer (which is role-agnostic and so can act as the client + * side without depending on the client package). + */ + +import { InMemoryTransport, Protocol, Server } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +const SearchParams = z.object({ query: z.string() }); +const SearchResult = z.object({ hits: z.array(z.string()) }); +const TickParams = z.object({ n: z.number() }); + +async function main() { + const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); + + server.setRequestHandler('acme/search', SearchParams, params => { + console.log('[server] acme/search query=' + params.query); + return { hits: [params.query, params.query + '-result'] }; + }); + + server.setNotificationHandler('acme/tick', TickParams, p => { + console.log('[server] acme/tick n=' + p.n); + }); + + const peer = new Protocol(); + + const [peerTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await peer.connect(peerTransport); + await peer.request({ + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'peer', version: '1.0.0' }, capabilities: {} } + }); + + const r = await peer.request('acme/search', { query: 'widgets' }, SearchResult); + console.log('[peer] hits=' + JSON.stringify(r.hits)); + + await peer.notification('acme/tick', { n: 1 }); + await peer.notification('acme/tick', { n: 2 }); + + await peer.close(); + await server.close(); +} + +await main(); diff --git a/examples/server/src/customMethodExtAppsExample.ts b/examples/server/src/customMethodExtAppsExample.ts new file mode 100644 index 000000000..79502f29b --- /dev/null +++ b/examples/server/src/customMethodExtAppsExample.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Demonstrates the ext-apps pattern on top of a concrete, role-agnostic `Protocol`. + * + * `App` and `Host` each extend `Protocol`. The vocabulary mixes spec-named methods + * (`tools/call`, `notifications/message`) and custom `ui/*` methods on a single object — no + * `ui.*`/`client.*` split, no wire renames, no direction enforcement. + */ + +import { InMemoryTransport, Protocol } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +type AppSpec = { + requests: { + 'ui/initialize': { + params: { protocolVersion: string; appInfo: { name: string; version: string } }; + result: { hostInfo: { name: string; version: string }; hostContext: { theme: 'light' | 'dark' } }; + }; + 'ui/open-link': { params: { url: string }; result: { opened: boolean } }; + 'tools/call': { params: { name: string; arguments?: Record }; result: { content: unknown[] } }; + }; + notifications: { + 'ui/tool-result': { params: { toolName: string; text: string } }; + 'ui/size-changed': { params: { width: number; height: number } }; + 'notifications/message': { params: { level: string; data: unknown } }; + }; +}; + +const InitializeParams = z.object({ + protocolVersion: z.string(), + appInfo: z.object({ name: z.string(), version: z.string() }) +}); +const InitializeResult = z.object({ + hostInfo: z.object({ name: z.string(), version: z.string() }), + hostContext: z.object({ theme: z.enum(['light', 'dark']) }) +}); +const OpenLinkParams = z.object({ url: z.string() }); +const OpenLinkResult = z.object({ opened: z.boolean() }); +const CallToolParams = z.object({ name: z.string(), arguments: z.record(z.string(), z.unknown()).optional() }); +const CallToolResult = z.object({ content: z.array(z.unknown()) }); +const ToolResultParams = z.object({ toolName: z.string(), text: z.string() }); +const SizeChangedParams = z.object({ width: z.number(), height: z.number() }); +const LogParams = z.object({ level: z.string(), data: z.unknown() }); + +class App extends Protocol { + private _hostContext?: { theme: 'light' | 'dark' }; + + constructor() { + super(); + this.setNotificationHandler('ui/tool-result', ToolResultParams, p => { + console.log(`[app] tool-result ${p.toolName}: "${p.text}"`); + }); + } + + async initialize() { + const r = await this.request( + 'ui/initialize', + { protocolVersion: '2026-01-26', appInfo: { name: 'demo-ui', version: '1.0.0' } }, + InitializeResult + ); + this._hostContext = r.hostContext; + console.log('[app] hostContext=' + JSON.stringify(this._hostContext)); + } + + openLink(url: string) { + return this.request('ui/open-link', { url }, OpenLinkResult); + } + + callServerTool(name: string, args?: Record) { + return this.request('tools/call', { name, arguments: args }, CallToolResult); + } + + sendLog(level: string, data: unknown) { + return this.notification('notifications/message', { level, data }); + } + + notifySize(width: number, height: number) { + return this.notification('ui/size-changed', { width, height }); + } +} + +class Host extends Protocol { + constructor() { + super(); + this.setRequestHandler('ui/initialize', InitializeParams, p => { + console.log(`[host] ui/initialize from ${p.appInfo.name}@${p.appInfo.version}`); + return { hostInfo: { name: 'demo-host', version: '1.0.0' }, hostContext: { theme: 'dark' as const } }; + }); + this.setRequestHandler('ui/open-link', OpenLinkParams, p => { + console.log('[host] open-link url=' + p.url); + return { opened: true }; + }); + this.setRequestHandler('tools/call', CallToolParams, p => { + console.log('[host] tools/call name=' + p.name); + return { content: [{ type: 'text', text: `result for ${p.name}` }] }; + }); + this.setNotificationHandler('ui/size-changed', SizeChangedParams, p => { + console.log(`[host] size-changed ${p.width}x${p.height}`); + }); + this.setNotificationHandler('notifications/message', LogParams, p => { + console.log(`[host] log [${p.level}]`, p.data); + }); + } + + notifyToolResult(toolName: string, text: string) { + return this.notification('ui/tool-result', { toolName, text }); + } +} + +async function main() { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const host = new Host(); + const app = new App(); + await host.connect(t2); + await app.connect(t1); + + await app.initialize(); + + const { opened } = await app.openLink('https://example.com'); + console.log('[app] openLink -> opened=' + opened); + + const tool = await app.callServerTool('weather', { city: 'Tokyo' }); + console.log('[app] tools/call ->', tool.content); + + await app.sendLog('info', { msg: 'iframe ready' }); + await app.notifySize(800, 600); + await host.notifyToolResult('weather', '18°C, rain'); + + await new Promise(r => setTimeout(r, 0)); + await app.close(); + await host.close(); +} + +await main(); From 0ccb4f1a9f10461984f33ae4d5b26a3b3d387a77 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 11:53:53 +0000 Subject: [PATCH 3/5] feat(core): add deprecate() warn-once helper for v1-compat shims --- .changeset/deprecate-helper.md | 5 +++++ packages/core/src/index.ts | 1 + packages/core/src/util/deprecate.ts | 22 ++++++++++++++++++++++ packages/core/test/util/deprecate.test.ts | 14 ++++++++++++++ 4 files changed, 42 insertions(+) create mode 100644 .changeset/deprecate-helper.md create mode 100644 packages/core/src/util/deprecate.ts create mode 100644 packages/core/test/util/deprecate.test.ts diff --git a/.changeset/deprecate-helper.md b/.changeset/deprecate-helper.md new file mode 100644 index 000000000..f93b2eb37 --- /dev/null +++ b/.changeset/deprecate-helper.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Add internal `deprecate()` warn-once helper for v1-compat shims. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e707d9939..589315c13 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,3 +49,4 @@ export * from './validators/fromJsonSchema.js'; // Core types only - implementations are exported via separate entry points export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; +export { deprecate } from './util/deprecate.js'; diff --git a/packages/core/src/util/deprecate.ts b/packages/core/src/util/deprecate.ts new file mode 100644 index 000000000..556daf6c1 --- /dev/null +++ b/packages/core/src/util/deprecate.ts @@ -0,0 +1,22 @@ +const _warned = new Set(); + +/** + * Emits a one-time deprecation warning to stderr. Subsequent calls with the + * same `key` are no-ops for the lifetime of the process. + * + * Used by v1-compat shims to nudge consumers toward the v2-native API without + * spamming logs on hot paths (e.g. per-tool registration). + * + * @internal + */ +export function deprecate(key: string, msg: string): void { + if (_warned.has(key)) return; + _warned.add(key); + // eslint-disable-next-line no-console + console.warn(`[mcp-sdk] DEPRECATED: ${msg}`); +} + +/** @internal exposed for tests */ +export function _resetDeprecationWarnings(): void { + _warned.clear(); +} diff --git a/packages/core/test/util/deprecate.test.ts b/packages/core/test/util/deprecate.test.ts new file mode 100644 index 000000000..55c4ccd8c --- /dev/null +++ b/packages/core/test/util/deprecate.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { deprecate, _resetDeprecationWarnings } from '../../src/util/deprecate.js'; + +describe('deprecate', () => { + afterEach(() => _resetDeprecationWarnings()); + it('warns once per key', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + deprecate('k', 'msg'); + deprecate('k', 'msg'); + deprecate('other', 'msg2'); + expect(spy).toHaveBeenCalledTimes(2); + spy.mockRestore(); + }); +}); From 92a5eadb1be31e0229d11dfd8fe6ece9ca40b8af Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 15:03:29 +0000 Subject: [PATCH 4/5] feat(compat): add deprecated schema-argument overloads to Protocol/Client (C3/C6 in core) - setRequestHandler/setNotificationHandler: accept Zod schema (extracts method literal, warns once) - request(): accept (req, ResultSchema, opts?) deprecated form (schema ignored) - callTool(): accept (params, ResultSchema, opts?) deprecated form - sendNotification(): non-overloaded alias for notification() (test-mock friendly) - Export AnySchema/SchemaOutput/ZodLikeRequestSchema from core/public --- packages/client/src/client/client.ts | 35 ++++++++++- packages/core/src/exports/public/index.ts | 2 + packages/core/src/index.ts | 2 + packages/core/src/shared/protocol.ts | 73 +++++++++++++++++++++-- packages/core/src/util/compatSchema.ts | 49 +++++++++++++++ packages/server/src/server/server.ts | 14 ++++- 6 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/util/compatSchema.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index ba7d42c64..034e05781 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -3,6 +3,7 @@ import type { AnySchema, BaseContext, CallToolRequest, + CallToolResult, ClientCapabilities, ClientContext, ClientNotification, @@ -38,7 +39,8 @@ import type { TaskManagerOptions, Tool, Transport, - UnsubscribeRequest + UnsubscribeRequest, + ZodLikeRequestSchema } from '@modelcontextprotocol/core'; import { assertClientRequestTaskCapability, @@ -49,12 +51,14 @@ import { CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, + deprecate, ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, + isZodLikeSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, ListPromptsResultSchema, @@ -347,11 +351,19 @@ export class Client extends Protocol { paramsSchema: P, handler: (params: SchemaOutput

, ctx: ClientContext) => Result | Promise ): void; + /** @deprecated Pass the method string instead of a Zod schema. Removed in v3. */ + public override setRequestHandler( + requestSchema: T, + handler: (request: ReturnType, ctx: ClientContext) => Result | Promise + ): void; public override setRequestHandler( - method: string, + method: string | ZodLikeRequestSchema, schemaOrHandler: unknown, maybeHandler?: (params: unknown, ctx: ClientContext) => unknown ): void { + if (isZodLikeSchema(method)) { + return super.setRequestHandler(method, schemaOrHandler as never); + } if (maybeHandler !== undefined) { return super.setRequestHandler( method, @@ -890,7 +902,24 @@ export class Client extends Protocol { * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise; + /** @deprecated The result schema is resolved automatically. Removed in v3. */ + async callTool(params: CallToolRequest['params'], resultSchema: unknown, options?: RequestOptions): Promise; + async callTool( + params: CallToolRequest['params'], + optionsOrSchema?: RequestOptions | unknown, + maybeOptions?: RequestOptions + ): Promise { + let options: RequestOptions | undefined; + if (optionsOrSchema && typeof optionsOrSchema === 'object' && 'parse' in optionsOrSchema) { + deprecate( + 'callTool(params, schema)', + 'Client.callTool(params, ResultSchema) is deprecated; the result schema is resolved automatically.' + ); + options = maybeOptions; + } else { + options = 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 4bd702d82..cb6efde1c 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -41,7 +41,9 @@ export { getDisplayName } from '../../shared/metadataUtils.js'; // Role-agnostic Protocol class (concrete; Client/Server extend it). NOT mergeCapabilities. export type { McpSpec, ProtocolSpec, SpecNotifications, SpecRequests } from '../../shared/protocol.js'; export { Protocol } from '../../shared/protocol.js'; +export type { ZodLikeRequestSchema } from '../../util/compatSchema.js'; export { InMemoryTransport } from '../../util/inMemory.js'; +export type { AnySchema, SchemaOutput } from '../../util/schema.js'; // Protocol types export type { BaseContext, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 589315c13..81afbdcea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,3 +50,5 @@ export * from './validators/fromJsonSchema.js'; // Core types only - implementations are exported via separate entry points export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; export { deprecate } from './util/deprecate.js'; +export type { ZodLikeRequestSchema } from './util/compatSchema.js'; +export { extractMethodLiteral, isZodLikeSchema } from './util/compatSchema.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d7acb9f32..ed14dca05 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -44,6 +44,9 @@ import { ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; +import type { ZodLikeRequestSchema } from '../util/compatSchema.js'; +import { extractMethodLiteral, isZodLikeSchema } from '../util/compatSchema.js'; +import { deprecate } from '../util/deprecate.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; @@ -849,23 +852,48 @@ export class Protocol>; + /** @deprecated The result schema is resolved automatically from the method name. Removed in v3. */ + request( + request: { method: M; params?: Record }, + resultSchema: ZodLikeRequestSchema | AnySchema, + options?: RequestOptions + ): Promise; request( requestOrMethod: { method: RequestMethod; params?: Record } | string, - optionsOrParams?: RequestOptions | Record, - resultSchema?: AnySchema, + optionsOrParams?: RequestOptions | Record | AnySchema | ZodLikeRequestSchema, + resultSchema?: AnySchema | RequestOptions, options?: RequestOptions ): Promise { if (typeof requestOrMethod === 'string') { return this._requestWithSchema( { method: requestOrMethod, params: optionsOrParams as Record | undefined } as Request, - resultSchema!, + resultSchema as AnySchema, options ); } + // v1-compat: request(reqObj, ResultSchema, opts?) — schema arg is ignored. + if (isZodLikeSchema(optionsOrParams) || (optionsOrParams && '~standard' in (optionsOrParams as object))) { + deprecate( + 'request(req, schema)', + 'Protocol.request(request, ResultSchema) is deprecated; the result schema is resolved automatically from the method name.' + ); + const schema = getResultSchema(requestOrMethod.method); + return this._requestWithSchema(requestOrMethod as Request, schema, resultSchema as RequestOptions | undefined); + } const schema = getResultSchema(requestOrMethod.method); return this._requestWithSchema(requestOrMethod as Request, schema, optionsOrParams as RequestOptions | undefined); } + /** + * Non-overloaded alias for {@linkcode notification} that always takes a + * `Notification` object. Exists so that test code can replace + * `client.notification` with a single-signature mock without TypeScript + * complaining about overload-intersection assignability. + */ + sendNotification(notification: Notification, options?: NotificationOptions): Promise { + return this.notification(notification, options); + } + /** * Sends a request and waits for a response, using the provided schema for validation. * @@ -1134,11 +1162,30 @@ export class Protocol, ctx: ContextT) => Result | Promise ): void; + /** @deprecated Pass the method string instead of a Zod schema. Removed in v3. */ + setRequestHandler( + requestSchema: T, + handler: (request: ReturnType, ctx: ContextT) => Result | Promise + ): void; setRequestHandler( - method: string, + method: string | ZodLikeRequestSchema, schemaOrHandler: AnySchema | ((request: Request, ctx: ContextT) => Result | Promise), maybeHandler?: (params: unknown, ctx: ContextT) => unknown ): void { + if (isZodLikeSchema(method)) { + deprecate( + 'setRequestHandler(schema)', + "setRequestHandler(ZodSchema, handler) is deprecated. Pass the method string, e.g. setRequestHandler('tools/call', handler)." + ); + const requestSchema = method; + const methodStr = extractMethodLiteral(requestSchema); + const handler = schemaOrHandler as (request: unknown, ctx: ContextT) => Result | Promise; + this.assertRequestHandlerCapability(methodStr); + this._requestHandlers.set(methodStr, (request, ctx) => + Promise.resolve(handler(requestSchema.parse(request), ctx)) + ); + return; + } this.assertRequestHandlerCapability(method); if (maybeHandler === undefined) { @@ -1202,11 +1249,27 @@ export class Protocol) => void | Promise ): void; + /** @deprecated Pass the method string instead of a Zod schema. Removed in v3. */ + setNotificationHandler( + notificationSchema: T, + handler: (notification: ReturnType) => void | Promise + ): void; setNotificationHandler( - method: string, + method: string | ZodLikeRequestSchema, schemaOrHandler: AnySchema | ((notification: Notification) => void | Promise), maybeHandler?: (params: unknown) => void | Promise ): void { + if (isZodLikeSchema(method)) { + deprecate( + 'setNotificationHandler(schema)', + "setNotificationHandler(ZodSchema, handler) is deprecated. Pass the method string, e.g. setNotificationHandler('notifications/initialized', handler)." + ); + const notificationSchema = method; + const methodStr = extractMethodLiteral(notificationSchema); + const handler = schemaOrHandler as (notification: unknown) => void | Promise; + this._notificationHandlers.set(methodStr, n => Promise.resolve(handler(notificationSchema.parse(n)))); + return; + } if (maybeHandler === undefined) { const handler = schemaOrHandler as (notification: Notification) => void | Promise; const schema = getNotificationSchema(method as NotificationMethod); diff --git a/packages/core/src/util/compatSchema.ts b/packages/core/src/util/compatSchema.ts new file mode 100644 index 000000000..b2ad67aac --- /dev/null +++ b/packages/core/src/util/compatSchema.ts @@ -0,0 +1,49 @@ +/** + * v1-compat helpers for the deprecated schema-argument forms of + * `setRequestHandler` / `setNotificationHandler` / `request`. + * + * v1 accepted a Zod object whose `.shape.method` is `z.literal('')`. + * v2 takes the method string directly. These helpers detect the v1 form and + * extract the literal so the deprecated overloads can forward to the v2 path. + * + * @internal + */ + +/** + * Minimal structural type for a Zod object schema. The `method` literal is + * checked at runtime by {@link 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( + 'v1-compat: schema passed to setRequestHandler/setNotificationHandler is missing a string `method` literal' + ); + } + return value; +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index b02e970fe..5bab34389 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -36,7 +36,8 @@ import type { ServerResult, TaskManagerOptions, ToolResultContent, - ToolUseContent + ToolUseContent, + ZodLikeRequestSchema } from '@modelcontextprotocol/core'; import { assertClientRequestTaskCapability, @@ -48,6 +49,7 @@ import { CreateTaskResultSchema, ElicitResultSchema, EmptyResultSchema, + isZodLikeSchema, extractTaskManagerOptions, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, @@ -236,11 +238,19 @@ export class Server extends Protocol { paramsSchema: P, handler: (params: SchemaOutput

, ctx: ServerContext) => Result | Promise ): void; + /** @deprecated Pass the method string instead of a Zod schema. Removed in v3. */ + public override setRequestHandler( + requestSchema: T, + handler: (request: ReturnType, ctx: ServerContext) => Result | Promise + ): void; public override setRequestHandler( - method: string, + method: string | ZodLikeRequestSchema, schemaOrHandler: unknown, maybeHandler?: (params: unknown, ctx: ServerContext) => unknown ): void { + if (isZodLikeSchema(method)) { + return super.setRequestHandler(method, schemaOrHandler as never); + } if (maybeHandler !== undefined) { return super.setRequestHandler( method, From 070a9cd3719b804e335aaeecc99faf5a6d978eec Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 16:29:48 +0000 Subject: [PATCH 5/5] refactor(core): keep notification() single-signature; add notifyCustom() for string-form custom methods notification() reverts to a single (Notification, opts?) signature so test code can do client.notification = mockFn without TS rejecting the mock for not matching the intersection of all overloads (the fastmcp regression). The typed string-form sender moves to a new notifyCustom(method, params, {paramsSchema?}). Removes the sendNotification() workaround alias. --- docs/migration-SKILL.md | 6 +- examples/client/src/customMethodExample.ts | 4 +- examples/server/src/customMethodExample.ts | 4 +- .../server/src/customMethodExtAppsExample.ts | 6 +- packages/core/src/index.ts | 4 +- packages/core/src/shared/protocol.ts | 59 ++++++++----------- packages/core/src/util/compatSchema.ts | 11 +--- .../core/test/shared/customMethods.test.ts | 29 ++++++++- 8 files changed, 67 insertions(+), 56 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index a9dd8d4c8..1825243da 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -377,16 +377,18 @@ Schema to method string mapping: Request/notification params remain fully typed. Remove unused schema imports after migration. -**Custom (non-standard) methods** — vendor extensions or sub-protocols whose method strings are not in the MCP spec — use the three-arg overloads of `setRequestHandler`/`setNotificationHandler`/`request`/`notification`, which accept any method string with a caller-supplied params/result schema. `Protocol` is now concrete and exported, so MCP-dialect protocols can subclass it directly: +**Custom (non-standard) methods** — vendor extensions or sub-protocols whose method strings are not in the MCP spec — use the three-arg overloads of `setRequestHandler`/`setNotificationHandler`/`request`, plus `notifyCustom` for sending custom notifications. `Protocol` is now concrete and exported, so MCP-dialect protocols can subclass it directly: | v1 | v2 | | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | | `setRequestHandler(CustomReqSchema, (req, extra) => ...)` | `setRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` | | `setNotificationHandler(CustomNotifSchema, n => ...)` | `setNotificationHandler('vendor/method', ParamsSchema, params => ...)` | | `this.request({ method: 'vendor/x', params }, ResultSchema)` | `this.request('vendor/x', params, ResultSchema)` | -| `this.notification({ method: 'vendor/x', params })` | `this.notification('vendor/x', params)` | +| `this.notification({ method: 'vendor/x', params })` | `this.notifyCustom('vendor/x', params)` (or keep object form via `notification({...})`) | | `class X extends Protocol` | `class X extends Client` (or `Server`), or compose a `Client` instance | +`notification()` keeps a single object-form signature so tests can replace it with a mock without TS overload-intersection errors; the typed string-form lives on `notifyCustom()`. + The v1 schema's `.shape.params` becomes the `ParamsSchema` argument; the `method: z.literal('...')` value becomes the string argument. diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts index c434ff025..6cccec313 100644 --- a/examples/client/src/customMethodExample.ts +++ b/examples/client/src/customMethodExample.ts @@ -17,8 +17,8 @@ const ProgressParams = z.object({ stage: z.string(), pct: z.number() }); async function main() { const peer = new Protocol(); peer.setRequestHandler('acme/search', SearchParams, async p => { - await peer.notification('acme/searchProgress', { stage: 'started', pct: 0 }); - await peer.notification('acme/searchProgress', { stage: 'done', pct: 100 }); + await peer.notifyCustom('acme/searchProgress', { stage: 'started', pct: 0 }, { paramsSchema: ProgressParams }); + await peer.notifyCustom('acme/searchProgress', { stage: 'done', pct: 100 }, { paramsSchema: ProgressParams }); return { hits: [p.query, p.query + '-2'] }; }); // The bare Protocol must respond to MCP initialize so Client.connect() completes. diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts index 1babbfd27..5dbe282ec 100644 --- a/examples/server/src/customMethodExample.ts +++ b/examples/server/src/customMethodExample.ts @@ -37,8 +37,8 @@ async function main() { const r = await peer.request('acme/search', { query: 'widgets' }, SearchResult); console.log('[peer] hits=' + JSON.stringify(r.hits)); - await peer.notification('acme/tick', { n: 1 }); - await peer.notification('acme/tick', { n: 2 }); + await peer.notifyCustom('acme/tick', { n: 1 }); + await peer.notifyCustom('acme/tick', { n: 2 }); await peer.close(); await server.close(); diff --git a/examples/server/src/customMethodExtAppsExample.ts b/examples/server/src/customMethodExtAppsExample.ts index 79502f29b..7cfbfd6ce 100644 --- a/examples/server/src/customMethodExtAppsExample.ts +++ b/examples/server/src/customMethodExtAppsExample.ts @@ -71,11 +71,11 @@ class App extends Protocol { } sendLog(level: string, data: unknown) { - return this.notification('notifications/message', { level, data }); + return this.notification({ method: 'notifications/message', params: { level, data } }); } notifySize(width: number, height: number) { - return this.notification('ui/size-changed', { width, height }); + return this.notifyCustom('ui/size-changed', { width, height }); } } @@ -103,7 +103,7 @@ class Host extends Protocol { } notifyToolResult(toolName: string, text: string) { - return this.notification('ui/tool-result', { toolName, text }); + return this.notifyCustom('ui/tool-result', { toolName, text }); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 81afbdcea..a7bc281a5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,7 +48,7 @@ export * from './validators/fromJsonSchema.js'; */ // Core types only - implementations are exported via separate entry points -export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; -export { deprecate } from './util/deprecate.js'; export type { ZodLikeRequestSchema } from './util/compatSchema.js'; export { extractMethodLiteral, isZodLikeSchema } from './util/compatSchema.js'; +export { deprecate } from './util/deprecate.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 ed14dca05..86a86bb07 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -884,16 +884,6 @@ export class Protocol { - return this.notification(notification, options); - } - /** * Sends a request and waits for a response, using the provided schema for validation. * @@ -1053,29 +1043,34 @@ export class Protocol>( - method: K, - params: _Notifications[K]['params'], - options?: NotificationOptions - ): Promise; - notification(notification: Notification, options?: NotificationOptions): Promise; - notification(method: string, params?: Record, options?: NotificationOptions): Promise; - notification( - notificationOrMethod: Notification | string, - optionsOrParams?: NotificationOptions | Record, - maybeOptions?: NotificationOptions + notification(notification: Notification, options?: NotificationOptions): Promise { + return this._sendNotification(notification, options); + } + + /** + * Emits a notification for a custom (non-spec) method by method string. Capability checks are + * skipped. If `paramsSchema` is provided, `params` are validated before sending. + * + * This is the typed string-form sender; {@linkcode notification} keeps a single object-form + * signature so tests can replace it with a mock without TS overload-intersection errors. + */ + async notifyCustom

( + method: string, + params?: Record, + options?: NotificationOptions & { paramsSchema?: P } ): Promise { - if (typeof notificationOrMethod === 'string') { - return this._sendNotification( - { method: notificationOrMethod, params: optionsOrParams as Record | undefined }, - maybeOptions - ); + if (options?.paramsSchema) { + const parsed = await parseSchema(options.paramsSchema, params ?? {}); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`); + } + return this._sendNotification({ method, params: parsed.data as Record | undefined }, options); } - return this._sendNotification(notificationOrMethod, optionsOrParams as NotificationOptions | undefined); + return this._sendNotification({ method, params }, options); } private async _sendNotification(notification: Notification, options?: NotificationOptions): Promise { @@ -1181,9 +1176,7 @@ export class Protocol Result | Promise; this.assertRequestHandlerCapability(methodStr); - this._requestHandlers.set(methodStr, (request, ctx) => - Promise.resolve(handler(requestSchema.parse(request), ctx)) - ); + this._requestHandlers.set(methodStr, (request, ctx) => Promise.resolve(handler(requestSchema.parse(request), ctx))); return; } this.assertRequestHandlerCapability(method); diff --git a/packages/core/src/util/compatSchema.ts b/packages/core/src/util/compatSchema.ts index b2ad67aac..a2ca412a9 100644 --- a/packages/core/src/util/compatSchema.ts +++ b/packages/core/src/util/compatSchema.ts @@ -23,12 +23,7 @@ export interface ZodLikeRequestSchema { /** 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' - ); + return typeof arg === 'object' && arg !== null && 'shape' in arg && typeof (arg as { parse?: unknown }).parse === 'function'; } /** @@ -41,9 +36,7 @@ export function extractMethodLiteral(schema: ZodLikeRequestSchema): string { | undefined; const value = methodField?.value ?? methodField?.def?.values?.[0]; if (typeof value !== 'string') { - throw new TypeError( - 'v1-compat: schema passed to setRequestHandler/setNotificationHandler is missing a string `method` literal' - ); + throw new TypeError('v1-compat: 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 index ddddeca68..02c69aff1 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -95,8 +95,8 @@ describe('setNotificationHandler — three-arg paramsSchema form', () => { b.setNotificationHandler('acme/tick', z.object({ n: z.number() }), p => { received.push(p); }); - await a.notification('acme/tick', { n: 1 }); - await a.notification('acme/tick', { n: 2 }); + await a.notifyCustom('acme/tick', { n: 1 }); + await a.notifyCustom('acme/tick', { n: 2 }); await new Promise(r => setTimeout(r, 0)); expect(received).toEqual([{ n: 1 }, { n: 2 }]); }); @@ -180,7 +180,7 @@ describe('ProtocolSpec typing', () => { host.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), p => { size = p; }); - await app.notification('ui/size-changed', { width: 800, height: 600 }); + await app.notifyCustom('ui/size-changed', { width: 800, height: 600 }); await new Promise(r => setTimeout(r, 0)); expect(size).toEqual({ width: 800, height: 600 }); }); @@ -240,3 +240,26 @@ describe('non-Zod StandardSchemaV1', () => { }); }); }); + +describe('notification() mock-assignability', () => { + it('single-signature notification() is assignable from a simple mock (compile-time check)', () => { + const p = new Protocol(); + // The point of this test is that the next line typechecks. If notification() were + // overloaded, TS would require the mock to match the intersection of all overloads + // and this assignment would fail (the fastmcp regression). + p.notification = async (_n: { method: string }) => {}; + expect(typeof p.notification).toBe('function'); + }); + + it('notifyCustom validates params when paramsSchema is provided', async () => { + const { a, b } = await makePair(); + const received: unknown[] = []; + b.setNotificationHandler('acme/v', z.object({ n: z.number() }), p => { + received.push(p); + }); + await a.notifyCustom('acme/v', { n: 1 }, { paramsSchema: z.object({ n: z.number() }) }); + await expect(a.notifyCustom('acme/v', { n: 'bad' }, { paramsSchema: z.object({ n: z.number() }) })).rejects.toThrow(); + await new Promise(r => setTimeout(r, 0)); + expect(received).toEqual([{ n: 1 }]); + }); +});