From af452552cb5e55b17dfe720a13ea3539bcdeae27 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 20 May 2026 17:02:17 +0000 Subject: [PATCH 1/5] refactor(server): rename Server -> LegacyServer (verbatim) git mv server.ts legacyServer.ts + class rename. Zero body changes. Importers updated to `LegacyServer as Server` so behavior is identical and the rename shows as a true file rename in history. --- packages/server/src/index.ts | 4 ++-- packages/server/src/server/handleHttp.ts | 2 +- packages/server/src/server/{server.ts => legacyServer.ts} | 4 ++-- packages/server/src/server/mcp.ts | 4 ++-- packages/server/test/server/dispatchStateless.test.ts | 2 +- packages/server/test/server/server.test.ts | 2 +- packages/server/test/server/statelessHttp.test.ts | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) rename packages/server/src/server/{server.ts => legacyServer.ts} (99%) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9949f610a8..9eda476030 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -28,8 +28,8 @@ export type { export { McpServer, ResourceTemplate } from './server/mcp.js'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; -export type { ServerOptions } from './server/server.js'; -export { Server } from './server/server.js'; +export type { ServerOptions } from './server/legacyServer.js'; +export { LegacyServer, LegacyServer as Server } from './server/legacyServer.js'; export type { StatelessHttpRequestOptions } from './server/statelessHttp.js'; export { statelessHttpHandler } from './server/statelessHttp.js'; export type { SubscriptionBackend, SubscriptionEvent } from './server/subscriptions.js'; diff --git a/packages/server/src/server/handleHttp.ts b/packages/server/src/server/handleHttp.ts index a9ab4861db..204ab8b5c8 100644 --- a/packages/server/src/server/handleHttp.ts +++ b/packages/server/src/server/handleHttp.ts @@ -1,7 +1,7 @@ import { ProtocolErrorCode } from '@modelcontextprotocol/core'; import { validateHostHeader } from './middleware/hostHeaderValidation.js'; -import type { Server } from './server.js'; +import type { LegacyServer as Server } from './legacyServer.js'; import type { StatelessHttpRequestOptions } from './statelessHttp.js'; import { jsonError, statelessHttpHandler } from './statelessHttp.js'; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/legacyServer.ts similarity index 99% rename from packages/server/src/server/server.ts rename to packages/server/src/server/legacyServer.ts index 7d7451c110..7ffbd616df 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/legacyServer.ts @@ -261,7 +261,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 LegacyServer extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; private _capabilities: ServerCapabilities; @@ -289,7 +289,7 @@ export class Server extends Protocol { this.subscriptions = options?.subscriptions ?? new InMemorySubscriptions(); this.dispatcher.use(inputRequiredMiddleware); - this.dispatcher.use(Server._callToolResultMiddleware); + this.dispatcher.use(LegacyServer._callToolResultMiddleware); this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setRequestHandler('server/discover', async (): Promise => this._ondiscover()); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 67253c46fc..0e0eba14a4 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -39,8 +39,8 @@ import { import type * as z from 'zod/v4'; import { getCompleter, isCompletable } from './completable.js'; -import type { ServerOptions } from './server.js'; -import { Server } from './server.js'; +import type { ServerOptions } from './legacyServer.js'; +import { LegacyServer as Server } from './legacyServer.js'; /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. diff --git a/packages/server/test/server/dispatchStateless.test.ts b/packages/server/test/server/dispatchStateless.test.ts index 6dadf25af7..96e966bae4 100644 --- a/packages/server/test/server/dispatchStateless.test.ts +++ b/packages/server/test/server/dispatchStateless.test.ts @@ -18,7 +18,7 @@ import { } from '@modelcontextprotocol/core'; import { describe, expect, it } from 'vitest'; -import { Server } from '../../src/server/server.js'; +import { LegacyServer as Server } from '../../src/server/legacyServer.js'; const STATELESS_VERSION = LATEST_PROTOCOL_VERSION; diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index fdb8214c56..9149069728 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,6 +1,6 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; -import { Server } from '../../src/server/server.js'; +import { LegacyServer as Server } from '../../src/server/legacyServer.js'; describe('Server', () => { describe('_oninitialize', () => { diff --git a/packages/server/test/server/statelessHttp.test.ts b/packages/server/test/server/statelessHttp.test.ts index e3f00401a4..cb0811e886 100644 --- a/packages/server/test/server/statelessHttp.test.ts +++ b/packages/server/test/server/statelessHttp.test.ts @@ -3,7 +3,7 @@ import { DRAFT_PROTOCOL_VERSION, JSONRPC_VERSION, META_KEYS } from '@modelcontex import { describe, expect, it } from 'vitest'; import { handleHttp } from '../../src/server/handleHttp.js'; -import { Server } from '../../src/server/server.js'; +import { LegacyServer as Server } from '../../src/server/legacyServer.js'; import { statelessHttpHandler } from '../../src/server/statelessHttp.js'; const _meta = { From 79da8166ea72da8fbe780a2761e5afbf4f85a4e2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 20 May 2026 17:07:32 +0000 Subject: [PATCH 2/5] refactor(server)!: NEW Server class (Protocol-free) composes LegacyServer `Server` no longer extends `Protocol`. It owns the 2026 stateless dispatch path (subscriptions, statelessHandlers, _dispatchStateless, _buildDispatchServerContext, _ondiscover) and the dual-mode surface (connect/close/transport/onclose/onerror, _fanoutNotify, send*ListChanged), lifted verbatim from the sectioned `LegacyServer` with field reads re-pointed at `this.config.*` / `this._legacy.*`. `LegacyServer` keeps the session-dependent block. Both share one handler registry via `_legacy._dispatch`/`_legacy.setRequestHandler`. `server.legacy` is the explicit escape hatch. LegacyServer body changes are limited to: - `_assertSession()` guard inserted at top of createMessage / elicitInput / listRoots / sendLoggingMessage / ping - ctor: dropped `subscriptions` init + `server/discover` registration (lifted to NEW Server) Adds `SdkErrorCode.SessionRequired`. --- examples/server/src/elicitationUrlExample.ts | 8 +- packages/core/src/errors/sdkErrors.ts | 2 + .../node/test/streamableHttp.test.ts | 18 +- packages/server/src/index.ts | 5 +- packages/server/src/server/handleHttp.ts | 6 +- packages/server/src/server/legacyServer.ts | 276 ++---------- packages/server/src/server/mcp.ts | 6 +- packages/server/src/server/server.ts | 407 ++++++++++++++++++ .../test/server/dispatchStateless.test.ts | 2 +- packages/server/test/server/server.test.ts | 2 +- .../server/test/server/statelessHttp.test.ts | 2 +- 11 files changed, 474 insertions(+), 260 deletions(-) create mode 100644 packages/server/src/server/server.ts diff --git a/examples/server/src/elicitationUrlExample.ts b/examples/server/src/elicitationUrlExample.ts index e56fe1b6eb..3bf3e06ae4 100644 --- a/examples/server/src/elicitationUrlExample.ts +++ b/examples/server/src/elicitationUrlExample.ts @@ -53,7 +53,7 @@ const getServer = () => { // Create and track the elicitation const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) + mcpServer.server.legacy.createElicitationCompletionNotifier(elicitationId) ); throw new UrlElicitationRequiredError([ { @@ -89,7 +89,7 @@ const getServer = () => { // Create and track the elicitation const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) + mcpServer.server.legacy.createElicitationCompletionNotifier(elicitationId) ); // Simulate OAuth callback and token exchange after 5 seconds @@ -583,8 +583,8 @@ const mcpPostHandler = async (req: Request, res: Response) => { // there is no session to send to. Prefer ctx.mcpReq.elicitInput // inside a tool/prompt handler when possible. sessionsNeedingElicitation[sessionId] = { - elicitationSender: params => server.server.elicitInput(params), - createCompletionNotifier: elicitationId => server.server.createElicitationCompletionNotifier(elicitationId) + elicitationSender: params => server.server.legacy.elicitInput(params), + createCompletionNotifier: elicitationId => server.server.legacy.createElicitationCompletionNotifier(elicitationId) }; } }); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index 8d5e34c14e..f2fa8f6aa8 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -10,6 +10,8 @@ export enum SdkErrorCode { // State errors /** Transport is not connected */ NotConnected = 'NOT_CONNECTED', + /** A pre-2026 session-dependent method was called without a connected session-based transport */ + SessionRequired = 'SESSION_REQUIRED', /** Transport is already connected */ AlreadyConnected = 'ALREADY_CONNECTED', /** Protocol is not initialized */ diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index c427aa2eea..a6a53d1462 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -1478,7 +1478,7 @@ describe('Zod v4', () => { expect(sseResponse.status).toBe(200); // Send a server notification through the MCP server - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); // Read the notification from the SSE stream const reader = sseResponse.body?.getReader(); @@ -1495,7 +1495,7 @@ describe('Zod v4', () => { const firstEventId = idMatch![1]!; // Send a second notification - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); // Close the first SSE stream to simulate a disconnect await reader!.cancel(); @@ -1538,7 +1538,7 @@ describe('Zod v4', () => { const reader = sseResponse.body?.getReader(); // Send a notification to get an event ID - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Initial notification' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Initial notification' }); // Read the notification from the SSE stream const { value } = await reader!.read(); @@ -1553,9 +1553,9 @@ describe('Zod v4', () => { await reader!.cancel(); // Send MULTIPLE notifications while the client is disconnected - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 1' }); - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 2' }); - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 3' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Missed notification 1' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Missed notification 2' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Missed notification 3' }); // Reconnect with the Last-Event-ID to get all missed messages const reconnectResponse = await fetch(baseUrl, { @@ -2223,7 +2223,7 @@ describe('Zod v4', () => { const getReader = sseResponse.body?.getReader(); // Send a notification to confirm GET stream is established - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Stream established' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Stream established' }); // Read the notification to confirm stream is working const { value } = await getReader!.read(); @@ -2304,7 +2304,7 @@ describe('Zod v4', () => { const getReader = sseResponse.body?.getReader(); // Send a notification to get an event ID - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Initial message' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Initial message' }); // Read the notification to get the event ID const { value } = await getReader!.read(); @@ -2354,7 +2354,7 @@ describe('Zod v4', () => { await new Promise(resolve => setTimeout(resolve, 5)); // Send a notification while client is disconnected - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed while disconnected' }); + await mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Missed while disconnected' }); // Client reconnects with Last-Event-ID const reconnectResponse = await fetch(baseUrl, { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9eda476030..6b6c1e956b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,6 +10,7 @@ export type { CompletableSchema, CompleteCallback } from './server/completable.j export { completable, isCompletable } from './server/completable.js'; export type { HandleHttpOptions } from './server/handleHttp.js'; export { handleHttp } from './server/handleHttp.js'; +export { LegacyServer } from './server/legacyServer.js'; export type { AnyToolHandler, BaseToolCallback, @@ -28,8 +29,8 @@ export type { export { McpServer, ResourceTemplate } from './server/mcp.js'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; -export type { ServerOptions } from './server/legacyServer.js'; -export { LegacyServer, LegacyServer as Server } from './server/legacyServer.js'; +export type { ServerOptions } from './server/server.js'; +export { Server } from './server/server.js'; export type { StatelessHttpRequestOptions } from './server/statelessHttp.js'; export { statelessHttpHandler } from './server/statelessHttp.js'; export type { SubscriptionBackend, SubscriptionEvent } from './server/subscriptions.js'; diff --git a/packages/server/src/server/handleHttp.ts b/packages/server/src/server/handleHttp.ts index 204ab8b5c8..60c4451fd5 100644 --- a/packages/server/src/server/handleHttp.ts +++ b/packages/server/src/server/handleHttp.ts @@ -1,7 +1,7 @@ import { ProtocolErrorCode } from '@modelcontextprotocol/core'; import { validateHostHeader } from './middleware/hostHeaderValidation.js'; -import type { LegacyServer as Server } from './legacyServer.js'; +import type { Server } from './server.js'; import type { StatelessHttpRequestOptions } from './statelessHttp.js'; import { jsonError, statelessHttpHandler } from './statelessHttp.js'; @@ -24,8 +24,8 @@ export interface HandleHttpOptions { /** * 2026-06 Fetch-API HTTP entry. Returns a `(Request) → Promise` - * handler that dispatches via the server's {@linkcode Server.dispatcher} and - * {@linkcode Server.subscriptions}. No `Transport` instance, no `connect()`, + * handler that dispatches via the server's {@linkcode Server.statelessHandlers} + * and {@linkcode Server.subscriptions}. No `Transport` instance, no `connect()`, * no per-connection state; one server instance can be shared across requests. * * Pre-2026 clients are NOT served by this entry (use the diff --git a/packages/server/src/server/legacyServer.ts b/packages/server/src/server/legacyServer.ts index 7ffbd616df..b5163501ec 100644 --- a/packages/server/src/server/legacyServer.ts +++ b/packages/server/src/server/legacyServer.ts @@ -1,45 +1,36 @@ import type { BaseContext, ClientCapabilities, - ClientMeta, CreateMessageRequest, CreateMessageRequestParamsBase, CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, - DiscoverResult, - DispatchContext, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, Implementation, InitializeRequest, InitializeResult, - InputRequest, JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResponse, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, - ListRootsResult, LoggingLevel, LoggingMessageNotification, MessageExtraInfo, Middleware, - Notification, NotificationMethod, NotificationOptions, ProtocolOptions, RequestMethod, RequestOptions, - ResourceUpdatedNotification, ServerCapabilities, ServerContext, - StatelessHandlers, ToolResultContent, - ToolUseContent, - Transport + ToolUseContent } from '@modelcontextprotocol/core'; import { CallToolRequestSchema, @@ -48,38 +39,30 @@ import { CreateMessageResultWithToolsSchema, ElicitResultSchema, EmptyResultSchema, - errorResponse, - InputRequiredError, isInputRequiredError, isStatelessProtocolVersion, - JSONRPC_VERSION, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, - META_KEYS, - parseClientMeta, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode, - STATELESS_REMOVED_METHODS, - SubscriptionsListenRequestSchema + SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; import type { SubscriptionBackend } from './subscriptions.js'; -import { InMemorySubscriptions } from './subscriptions.js'; const LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); /** * Returns true when `level` is at least as severe as `threshold`. - * Lower index in {@linkcode LoggingLevelSchema}.options is more verbose. + * Lower index in `LoggingLevelSchema.options` is more verbose. */ -function severityAtLeast(level: LoggingLevel, threshold: LoggingLevel): boolean { +export function severityAtLeast(level: LoggingLevel, threshold: LoggingLevel): boolean { return (LOG_LEVEL_SEVERITY.get(level) ?? 0) >= (LOG_LEVEL_SEVERITY.get(threshold) ?? 0); } @@ -89,7 +72,7 @@ function severityAtLeast(level: LoggingLevel, threshold: LoggingLevel): boolean * stateless `ctx.mcpReq.requestSampling` path so the handler-facing call has * the same semantics under both protocols. */ -function assertSamplingCapability(params: CreateMessageRequest['params'], clientCapabilities: ClientCapabilities | undefined): void { +export function assertSamplingCapability(params: CreateMessageRequest['params'], clientCapabilities: ClientCapabilities | undefined): void { if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); } @@ -100,7 +83,7 @@ function assertSamplingCapability(params: CreateMessageRequest['params'], client * appear even without `tools`/`toolChoice` in the current request when a prior * sampling request returned `tool_use` and this is a follow-up with results. */ -function assertSamplingMessagePairing(params: CreateMessageRequest['params']): void { +export function assertSamplingMessagePairing(params: CreateMessageRequest['params']): void { if (params.messages.length === 0) return; const lastMessage = params.messages.at(-1)!; const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; @@ -145,7 +128,7 @@ function assertSamplingMessagePairing(params: CreateMessageRequest['params']): v * client did not declare. Shared by the legacy `elicitInput` path and the * stateless `ctx.mcpReq.elicitInput` path. */ -function assertElicitCapability( +export function assertElicitCapability( params: ElicitRequestFormParams | ElicitRequestURLParams, clientCapabilities: ClientCapabilities | undefined ): void { @@ -162,7 +145,7 @@ function assertElicitCapability( * Validates a form-mode elicitation result's `content` against the request's * `requestedSchema`. Throws on schema or validation failure. */ -function validateElicitFormContent( +export function validateElicitFormContent( validator: jsonSchemaValidator, params: ElicitRequestFormParams | ElicitRequestURLParams, result: ElicitResult @@ -190,7 +173,7 @@ function validateElicitFormContent( } /** - * Dispatcher middleware that catches {@linkcode InputRequiredError}, gates + * Dispatcher middleware that catches `InputRequiredError`, gates * against `ctx.clientCapabilities`, and translates to an `InputRequiredResult`. * * Runs on both dispatch paths but only the stateless ctx-builder installs the @@ -198,11 +181,11 @@ function validateElicitFormContent( * legacy `ctx.mcpReq.elicitInput`/`requestSampling` send real requests, so the * catch never fires on the legacy path. * - * MRTR via {@linkcode InputRequiredError} works for handlers registered via + * MRTR via `InputRequiredError` works for handlers registered via * `setRequestHandler`; `fallbackRequestHandler` is not wrapped by middleware * (matches pre-existing behavior). */ -const inputRequiredMiddleware: Middleware = async (_request, ctx, next) => { +export const inputRequiredMiddleware: Middleware = async (_request, ctx, next) => { try { return await next(); } catch (error) { @@ -248,7 +231,7 @@ export type ServerOptions = ProtocolOptions & { jsonSchemaValidator?: jsonSchemaValidator; /** - * Backend for `subscriptions/listen`. Defaults to {@linkcode InMemorySubscriptions}. + * Backend for `subscriptions/listen`. Defaults to `InMemorySubscriptions`. * Supply a distributed implementation for horizontally-scaled deployments. */ subscriptions?: SubscriptionBackend; @@ -286,13 +269,11 @@ export class LegacyServer extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - this.subscriptions = options?.subscriptions ?? new InMemorySubscriptions(); this.dispatcher.use(inputRequiredMiddleware); this.dispatcher.use(LegacyServer._callToolResultMiddleware); this.setRequestHandler('initialize', request => this._oninitialize(request)); - this.setRequestHandler('server/discover', async (): Promise => this._ondiscover()); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); if (this._capabilities.logging) { @@ -301,223 +282,41 @@ export class LegacyServer extends Protocol { } // ═══════════════════════════════════════════════════════════════════════ - // 2026 stateless (SEP-2575/2322) + // internal — exposed for the composing Server (server.ts) // ═══════════════════════════════════════════════════════════════════════ /** - * Backend for `subscriptions/listen`. Default in-memory; pass via - * `ServerOptions.subscriptions` for distributed deployments. + * @internal Routes a request through this instance's `Dispatcher`. + * Called by `Server._dispatchStateless` so both the legacy `_onrequest` + * path and the 2026 stateless path share one registry + middleware chain. */ - readonly subscriptions: SubscriptionBackend; - - /** - * Builds the {@linkcode StatelessHandlers} pair this server provides to - * transports (via `setStatelessHandlers`) and to `handleHttp`. - */ - statelessHandlers(): StatelessHandlers { - return { - dispatch: (req, ctx) => this._dispatchStateless(req, ctx), - listen: (req, ctx) => - this.subscriptions.handle({ ...SubscriptionsListenRequestSchema.parse(req), id: req.id }, ctx, this._capabilities) - }; + _dispatch(request: JSONRPCRequest, ctx: ServerContext): Promise { + return this.dispatcher.dispatch(request, ctx); } - /** - * server/discover handler. Returns this server's identity, capabilities, - * and supported protocol versions. - */ - private _ondiscover(): DiscoverResult { - return { - supportedVersions: [...this._supportedProtocolVersions], - capabilities: this._capabilities, - serverInfo: this._serverInfo, - ...(this._instructions === undefined ? {} : { instructions: this._instructions }) - }; + /** @internal Exposed so the composing `Server` can `dispatcher.use(...)`. */ + _useMiddleware(mw: Middleware): void { + this.dispatcher.use(mw); } - /** - * Dispatches one stateless JSON-RPC request and returns its response. - * - * Builds a per-request `ServerContext` from {@linkcode DispatchContext} + - * the request's `_meta` (notify/log via `dctx.notify`; `send` throws; - * `elicitInput`/`requestSampling` are MRTR throw-then-cache), then routes - * through the shared {@linkcode Dispatcher} so the same registry and - * middleware chain as `_onrequest` apply. Pre-2026-only methods are - * rejected before the dispatcher. - */ - private async _dispatchStateless(request: JSONRPCRequest, dctx: DispatchContext): Promise { - const id = request.id; - const meta = dctx.meta ?? parseClientMeta(request.params); - - if (meta.protocolVersion !== undefined && !this._supportedProtocolVersions.includes(meta.protocolVersion)) { - return errorResponse(id, ProtocolErrorCode.InvalidParams, 'Unsupported protocol version', { - supported: [...this._supportedProtocolVersions], - requested: meta.protocolVersion - }); - } - - if (STATELESS_REMOVED_METHODS.has(request.method)) { - return errorResponse(id, ProtocolErrorCode.MethodNotFound, `Method not found: '${request.method}'`); - } - - const ctx = this._buildDispatchServerContext(request, dctx, meta); - - const response = await this.dispatcher.dispatch(request, ctx); - // Default resultType:'complete' on success, but never on server/discover - // (DiscoverResult has no resultType field). - if ( - request.method !== 'server/discover' && - 'result' in response && - (response.result as { resultType?: unknown }).resultType === undefined - ) { - return { ...response, result: { ...response.result, resultType: 'complete' } }; - } - return response; + /** @internal */ + get _validator(): jsonSchemaValidator { + return this._jsonSchemaValidator; } /** - * Builds the `ServerContext` handlers receive under the stateless dispatch - * path. `notify`/`log` go out via `dctx.notify`; `send` throws (no push - * channel under stateless); `elicitInput`/`requestSampling`/`listRoots` - * are MRTR throw-then-cache against `params.inputResponses`. + * Throws when no session-based transport is connected. Guards the + * pre-2026 server-to-client methods so callers get a directed migration + * error instead of `NotConnected`. */ - private _buildDispatchServerContext(request: JSONRPCRequest, dctx: DispatchContext, per: ClientMeta): ServerContext { - let mrtrSeq = 0; - const mrtrOrThrow = (method: string, params: unknown, schema: { parse(v: unknown): R }, after?: (r: R) => void): Promise => { - const key = `${method}#${mrtrSeq++}`; - const cached = per.inputResponses?.[key]; - if (cached !== undefined) { - // Validate the cached value: do not return raw client input. - let parsed: R; - try { - parsed = schema.parse(cached); - } catch (error) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `inputResponses['${key}'] does not match expected schema: ${error instanceof Error ? error.message : String(error)}` - ); - } - after?.(parsed); - return Promise.resolve(parsed); - } - throw new InputRequiredError({ [key]: { method, params } as InputRequest }); - }; - - const notify = (n: Notification): Promise => { - // Stamp `_meta.subscriptionId` (= the JSON-RPC request id, per - // SEP-2575) so notifications correlate to this request on - // pipe-shaped client transports that demultiplex a single inbound - // stream. Handler-supplied `_meta` first, server-stamped key last, - // so a handler cannot override the framing key. - const _meta = { ...n.params?._meta, [META_KEYS.subscriptionId]: String(request.id) }; - const params = { ...n.params, _meta }; - dctx.notify({ jsonrpc: JSONRPC_VERSION, method: n.method, params }); - return Promise.resolve(); - }; - - const sendThrows = (() => { + private _assertSession(via: string): void { + if (!this.transport) { throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - 'Server-to-client requests are not available under the stateless dispatch path; use ctx.mcpReq.elicitInput/requestSampling (MRTR).' + SdkErrorCode.SessionRequired, + `${this.constructor.name}.${via} requires a connected pre-2026 session; use ${via} inside a handler instead. ` + + 'See https://modelcontextprotocol.io/docs/migration#2026-06' ); - }) as ServerContext['mcpReq']['send']; - - return { - sessionId: undefined, - clientCapabilities: per.clientCapabilities, - mcpReq: { - id: request.id, - method: request.method, - _meta: request.params?._meta, - signal: dctx.signal ?? new AbortController().signal, - send: sendThrows, - notify, - log: async (level, data, logger) => { - // Spec: server MUST NOT emit notifications/message for - // requests that did not include _meta.logLevel. - if (per.logLevel === undefined || !severityAtLeast(level, per.logLevel)) return; - await notify({ method: 'notifications/message', params: { level, data, ...(logger === undefined ? {} : { logger }) } }); - }, - listRoots: params => mrtrOrThrow('roots/list', params ?? {}, ListRootsResultSchema), - elicitInput: params => { - // Sub-capability (form/url) check only when the top-level - // `elicitation` capability is declared. Absent top-level is - // handled by `inputRequiredMiddleware` (-32003) so the wire - // error code matches SEP-2322. - if (per.clientCapabilities?.elicitation) assertElicitCapability(params, per.clientCapabilities); - return mrtrOrThrow('elicitation/create', params, ElicitResultSchema, result => - validateElicitFormContent(this._jsonSchemaValidator, params, result) - ); - }, - // Cast: arrow has the implementation signature (union return); - // narrowing is provided by the overload set on the field type. - requestSampling: ((params: CreateMessageRequest['params']) => { - if (per.clientCapabilities?.sampling) assertSamplingCapability(params, per.clientCapabilities); - assertSamplingMessagePairing(params); - return mrtrOrThrow( - 'sampling/createMessage', - params, - params.tools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema - ); - }) as ServerContext['mcpReq']['requestSampling'] - }, - http: - dctx.authInfo !== undefined || dctx.httpRequest !== undefined - ? { authInfo: dctx.authInfo, req: dctx.httpRequest } - : undefined - }; - } - - // ═══════════════════════════════════════════════════════════════════════ - // dual-mode (SEP-2575/2567) - // ═══════════════════════════════════════════════════════════════════════ - - /** - * Connects this server to a transport. Installs the stateless - * {@linkcode statelessHandlers | dispatch/listen} pair on transports that - * support per-message routing (`setStatelessHandlers` is optional on the - * `Transport` interface), then starts the legacy `Protocol` connect path - * so pre-2026 clients also work over the same transport. - */ - override async connect(transport: Transport): Promise { - // Install stateless handlers before starting the transport so the - // first message cannot arrive before the router is wired. - transport.setStatelessHandlers?.(this.statelessHandlers()); - await super.connect(transport); - } - - /** - * Runs `subscriptions.notify` and the legacy `notification()` concurrently. - * A subscription-backend rejection does not block legacy delivery (and is - * surfaced via `onerror`); a legacy rejection (cap-missing, send fail) is - * rethrown so existing callers see the same errors as before. - */ - private async _fanoutNotify(event: Parameters[0], legacy: () => Promise): Promise { - const [sub, leg] = await Promise.allSettled([this.subscriptions.notify(event), this.transport ? legacy() : Promise.resolve()]); - if (sub.status === 'rejected') { - this.onerror?.(sub.reason instanceof Error ? sub.reason : new Error(String(sub.reason))); } - if (leg.status === 'rejected') throw leg.reason; - } - - async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { - await this._fanoutNotify({ type: 'resourceUpdated', uri: params.uri }, () => - this.notification({ method: 'notifications/resources/updated', params }) - ); - } - - async sendResourceListChanged() { - await this._fanoutNotify({ type: 'resourcesListChanged' }, () => - this.notification({ method: 'notifications/resources/list_changed' }) - ); - } - - async sendToolListChanged() { - await this._fanoutNotify({ type: 'toolsListChanged' }, () => this.notification({ method: 'notifications/tools/list_changed' })); - } - - async sendPromptListChanged() { - await this._fanoutNotify({ type: 'promptsListChanged' }, () => this.notification({ method: 'notifications/prompts/list_changed' })); } // ═══════════════════════════════════════════════════════════════════════ @@ -526,9 +325,9 @@ export class LegacyServer extends Protocol { // These top-level methods need a connected pre-2026 client (initialize // handshake). The same capability is available per-request via // ctx.mcpReq.* / ctx.clientCapabilities under both protocols. - // See _buildDispatchServerContext for the 2026 ctx shape. + // See Server._buildDispatchServerContext for the 2026 ctx shape. // - // _oninitialize — 2026 equiv: _ondiscover + // _oninitialize — 2026 equiv: Server._ondiscover // createMessage — 2026 equiv: ctx.mcpReq.requestSampling (MRTR) // elicitInput — 2026 equiv: ctx.mcpReq.elicitInput (MRTR) // listRoots — 2026 equiv: ctx.mcpReq.listRoots (MRTR) @@ -536,7 +335,7 @@ export class LegacyServer extends Protocol { // getClientCapabilities — 2026 equiv: ctx.clientCapabilities (per-request from _meta) // getClientVersion — 2026 equiv: ctx.mcpReq._meta clientInfo // ping — removed by 2026 spec (STATELESS_REMOVED_METHODS) - // buildContext — legacy ctx builder; _buildDispatchServerContext is 2026's + // buildContext — legacy ctx builder; Server._buildDispatchServerContext is 2026's // ═══════════════════════════════════════════════════════════════════════ private _registerLoggingHandler(): void { @@ -820,6 +619,7 @@ export class LegacyServer extends Protocol { * @deprecated `ping` is removed in the 2026-06 protocol. This top-level form requires a pre-2026 connection. */ async ping() { + this._assertSession('ping'); return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); } @@ -855,6 +655,7 @@ export class LegacyServer extends Protocol { params: CreateMessageRequest['params'], options?: RequestOptions ): Promise { + this._assertSession('createMessage (use ctx.mcpReq.requestSampling)'); assertSamplingCapability(params, this._clientCapabilities); assertSamplingMessagePairing(params); @@ -874,6 +675,7 @@ export class LegacyServer extends Protocol { * @deprecated Use `ctx.mcpReq.elicitInput(params)` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + this._assertSession('elicitInput (use ctx.mcpReq.elicitInput)'); assertElicitCapability(params, this._clientCapabilities); const mode = (params.mode ?? 'form') as 'form' | 'url'; @@ -930,6 +732,7 @@ export class LegacyServer extends Protocol { * @deprecated Use `ctx.mcpReq.listRoots()` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { + this._assertSession('listRoots (use ctx.mcpReq.listRoots)'); return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); } @@ -942,6 +745,7 @@ export class LegacyServer extends Protocol { * @deprecated Use `ctx.mcpReq.log(level, data, logger?)` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { + this._assertSession('sendLoggingMessage (use ctx.mcpReq.log)'); if (this._capabilities.logging && !this.isMessageIgnored(params.level, sessionId)) { return this.notification({ method: 'notifications/message', params }); } diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 0e0eba14a4..c7ee969abf 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -39,8 +39,8 @@ import { import type * as z from 'zod/v4'; import { getCompleter, isCompletable } from './completable.js'; -import type { ServerOptions } from './legacyServer.js'; -import { LegacyServer as Server } from './legacyServer.js'; +import type { ServerOptions } from './server.js'; +import { Server } from './server.js'; /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. @@ -929,7 +929,7 @@ export class McpServer { * ``` */ async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { - return this.server.sendLoggingMessage(params, sessionId); + return this.server.legacy.sendLoggingMessage(params, sessionId); } /** * Sends a resource list changed event. Delivered to a connected pre-2026 diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts new file mode 100644 index 0000000000..cd76044f3d --- /dev/null +++ b/packages/server/src/server/server.ts @@ -0,0 +1,407 @@ +import type { + ClientMeta, + CreateMessageRequest, + CreateMessageResult, + CreateMessageResultWithTools, + DiscoverResult, + DispatchContext, + ElicitResult, + Handler, + Implementation, + InferHandlerResult, + InputRequest, + JSONRPCErrorResponse, + JSONRPCRequest, + JSONRPCResponse, + ListRootsResult, + Notification, + RequestMethod, + RequestTypeMap, + ResourceUpdatedNotification, + ResultTypeMap, + ServerCapabilities, + ServerContext, + StandardSchemaV1, + StatelessHandlers, + Transport +} from '@modelcontextprotocol/core'; +import { + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitResultSchema, + errorResponse, + InputRequiredError, + JSONRPC_VERSION, + ListRootsResultSchema, + META_KEYS, + parseClientMeta, + ProtocolError, + ProtocolErrorCode, + SdkError, + SdkErrorCode, + STATELESS_REMOVED_METHODS, + SubscriptionsListenRequestSchema, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +import type { ServerOptions } from './legacyServer.js'; +import { + assertElicitCapability, + assertSamplingCapability, + assertSamplingMessagePairing, + LegacyServer, + severityAtLeast, + validateElicitFormContent +} from './legacyServer.js'; +import type { SubscriptionBackend } from './subscriptions.js'; +import { InMemorySubscriptions } from './subscriptions.js'; + +export type { ServerOptions } from './legacyServer.js'; + +/** + * An MCP server on top of a pluggable transport. + * + * `Server` does not extend `Protocol`. It owns the 2026-06 stateless dispatch + * path ({@linkcode statelessHandlers}, {@linkcode subscriptions}) and composes + * a {@linkcode LegacyServer} (reachable via {@linkcode legacy}) for the + * pre-2026 connection model. Both share one handler registry, so a single + * `setRequestHandler` call serves clients of either protocol era. + * + * @deprecated Use {@linkcode server/mcp.McpServer | McpServer} instead for the high-level API. Only use `Server` for advanced use cases. + */ +export class Server { + /** + * Static identity + capabilities. Read by `_ondiscover`/`_dispatchStateless`; + * `capabilities` is shared by reference with {@linkcode legacy} via + * `registerCapabilities`. + */ + readonly config: { + readonly serverInfo: Implementation; + capabilities: ServerCapabilities; + readonly instructions?: string; + readonly supportedProtocolVersions: readonly string[]; + }; + + /** Composed pre-2026 implementation; owns the `Protocol` connection. */ + private readonly _legacy: LegacyServer; + + /** + * Initializes this server with the given name and version information. + */ + constructor(serverInfo: Implementation, options?: ServerOptions) { + this._legacy = new LegacyServer(serverInfo, options); + this.config = { + serverInfo, + capabilities: this._legacy.getCapabilities(), + ...(options?.instructions === undefined ? {} : { instructions: options.instructions }), + supportedProtocolVersions: options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS + }; + this.subscriptions = options?.subscriptions ?? new InMemorySubscriptions(); + + this._legacy.setRequestHandler('server/discover', async (): Promise => this._ondiscover()); + } + + /** + * Escape hatch to the composed pre-2026 connection-model server (extends + * `Protocol`). Use for `createMessage`/`elicitInput`/`listRoots`/`ping`/ + * `sendLoggingMessage`/`oninitialized` against a connected pre-2026 client. + */ + get legacy(): LegacyServer { + return this._legacy; + } + + // ═══════════════════════════════════════════════════════════════════════ + // 2026 stateless (SEP-2575/2322) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Backend for `subscriptions/listen`. Default in-memory; pass via + * `ServerOptions.subscriptions` for distributed deployments. + */ + readonly subscriptions: SubscriptionBackend; + + /** + * Builds the {@linkcode StatelessHandlers} pair this server provides to + * transports (via `setStatelessHandlers`) and to `handleHttp`. + */ + statelessHandlers(): StatelessHandlers { + return { + dispatch: (req, ctx) => this._dispatchStateless(req, ctx), + listen: (req, ctx) => + this.subscriptions.handle({ ...SubscriptionsListenRequestSchema.parse(req), id: req.id }, ctx, this.config.capabilities) + }; + } + + /** + * server/discover handler. Returns this server's identity, capabilities, + * and supported protocol versions. + */ + private _ondiscover(): DiscoverResult { + return { + supportedVersions: [...this.config.supportedProtocolVersions], + capabilities: this.config.capabilities, + serverInfo: this.config.serverInfo, + ...(this.config.instructions === undefined ? {} : { instructions: this.config.instructions }) + }; + } + + /** + * Dispatches one stateless JSON-RPC request and returns its response. + * + * Builds a per-request `ServerContext` from {@linkcode DispatchContext} + + * the request's `_meta` (notify/log via `dctx.notify`; `send` throws; + * `elicitInput`/`requestSampling` are MRTR throw-then-cache), then routes + * through the shared {@linkcode Dispatcher} so the same registry and + * middleware chain as `_onrequest` apply. Pre-2026-only methods are + * rejected before the dispatcher. + */ + private async _dispatchStateless(request: JSONRPCRequest, dctx: DispatchContext): Promise { + const id = request.id; + const meta = dctx.meta ?? parseClientMeta(request.params); + + if (meta.protocolVersion !== undefined && !this.config.supportedProtocolVersions.includes(meta.protocolVersion)) { + return errorResponse(id, ProtocolErrorCode.InvalidParams, 'Unsupported protocol version', { + supported: [...this.config.supportedProtocolVersions], + requested: meta.protocolVersion + }); + } + + if (STATELESS_REMOVED_METHODS.has(request.method)) { + return errorResponse(id, ProtocolErrorCode.MethodNotFound, `Method not found: '${request.method}'`); + } + + const ctx = this._buildDispatchServerContext(request, dctx, meta); + + const response = await this._legacy._dispatch(request, ctx); + // Default resultType:'complete' on success, but never on server/discover + // (DiscoverResult has no resultType field). + if ( + request.method !== 'server/discover' && + 'result' in response && + (response.result as { resultType?: unknown }).resultType === undefined + ) { + return { ...response, result: { ...response.result, resultType: 'complete' } }; + } + return response; + } + + /** + * Builds the `ServerContext` handlers receive under the stateless dispatch + * path. `notify`/`log` go out via `dctx.notify`; `send` throws (no push + * channel under stateless); `elicitInput`/`requestSampling`/`listRoots` + * are MRTR throw-then-cache against `params.inputResponses`. + */ + private _buildDispatchServerContext(request: JSONRPCRequest, dctx: DispatchContext, per: ClientMeta): ServerContext { + let mrtrSeq = 0; + const mrtrOrThrow = (method: string, params: unknown, schema: { parse(v: unknown): R }, after?: (r: R) => void): Promise => { + const key = `${method}#${mrtrSeq++}`; + const cached = per.inputResponses?.[key]; + if (cached !== undefined) { + // Validate the cached value: do not return raw client input. + let parsed: R; + try { + parsed = schema.parse(cached); + } catch (error) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `inputResponses['${key}'] does not match expected schema: ${error instanceof Error ? error.message : String(error)}` + ); + } + after?.(parsed); + return Promise.resolve(parsed); + } + throw new InputRequiredError({ [key]: { method, params } as InputRequest }); + }; + + const notify = (n: Notification): Promise => { + // Stamp `_meta.subscriptionId` (= the JSON-RPC request id, per + // SEP-2575) so notifications correlate to this request on + // pipe-shaped client transports that demultiplex a single inbound + // stream. Handler-supplied `_meta` first, server-stamped key last, + // so a handler cannot override the framing key. + const _meta = { ...n.params?._meta, [META_KEYS.subscriptionId]: String(request.id) }; + const params = { ...n.params, _meta }; + dctx.notify({ jsonrpc: JSONRPC_VERSION, method: n.method, params }); + return Promise.resolve(); + }; + + const sendThrows = (() => { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + 'Server-to-client requests are not available under the stateless dispatch path; use ctx.mcpReq.elicitInput/requestSampling (MRTR).' + ); + }) as ServerContext['mcpReq']['send']; + + return { + sessionId: undefined, + clientCapabilities: per.clientCapabilities, + mcpReq: { + id: request.id, + method: request.method, + _meta: request.params?._meta, + signal: dctx.signal ?? new AbortController().signal, + send: sendThrows, + notify, + log: async (level, data, logger) => { + // Spec: server MUST NOT emit notifications/message for + // requests that did not include _meta.logLevel. + if (per.logLevel === undefined || !severityAtLeast(level, per.logLevel)) return; + await notify({ method: 'notifications/message', params: { level, data, ...(logger === undefined ? {} : { logger }) } }); + }, + listRoots: params => mrtrOrThrow('roots/list', params ?? {}, ListRootsResultSchema), + elicitInput: params => { + // Sub-capability (form/url) check only when the top-level + // `elicitation` capability is declared. Absent top-level is + // handled by `inputRequiredMiddleware` (-32003) so the wire + // error code matches SEP-2322. + if (per.clientCapabilities?.elicitation) assertElicitCapability(params, per.clientCapabilities); + return mrtrOrThrow('elicitation/create', params, ElicitResultSchema, result => + validateElicitFormContent(this._legacy._validator, params, result) + ); + }, + // Cast: arrow has the implementation signature (union return); + // narrowing is provided by the overload set on the field type. + requestSampling: ((params: CreateMessageRequest['params']) => { + if (per.clientCapabilities?.sampling) assertSamplingCapability(params, per.clientCapabilities); + assertSamplingMessagePairing(params); + return mrtrOrThrow( + 'sampling/createMessage', + params, + params.tools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema + ); + }) as ServerContext['mcpReq']['requestSampling'] + }, + http: + dctx.authInfo !== undefined || dctx.httpRequest !== undefined + ? { authInfo: dctx.authInfo, req: dctx.httpRequest } + : undefined + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // dual-mode (SEP-2575/2567) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Connects this server to a transport. Installs the stateless + * {@linkcode statelessHandlers | dispatch/listen} pair on transports that + * support per-message routing (`setStatelessHandlers` is optional on the + * `Transport` interface), then starts the legacy `Protocol` connect path + * so pre-2026 clients also work over the same transport. + */ + async connect(transport: Transport): Promise { + // Install stateless handlers before starting the transport so the + // first message cannot arrive before the router is wired. + transport.setStatelessHandlers?.(this.statelessHandlers()); + await this._legacy.connect(transport); + } + + async close(): Promise { + return this._legacy.close(); + } + + get transport(): Transport | undefined { + return this._legacy.transport; + } + + get onclose(): (() => void) | undefined { + return this._legacy.onclose; + } + set onclose(cb: (() => void) | undefined) { + this._legacy.onclose = cb; + } + + get onerror(): ((error: Error) => void) | undefined { + return this._legacy.onerror; + } + set onerror(cb: ((error: Error) => void) | undefined) { + this._legacy.onerror = cb; + } + + /** + * Runs `subscriptions.notify` and the legacy `notification()` concurrently. + * A subscription-backend rejection does not block legacy delivery (and is + * surfaced via `onerror`); a legacy rejection (cap-missing, send fail) is + * rethrown so existing callers see the same errors as before. + */ + private async _fanoutNotify(event: Parameters[0], legacy: () => Promise): Promise { + const [sub, leg] = await Promise.allSettled([this.subscriptions.notify(event), this.transport ? legacy() : Promise.resolve()]); + if (sub.status === 'rejected') { + this.onerror?.(sub.reason instanceof Error ? sub.reason : new Error(String(sub.reason))); + } + if (leg.status === 'rejected') throw leg.reason; + } + + async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { + await this._fanoutNotify({ type: 'resourceUpdated', uri: params.uri }, () => + this._legacy.notification({ method: 'notifications/resources/updated', params }) + ); + } + + async sendResourceListChanged() { + await this._fanoutNotify({ type: 'resourcesListChanged' }, () => + this._legacy.notification({ method: 'notifications/resources/list_changed' }) + ); + } + + async sendToolListChanged() { + await this._fanoutNotify({ type: 'toolsListChanged' }, () => + this._legacy.notification({ method: 'notifications/tools/list_changed' }) + ); + } + + async sendPromptListChanged() { + await this._fanoutNotify({ type: 'promptsListChanged' }, () => + this._legacy.notification({ method: 'notifications/prompts/list_changed' }) + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // delegating (single registry; legacy owns the Dispatcher) + // ═══════════════════════════════════════════════════════════════════════ + + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise + ): void; + setRequestHandler

( + method: string, + schemas: { params: P; result?: R }, + handler: (params: StandardSchemaV1.InferOutput

, ctx: ServerContext) => InferHandlerResult | Promise> + ): void; + setRequestHandler(method: string, b: unknown, c?: unknown): void { + (this._legacy.setRequestHandler as (m: string, b: unknown, c?: unknown) => void)(method, b, c); + } + + removeRequestHandler(method: RequestMethod | string): void { + this._legacy.removeRequestHandler(method); + } + + assertCanSetRequestHandler(method: RequestMethod | string): void { + this._legacy.assertCanSetRequestHandler(method); + } + + get fallbackRequestHandler(): Handler | undefined { + return this._legacy.fallbackRequestHandler; + } + set fallbackRequestHandler(handler: Handler | undefined) { + this._legacy.fallbackRequestHandler = handler; + } + + /** + * Registers new capabilities. This can only be called before connecting to a transport. + * + * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). + */ + registerCapabilities(capabilities: ServerCapabilities): void { + this._legacy.registerCapabilities(capabilities); + this.config.capabilities = this._legacy.getCapabilities(); + } + + /** + * Returns the current server capabilities. + */ + getCapabilities(): ServerCapabilities { + return this.config.capabilities; + } +} diff --git a/packages/server/test/server/dispatchStateless.test.ts b/packages/server/test/server/dispatchStateless.test.ts index 96e966bae4..6dadf25af7 100644 --- a/packages/server/test/server/dispatchStateless.test.ts +++ b/packages/server/test/server/dispatchStateless.test.ts @@ -18,7 +18,7 @@ import { } from '@modelcontextprotocol/core'; import { describe, expect, it } from 'vitest'; -import { LegacyServer as Server } from '../../src/server/legacyServer.js'; +import { Server } from '../../src/server/server.js'; const STATELESS_VERSION = LATEST_PROTOCOL_VERSION; diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 9149069728..fdb8214c56 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,6 +1,6 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; -import { LegacyServer as Server } from '../../src/server/legacyServer.js'; +import { Server } from '../../src/server/server.js'; describe('Server', () => { describe('_oninitialize', () => { diff --git a/packages/server/test/server/statelessHttp.test.ts b/packages/server/test/server/statelessHttp.test.ts index cb0811e886..e3f00401a4 100644 --- a/packages/server/test/server/statelessHttp.test.ts +++ b/packages/server/test/server/statelessHttp.test.ts @@ -3,7 +3,7 @@ import { DRAFT_PROTOCOL_VERSION, JSONRPC_VERSION, META_KEYS } from '@modelcontex import { describe, expect, it } from 'vitest'; import { handleHttp } from '../../src/server/handleHttp.js'; -import { LegacyServer as Server } from '../../src/server/legacyServer.js'; +import { Server } from '../../src/server/server.js'; import { statelessHttpHandler } from '../../src/server/statelessHttp.js'; const _meta = { From c4bb8bb29235396884fc02900ccffc4fee6ed0ae Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 20 May 2026 17:08:10 +0000 Subject: [PATCH 3/5] refactor(client): rename Client -> LegacyClient (verbatim) git mv client.ts legacyClient.ts + class rename. Zero body changes. Importers updated to `LegacyClient as Client` so behavior is identical and the rename shows as a true file rename in history. --- packages/client/src/client/client.examples.ts | 2 +- packages/client/src/client/{client.ts => legacyClient.ts} | 2 +- packages/client/src/index.ts | 5 ++--- packages/client/test/client/clientSend.test.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) rename packages/client/src/client/{client.ts => legacyClient.ts} (99%) diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index b08694cfbd..32208965b5 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -9,7 +9,7 @@ import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core'; -import { Client } from './client.js'; +import { LegacyClient as Client } from './legacyClient.js'; import { SSEClientTransport } from './sse.js'; import { StdioClientTransport } from './stdio.js'; import { StreamableHTTPClientTransport } from './streamableHttp.js'; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/legacyClient.ts similarity index 99% rename from packages/client/src/client/client.ts rename to packages/client/src/client/legacyClient.ts index 21928fc4c9..84fde20778 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/legacyClient.ts @@ -261,7 +261,7 @@ export type ClientOptions = ProtocolOptions & { * }); * ``` */ -export class Client extends Protocol { +export class LegacyClient extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; private _negotiatedProtocolVersion?: string; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8a08e8fd79..cd325db2e1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,9 +52,8 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { ClientOptions } from './client/client.js'; -export { Client } from './client/client.js'; -export { getSupportedElicitationModes } from './client/client.js'; +export type { ClientOptions } from './client/legacyClient.js'; +export { getSupportedElicitationModes, LegacyClient, LegacyClient as Client } from './client/legacyClient.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess.js'; export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware.js'; diff --git a/packages/client/test/client/clientSend.test.ts b/packages/client/test/client/clientSend.test.ts index 4d56ba3063..f052b72dba 100644 --- a/packages/client/test/client/clientSend.test.ts +++ b/packages/client/test/client/clientSend.test.ts @@ -2,7 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextpro import { DRAFT_PROTOCOL_VERSION, JSONRPC_VERSION, META_KEYS, SdkError } from '@modelcontextprotocol/core'; import { describe, expect, it } from 'vitest'; -import { Client } from '../../src/client/client.js'; +import { LegacyClient as Client } from '../../src/client/legacyClient.js'; /** Minimal transport with a scriptable sendAndReceive. */ function mockTransport(handler: (req: JSONRPCRequest) => AsyncIterable): Transport { From a6e152965ff4bc2c09533fa55943c90663999c02 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 20 May 2026 17:15:48 +0000 Subject: [PATCH 4/5] refactor(client)!: NEW Client class (Protocol-free) composes LegacyClient `Client` no longer extends `Protocol`. It owns the 2026 stateless send path (_isStateless/_buildMeta/_withMeta/_collect/_send/_negotiate/ subscribe/_listChangedLoop) and the typed request methods (callTool/listTools/getPrompt/listPrompts/readResource/listResources/ listResourceTemplates/complete/setLoggingLevel), lifted verbatim from the sectioned `LegacyClient` with field reads re-pointed at `this.config.*` / `this._legacy.*`. `LegacyClient` keeps the session-dependent block. Both share one handler registry via `_legacy._dispatch`/`_legacy.setRequestHandler`; negotiated server state lives on `_legacy` (single source of truth) and is written by both `_negotiate` (via `_setNegotiated`) and `_initialize`. `client.legacy` is the explicit escape hatch. LegacyClient body changes are limited to: - `_assertSession()` guard inserted at top of ping / subscribeResource / unsubscribeResource / sendRootsListChanged - ctor: dropped now-unused `_enforceStrictCapabilities` / `_pendingListChangedConfig` / `_cachedToolOutputValidators` fields (lifted to NEW Client) - `_initialize` / `_setupListChangedHandler` visibility widened to @internal --- docs/client.md | 10 +- examples/client/src/clientGuide.examples.ts | 6 +- examples/client/src/customMethodExample.ts | 2 +- examples/client/src/elicitationUrlExample.ts | 2 +- .../client/src/parallelToolCallsClient.ts | 2 +- examples/client/src/simpleOAuthClient.ts | 2 +- examples/client/src/simpleStreamableHttp.ts | 12 +- examples/client/src/ssePollingClient.ts | 2 +- .../streamableHttpWithSseFallbackClient.ts | 2 +- packages/client/src/client/client.examples.ts | 2 +- packages/client/src/client/client.ts | 995 ++++++++++++++++++ packages/client/src/client/legacyClient.ts | 822 +-------------- packages/client/src/index.ts | 5 +- .../client/test/client/clientSend.test.ts | 2 +- 14 files changed, 1069 insertions(+), 797 deletions(-) create mode 100644 packages/client/src/client/client.ts diff --git a/docs/client.md b/docs/client.md index 6ba844b534..ab705b40f2 100644 --- a/docs/client.md +++ b/docs/client.md @@ -298,10 +298,10 @@ To discover URI templates for dynamic resources, use {@linkcode @modelcontextpro ### Subscribing to resource changes -If the server supports resource subscriptions, use {@linkcode @modelcontextprotocol/client!client/client.Client#subscribeResource | subscribeResource()} to receive notifications when a resource changes, then re-read it: +If the server supports resource subscriptions, use {@linkcode @modelcontextprotocol/client!client/client.Client#subscribe | client.subscribe()} on 2026-06+ connections, or {@linkcode @modelcontextprotocol/client!client/legacyClient.LegacyClient#subscribeResource | client.legacy.subscribeResource()} on pre-2026 connections, to receive notifications when a resource changes, then re-read it: ```ts source="../examples/client/src/clientGuide.examples.ts#subscribeResource_basic" -await client.subscribeResource({ uri: 'config://app' }); +await client.legacy.subscribeResource({ uri: 'config://app' }); client.setNotificationHandler('notifications/resources/updated', async notification => { if (notification.params.uri === 'config://app') { @@ -311,7 +311,7 @@ client.setNotificationHandler('notifications/resources/updated', async notificat }); // Later: stop receiving updates -await client.unsubscribeResource({ uri: 'config://app' }); +await client.legacy.unsubscribeResource({ uri: 'config://app' }); ``` ## Prompts @@ -485,7 +485,7 @@ client.setRequestHandler('roots/list', async () => { }); ``` -When the available roots change, notify the server with {@linkcode @modelcontextprotocol/client!client/client.Client#sendRootsListChanged | client.sendRootsListChanged()}. +When the available roots change, notify the server with {@linkcode @modelcontextprotocol/client!client/legacyClient.LegacyClient#sendRootsListChanged | client.legacy.sendRootsListChanged()}. ## Error handling @@ -578,7 +578,7 @@ When using SSE-based streaming, the server can assign event IDs. Pass `onresumpt ```ts source="../examples/client/src/clientGuide.examples.ts#resumptionToken_basic" let lastToken: string | undefined; -const result = await client.request( +const result = await client.legacy.request( { method: 'tools/call', params: { name: 'long-running-task', arguments: {} } diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 9704ed8a5b..271f5c0670 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -258,7 +258,7 @@ async function readResource_basic(client: Client) { /** Example: Subscribe to resource changes. */ async function subscribeResource_basic(client: Client) { //#region subscribeResource_basic - await client.subscribeResource({ uri: 'config://app' }); + await client.legacy.subscribeResource({ uri: 'config://app' }); client.setNotificationHandler('notifications/resources/updated', async notification => { if (notification.params.uri === 'config://app') { @@ -268,7 +268,7 @@ async function subscribeResource_basic(client: Client) { }); // Later: stop receiving updates - await client.unsubscribeResource({ uri: 'config://app' }); + await client.legacy.unsubscribeResource({ uri: 'config://app' }); //#endregion subscribeResource_basic } @@ -527,7 +527,7 @@ async function resumptionToken_basic(client: Client) { //#region resumptionToken_basic let lastToken: string | undefined; - const result = await client.request( + const result = await client.legacy.request( { method: 'tools/call', params: { name: 'long-running-task', arguments: {} } diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts index a289af0a47..110d008a8a 100644 --- a/examples/client/src/customMethodExample.ts +++ b/examples/client/src/customMethodExample.ts @@ -19,7 +19,7 @@ client.setNotificationHandler('acme/searchProgress', { params: SearchProgressPar await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] })); -const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); +const result = await client.legacy.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); console.log('items:', result.items); await client.close(); diff --git a/examples/client/src/elicitationUrlExample.ts b/examples/client/src/elicitationUrlExample.ts index 7c5cce2ee2..7c55b6a20a 100644 --- a/examples/client/src/elicitationUrlExample.ts +++ b/examples/client/src/elicitationUrlExample.ts @@ -663,7 +663,7 @@ async function listTools(): Promise { method: 'tools/list', params: {} }; - const toolsResult = await client.request(toolsRequest); + const toolsResult = await client.legacy.request(toolsRequest); console.log('Available tools:'); if (toolsResult.tools.length === 0) { diff --git a/examples/client/src/parallelToolCallsClient.ts b/examples/client/src/parallelToolCallsClient.ts index 5b16cc9cc8..db57ef6349 100644 --- a/examples/client/src/parallelToolCallsClient.ts +++ b/examples/client/src/parallelToolCallsClient.ts @@ -86,7 +86,7 @@ async function listTools(client: Client): Promise { method: 'tools/list', params: {} }; - const toolsResult = await client.request(toolsRequest); + const toolsResult = await client.legacy.request(toolsRequest); console.log('Available tools:'); if (toolsResult.tools.length === 0) { diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/client/src/simpleOAuthClient.ts index f87abb7e3e..931e4b28c6 100644 --- a/examples/client/src/simpleOAuthClient.ts +++ b/examples/client/src/simpleOAuthClient.ts @@ -256,7 +256,7 @@ class InteractiveOAuthClient { params: {} }; - const result = await this.client.request(request); + const result = await this.client.legacy.request(request); if (result.tools && result.tools.length > 0) { console.log('\n📋 Available tools:'); diff --git a/examples/client/src/simpleStreamableHttp.ts b/examples/client/src/simpleStreamableHttp.ts index 6c8be12610..a09204ca8d 100644 --- a/examples/client/src/simpleStreamableHttp.ts +++ b/examples/client/src/simpleStreamableHttp.ts @@ -471,7 +471,7 @@ async function connect(url?: string): Promise { console.log('Client disconnected, cannot fetch resources'); return; } - const resourcesResult = await client.request({ + const resourcesResult = await client.legacy.request({ method: 'resources/list', params: {} }); @@ -559,7 +559,7 @@ async function listTools(): Promise { method: 'tools/list', params: {} }; - const toolsResult = await client.request(toolsRequest); + const toolsResult = await client.legacy.request(toolsRequest); console.log('Available tools:'); if (toolsResult.tools.length === 0) { @@ -704,7 +704,7 @@ async function listPrompts(): Promise { method: 'prompts/list', params: {} }; - const promptsResult = await client.request(promptsRequest); + const promptsResult = await client.legacy.request(promptsRequest); console.log('Available prompts:'); if (promptsResult.prompts.length === 0) { console.log(' No prompts available'); @@ -733,7 +733,7 @@ async function getPrompt(name: string, args: Record): Promise { method: 'resources/list', params: {} }; - const resourcesResult = await client.request(resourcesRequest); + const resourcesResult = await client.legacy.request(resourcesRequest); console.log('Available resources:'); if (resourcesResult.resources.length === 0) { @@ -782,7 +782,7 @@ async function readResource(uri: string): Promise { }; console.log(`Reading resource: ${uri}`); - const result = await client.request(request); + const result = await client.legacy.request(request); console.log('Resource contents:'); for (const content of result.contents) { diff --git a/examples/client/src/ssePollingClient.ts b/examples/client/src/ssePollingClient.ts index 4887471f99..b170e12fd8 100644 --- a/examples/client/src/ssePollingClient.ts +++ b/examples/client/src/ssePollingClient.ts @@ -70,7 +70,7 @@ async function main(): Promise { console.log('[Client] Server will disconnect mid-task to demonstrate polling'); console.log(''); - const result = await client.request( + const result = await client.legacy.request( { method: 'tools/call', params: { diff --git a/examples/client/src/streamableHttpWithSseFallbackClient.ts b/examples/client/src/streamableHttpWithSseFallbackClient.ts index 0925f8dd0b..9e4fee29f9 100644 --- a/examples/client/src/streamableHttpWithSseFallbackClient.ts +++ b/examples/client/src/streamableHttpWithSseFallbackClient.ts @@ -129,7 +129,7 @@ async function listTools(client: Client): Promise { method: 'tools/list', params: {} }; - const toolsResult = await client.request(toolsRequest); + const toolsResult = await client.legacy.request(toolsRequest); console.log('Available tools:'); if (toolsResult.tools.length === 0) { diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index 32208965b5..b08694cfbd 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -9,7 +9,7 @@ import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core'; -import { LegacyClient as Client } from './legacyClient.js'; +import { Client } from './client.js'; import { SSEClientTransport } from './sse.js'; import { StdioClientTransport } from './stdio.js'; import { StreamableHTTPClientTransport } from './streamableHttp.js'; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts new file mode 100644 index 0000000000..cb977a7b9f --- /dev/null +++ b/packages/client/src/client/client.ts @@ -0,0 +1,995 @@ +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; +import type { + CallToolRequest, + ClientCapabilities, + ClientContext, + CompleteRequest, + GetPromptRequest, + Handler, + Implementation, + InferHandlerResult, + InputRequiredResult, + JSONRPCMessage, + JSONRPCNotification, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + ListChangedHandlers, + ListChangedOptions, + ListPromptsRequest, + ListResourcesRequest, + ListResourceTemplatesRequest, + ListToolsRequest, + LoggingLevel, + Notification, + NotificationMethod, + NotificationTypeMap, + Progress, + ProgressToken, + ReadResourceRequest, + RequestMethod, + RequestOptions, + RequestTypeMap, + ResultTypeMap, + ServerCapabilities, + StandardSchemaV1, + SubscriptionFilter, + Tool, + Transport +} from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + CompleteResultSchema, + DEFAULT_REQUEST_TIMEOUT_MSEC, + DiscoverResultSchema, + EmptyResultSchema, + GetPromptResultSchema, + InputRequiredResultSchema, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCResultResponse, + isStatelessProtocolVersion, + JSONRPC_VERSION, + ListChangedOptionsBaseSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListToolsResultSchema, + META_KEYS, + parseSchema, + ProtocolError, + ProtocolErrorCode, + ReadResourceResultSchema, + SdkError, + SdkErrorCode, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +import type { ClientOptions } from './legacyClient.js'; +import { LegacyClient } from './legacyClient.js'; + +export type { ClientOptions } from './legacyClient.js'; +export { getSupportedElicitationModes } from './legacyClient.js'; + +const MRTR_MAX_ROUNDS = 16; +const MAX_INPUT_REQUESTS_PER_ROUND = 16; + +/** Only these methods may appear as `inputRequests` (defense-in-depth; schema also constrains). */ +const MRTR_INPUT_METHODS: ReadonlySet = new Set(['sampling/createMessage', 'elicitation/create', 'roots/list']); + +type ListChangedKinds = Record< + string, + { + filterKey: Exclude; + config: ListChangedOptions; + fetcher: () => Promise; + autoRefresh: boolean; + debounceMs?: number; + } +>; + +/** + * Returns true for `server/discover` failures that should fall through to the + * legacy `initialize` handshake (server doesn't speak 2026-06). Auth failures + * (401/403) are NOT fallbackable: a server that requires auth for `discover` + * will require it for `initialize` too, so falling back would only mask the + * real error and skip the transport's re-auth path. + */ +function isFallbackable(e: unknown): boolean { + if (e instanceof ProtocolError) { + return e.code === ProtocolErrorCode.MethodNotFound; + } + if (e instanceof SdkError) { + const status = (e.data as { status?: number } | undefined)?.status; + // Any 4xx except 401/403 (auth) means the server doesn't speak 2026-06. + // 400 in particular is what a pre-2026 StreamableHTTP server returns for + // a non-initialize POST without an mcp-session-id. + return ( + e.code === SdkErrorCode.InvalidResult || + (typeof status === 'number' && status >= 400 && status < 500 && status !== 401 && status !== 403) + ); + } + return false; +} + +/** + * An MCP client on top of a pluggable transport. + * + * `Client` does not extend `Protocol`. It owns the 2026-06 stateless send + * path ({@linkcode subscribe}, `_send`/`_negotiate`/`_collect`) and the typed + * request methods, and composes a {@linkcode LegacyClient} (reachable via + * {@linkcode legacy}) for the pre-2026 connection model. Both share one + * handler registry, so a single `setRequestHandler` call serves + * server-to-client requests under either protocol era. + * + * @example Handling a sampling request + * ```ts source="./client.examples.ts#Client_setRequestHandler_sampling" + * client.setRequestHandler('sampling/createMessage', async request => { + * const lastMessage = request.params.messages.at(-1); + * console.log('Sampling request:', lastMessage); + * + * // In production, send messages to your LLM here + * return { + * model: 'my-model', + * role: 'assistant' as const, + * content: { + * type: 'text' as const, + * text: 'Response from the model' + * } + * }; + * }); + * ``` + */ +export class Client { + /** Static identity + capabilities. `capabilities` is read from {@linkcode legacy} so `registerCapabilities` stays single-source. */ + readonly config: { + readonly clientInfo: Implementation; + readonly supportedProtocolVersions: readonly string[]; + }; + + /** Composed pre-2026 implementation; owns the `Protocol` connection + negotiated server state. */ + private readonly _legacy: LegacyClient; + + private readonly _jsonSchemaValidator: jsonSchemaValidator; + private readonly _enforceStrictCapabilities: boolean; + private readonly _cachedToolOutputValidators: Map> = new Map(); + private _pendingListChangedConfig?: ListChangedHandlers; + + /** + * Initializes this client with the given name and version information. + */ + constructor(clientInfo: Implementation, options?: ClientOptions) { + this._legacy = new LegacyClient(clientInfo, options); + this.config = { + clientInfo, + supportedProtocolVersions: options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS + }; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + + // Store list changed config for setup after connection (when we know server capabilities) + if (options?.listChanged) { + this._pendingListChangedConfig = options.listChanged; + } + } + + /** + * Escape hatch to the composed pre-2026 connection-model client (extends + * `Protocol`). Use for `ping`/`subscribeResource`/`unsubscribeResource`/ + * `sendRootsListChanged`/raw `request`/`notification`/`setNotificationHandler`. + */ + get legacy(): LegacyClient { + return this._legacy; + } + + // ═══════════════════════════════════════════════════════════════════════ + // 2026 stateless (SEP-2575/2322) + // ═══════════════════════════════════════════════════════════════════════ + + /** Set true by {@linkcode _negotiate} when `server/discover` succeeds. */ + private _isStateless = false; + + /** Log level included in per-request `_meta` (set by {@linkcode setLoggingLevel}). */ + private _logLevel?: LoggingLevel; + + /** + * Builds the namespaced `_meta` object this client sends on every 2026-06 + * request: protocol version, client identity, capabilities, log level. + */ + private _buildMeta(version?: string): Record { + const meta: Record = { + [META_KEYS.protocolVersion]: version ?? this._legacy.getNegotiatedProtocolVersion(), + [META_KEYS.clientInfo]: this.config.clientInfo, + [META_KEYS.clientCapabilities]: this._legacy._clientCapabilities + }; + if (this._logLevel !== undefined) meta[META_KEYS.logLevel] = this._logLevel; + return meta; + } + + /** + * Merges {@linkcode _buildMeta} + `extra` into `params._meta`. Caller-supplied + * `params._meta` keys take precedence. + */ + private _withMeta(params: Record | undefined, extra?: Record): Record { + return { ...params, _meta: { ...this._buildMeta(), ...extra, ...(params?._meta as object | undefined) } }; + } + + /** + * Drains a `sendAndReceive` async iterable: routes `notifications/progress` + * with the matching token to `opts.onprogress`, drops any other + * notification (the JSON branch cannot deliver them and the server knows + * this), parses and returns the first response, throws on JSON-RPC error. + */ + private async _collect( + it: AsyncIterable, + opts?: { signal?: AbortSignal; onprogress?: (p: Progress) => void; progressToken?: ProgressToken } + ): Promise> { + for await (const m of it) { + opts?.signal?.throwIfAborted(); + if (isJSONRPCErrorResponse(m)) { + throw new ProtocolError(m.error.code, m.error.message, m.error.data); + } + if (isJSONRPCResultResponse(m)) { + return m.result; + } + if (isJSONRPCNotification(m)) { + if ( + m.method === 'notifications/progress' && + opts?.onprogress && + (m.params as { progressToken?: ProgressToken }).progressToken === opts.progressToken + ) { + opts.onprogress(m.params as Progress); + } else { + // Route other notifications (e.g. notifications/message) through + // Protocol's _onnotification so any handler registered via + // setNotificationHandler fires, with the fallback as last resort. + this._legacy._routeNotification(m); + } + } + // Anything else (e.g. a stray request) is ignored. + } + if (opts?.signal?.aborted) throw opts.signal.reason ?? new DOMException('Aborted', 'AbortError'); + throw new SdkError(SdkErrorCode.ConnectionClosed, 'Stream ended without a response'); + } + + /** + * Routes one client-to-server request. When not stateless (or transport + * lacks `sendAndReceive`), delegates to {@linkcode Protocol.request | request()}. + * Otherwise sends via `sendAndReceive` and runs the MRTR resume loop: + * on `resultType: 'input_required'`, dispatch each input request through + * `this._legacy._dispatch` (so `_validationMiddleware` runs), + * accumulate `inputResponses` + thread `requestState`, re-send. + */ + private async _send( + request: { method: string; params?: Record }, + schema: T, + options?: RequestOptions + ): Promise> { + const sar = this.transport?.sendAndReceive?.bind(this.transport); + if (!this._isStateless || !sar) { + return this._legacy.request(request, schema, options); + } + + const progressToken: ProgressToken | undefined = options?.onprogress ? crypto.randomUUID() : undefined; + const accumulated: Record = {}; + let requestState: string | undefined; + + // Compose `options.signal` + `options.maxTotalTimeout` + a resettable + // per-request `options.timeout` (default 60s, same as Protocol.request) + // into one signal. `resetTimeoutOnProgress` resets the per-request timer + // when progress arrives; `maxTotalTimeout` is never reset. + const timeoutMs = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timeoutCtl = new AbortController(); + const armTimeout = () => + setTimeout( + () => timeoutCtl.abort(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: timeoutMs })), + timeoutMs + ); + let timeoutHandle = armTimeout(); + const onprogress = (p: Progress) => { + if (options?.resetTimeoutOnProgress) { + clearTimeout(timeoutHandle); + timeoutHandle = armTimeout(); + } + options?.onprogress?.(p); + }; + const parts: AbortSignal[] = [timeoutCtl.signal]; + if (options?.signal) parts.push(options.signal); + if (options?.maxTotalTimeout !== undefined) parts.push(AbortSignal.timeout(options.maxTotalTimeout)); + const signal = parts.length === 1 ? parts[0]! : AbortSignal.any(parts); + + try { + for (let round = 0; round < MRTR_MAX_ROUNDS; round++) { + signal.throwIfAborted(); + // SEP-2322: inputResponses + requestState are params-level fields + // (spec InputResponseRequestParams), not _meta keys. + const params: Record = { ...request.params }; + if (Object.keys(accumulated).length > 0) params.inputResponses = accumulated; + if (requestState !== undefined) params.requestState = requestState; + const metaExtra = progressToken === undefined ? undefined : { progressToken }; + + const raw = await this._collect(sar({ method: request.method, params: this._withMeta(params, metaExtra) }, { signal }), { + signal, + onprogress, + progressToken + }); + + if (raw.resultType !== 'input_required') { + const parsed = await schema['~standard'].validate(raw); + if (parsed.issues) { + throw new SdkError(SdkErrorCode.InvalidResult, `Invalid result: ${JSON.stringify(parsed.issues)}`); + } + return parsed.value; + } + const ir = InputRequiredResultSchema.parse(raw) as InputRequiredResult; + requestState = ir.requestState; + const entries = Object.entries(ir.inputRequests ?? {}); + if (entries.length > MAX_INPUT_REQUESTS_PER_ROUND) { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Too many input requests (${entries.length}); server may issue at most ${MAX_INPUT_REQUESTS_PER_ROUND} per round` + ); + } + for (const [key, irq] of entries) { + signal.throwIfAborted(); + if (!MRTR_INPUT_METHODS.has(irq.method)) { + throw new SdkError(SdkErrorCode.InvalidResult, `inputRequests['${key}'].method '${irq.method}' is not allowed`); + } + // Dispatch through the same middleware chain as legacy + // server-to-client requests so _validationMiddleware applies. + const ctx: ClientContext = { + sessionId: undefined, + mcpReq: { + id: key, + method: irq.method, + signal, + send: (() => { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'send is not available inside MRTR input handlers'); + }) as ClientContext['mcpReq']['send'], + notify: async () => {} + } + }; + const res = await this._legacy._dispatch( + { jsonrpc: JSONRPC_VERSION, id: key, method: irq.method, params: irq.params as Record }, + ctx + ); + if ('error' in res) { + throw new ProtocolError(res.error.code, res.error.message, res.error.data); + } + accumulated[key] = res.result; + } + } + throw new SdkError(SdkErrorCode.RequestTimeout, `MRTR exceeded ${MRTR_MAX_ROUNDS} rounds for ${request.method}`); + } finally { + clearTimeout(timeoutHandle); + } + } + + /** + * Probes `server/discover` via `transport.sendAndReceive`. On success, + * marks this client stateless and populates server identity/capabilities + * from the result. On {@linkcode isFallbackable} failure, leaves state + * untouched so {@linkcode connect} falls through to the legacy + * `initialize` handshake. + */ + private async _negotiate(transport: Transport, options?: RequestOptions): Promise { + const sar = transport.sendAndReceive?.bind(transport); + const preferred = this.config.supportedProtocolVersions.find(v => isStatelessProtocolVersion(v)); + if (!sar || !preferred) return; + + transport.setProtocolVersion?.(preferred); + const signal = + options?.timeout === undefined + ? (options?.signal ?? AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MSEC)) + : options?.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeout)]) + : AbortSignal.timeout(options.timeout); + try { + const raw = await this._collect(sar({ method: 'server/discover', params: { _meta: this._buildMeta(preferred) } }, { signal }), { + signal + }); + const drParsed = DiscoverResultSchema.safeParse(raw); + if (drParsed.success) { + const dr = drParsed.data; + // The probe only counts as success when there is a mutual + // *stateless* version; otherwise fall through to legacy initialize. + const negotiated = dr.supportedVersions.find( + v => isStatelessProtocolVersion(v) && this.config.supportedProtocolVersions.includes(v) + ); + if (negotiated) { + this._legacy._setNegotiated({ + serverCapabilities: dr.capabilities, + serverVersion: dr.serverInfo, + instructions: dr.instructions, + protocolVersion: negotiated + }); + this._isStateless = true; + transport.setProtocolVersion?.(negotiated); + return; + } + } + } catch (error) { + if (!isFallbackable(error)) { + // Reset the version we set before re-throwing so the + // transport is not left advertising a stateless version. + transport.setProtocolVersion?.(this._legacy.getNegotiatedProtocolVersion() ?? ''); + throw error; + } + } + // Fallback path: reset the version header so the subsequent legacy + // `_initialize()` (run by `connect()`) can set it. + transport.setProtocolVersion?.(this._legacy.getNegotiatedProtocolVersion() ?? ''); + } + + /** + * Opens a `subscriptions/listen` stream and yields each notification. + * Throws unless this client negotiated stateless mode and the transport + * supports `sendAndReceive`. Breaking out of the loop sends + * `notifications/cancelled`. + */ + async *subscribe(filter: SubscriptionFilter, opts?: { signal?: AbortSignal }): AsyncGenerator { + const sar = this.transport?.sendAndReceive?.bind(this.transport); + if (!this._isStateless || !sar) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + 'subscribe() requires a stateless protocol version and a transport that supports sendAndReceive' + ); + } + for await (const m of sar( + { method: 'subscriptions/listen', params: this._withMeta({ notifications: filter }) }, + { signal: opts?.signal } + )) { + if (isJSONRPCNotification(m)) { + yield m; + } else if (isJSONRPCErrorResponse(m)) { + throw new ProtocolError(m.error.code, m.error.message, m.error.data); + } + } + } + + private _listChangedAbort?: AbortController; + + /** + * Stateless backing for `options.listChanged`: opens ONE + * `subscriptions/listen` for all configured list-changed kinds and calls + * the matching `onChanged` per notification (debounced by `debounceMs`). + */ + private async _listChangedLoop(kinds: ListChangedKinds): Promise { + const filter: SubscriptionFilter = {}; + const debounced: Record void> = {}; + const timers = new Map>(); + for (const [method, k] of Object.entries(kinds)) { + filter[k.filterKey] = true; + const { autoRefresh, debounceMs } = k; + const refresh = async () => { + if (!autoRefresh) { + k.config.onChanged(null, null); + return; + } + try { + k.config.onChanged(null, await k.fetcher()); + } catch (error) { + k.config.onChanged(error instanceof Error ? error : new Error(String(error)), null); + } + }; + // eslint-disable-next-line unicorn/consistent-function-scoping -- closes over per-iteration `refresh` + const run = () => + void refresh().catch(error => (this.onerror ?? console.error)(error instanceof Error ? error : new Error(String(error)))); + debounced[method] = debounceMs + ? () => { + const t = timers.get(method); + if (t) clearTimeout(t); + timers.set(method, setTimeout(run, debounceMs)); + } + : run; + } + this._listChangedAbort?.abort(); + this._listChangedAbort = new AbortController(); + const { signal } = this._listChangedAbort; + try { + for await (const n of this.subscribe(filter, { signal })) { + debounced[n.method]?.(); + } + // Stream ended without error and without our abort: surface so the + // caller knows list-changed delivery has stopped. + if (!signal.aborted) { + throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended'); + } + } finally { + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // dual-mode (SEP-2575/2567) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Connects to a server via the given transport and performs the MCP initialization handshake. + * + * @example Basic usage (stdio) + * ```ts source="./client.examples.ts#Client_connect_stdio" + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new StdioClientTransport({ command: 'my-mcp-server' }); + * await client.connect(transport); + * ``` + * + * @example Streamable HTTP with SSE fallback + * ```ts source="./client.examples.ts#Client_connect_sseFallback" + * const baseUrl = new URL(url); + * + * try { + * // Try modern Streamable HTTP transport first + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new StreamableHTTPClientTransport(baseUrl); + * await client.connect(transport); + * return { client, transport }; + * } catch { + * // Fall back to legacy SSE transport + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new SSEClientTransport(baseUrl); + * await client.connect(transport); + * return { client, transport }; + * } + * ``` + */ + async connect(transport: Transport, options?: RequestOptions): Promise { + await this._legacy.connect(transport); + // When transport sessionId is already set this means we are trying to reconnect. + // Restore the protocol version negotiated during the original initialize handshake + // so HTTP transports include the required mcp-protocol-version header, but skip re-init. + if (transport.sessionId !== undefined) { + const negotiated = this._legacy.getNegotiatedProtocolVersion(); + if (negotiated !== undefined && transport.setProtocolVersion) { + transport.setProtocolVersion(negotiated); + } + return; + } + try { + // Probe `server/discover` (SEP-2575). If it succeeds, this client is + // stateless and the legacy `initialize` is skipped. + await this._negotiate(transport, options); + if (!this._isStateless) { + await this._legacy._initialize(transport, options); + } + this._setupListChanged(); + } catch (error) { + // Disconnect if initialization fails. + void this.close(); + throw error; + } + } + + async close(): Promise { + this._isStateless = false; + this._listChangedAbort?.abort(); + this._listChangedAbort = undefined; + await this._legacy.close(); + } + + get transport(): Transport | undefined { + return this._legacy.transport; + } + + get onclose(): (() => void) | undefined { + return this._legacy.onclose; + } + set onclose(cb: (() => void) | undefined) { + this._legacy.onclose = cb; + } + + get onerror(): ((error: Error) => void) | undefined { + return this._legacy.onerror; + } + set onerror(cb: ((error: Error) => void) | undefined) { + this._legacy.onerror = cb; + } + + get fallbackNotificationHandler(): ((notification: Notification) => Promise) | undefined { + return this._legacy.fallbackNotificationHandler; + } + set fallbackNotificationHandler(cb: ((notification: Notification) => Promise) | undefined) { + this._legacy.fallbackNotificationHandler = cb; + } + + /** + * Set up handlers for list changed notifications based on config and server capabilities. + * This should only be called after initialization when server capabilities are known. + * Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability. + * @internal + */ + private _setupListChangedHandlers(config: ListChangedHandlers): void { + const caps = this._legacy.getServerCapabilities(); + if (config.tools && caps?.tools?.listChanged) { + this._legacy._setupListChangedHandler('tools', 'notifications/tools/list_changed', config.tools, async () => { + const result = await this.listTools(); + return result.tools; + }); + } + + if (config.prompts && caps?.prompts?.listChanged) { + this._legacy._setupListChangedHandler('prompts', 'notifications/prompts/list_changed', config.prompts, async () => { + const result = await this.listPrompts(); + return result.prompts; + }); + } + + if (config.resources && caps?.resources?.listChanged) { + this._legacy._setupListChangedHandler('resources', 'notifications/resources/list_changed', config.resources, async () => { + const result = await this.listResources(); + return result.resources; + }); + } + } + + /** + * Wires `options.listChanged` after capabilities are known. Stateless + * connections use `subscriptions/listen` ({@linkcode _listChangedLoop}); + * legacy connections register notification handlers. + */ + private _setupListChanged(): void { + const config = this._pendingListChangedConfig; + if (!config) return; + if (!this._isStateless) { + this._pendingListChangedConfig = undefined; + this._setupListChangedHandlers(config); + return; + } + const caps = this._legacy.getServerCapabilities(); + const kinds: ListChangedKinds = {}; + const add = ( + method: string, + filterKey: Exclude, + cfg: ListChangedOptions, + fetcher: () => Promise + ): void => { + const parsed = parseSchema(ListChangedOptionsBaseSchema, cfg); + if (!parsed.success) throw new Error(`Invalid ${String(filterKey)} listChanged options: ${parsed.error.message}`); + kinds[method] = { filterKey, config: cfg as ListChangedOptions, fetcher, ...parsed.data }; + }; + if (config.tools && caps?.tools?.listChanged) { + add('notifications/tools/list_changed', 'toolsListChanged', config.tools, () => this.listTools().then(r => r.tools)); + } + if (config.prompts && caps?.prompts?.listChanged) { + add('notifications/prompts/list_changed', 'promptsListChanged', config.prompts, () => this.listPrompts().then(r => r.prompts)); + } + if (config.resources && caps?.resources?.listChanged) { + add('notifications/resources/list_changed', 'resourcesListChanged', config.resources, () => + this.listResources().then(r => r.resources) + ); + } + if (Object.keys(kinds).length > 0) { + this._listChangedLoop(kinds).catch(error => + (this.onerror ?? console.error)(error instanceof Error ? error : new Error(String(error))) + ); + } + } + + /** + * Sets the minimum severity level for log messages sent by the server. + * Stored locally for per-request `_meta.logLevel`; when not stateless, + * also sends the legacy `logging/setLevel` RPC. + */ + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + this._logLevel = level; + if (this._isStateless) return {}; + return this._legacy.request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + } + + // ═══════════════════════════════════════════════════════════════════════ + // delegating (single registry; legacy owns the Dispatcher + negotiated state) + // ═══════════════════════════════════════════════════════════════════════ + + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise + ): void; + setRequestHandler

( + method: string, + schemas: { params: P; result?: R }, + handler: (params: StandardSchemaV1.InferOutput

, ctx: ClientContext) => InferHandlerResult | Promise> + ): void; + setRequestHandler(method: string, b: unknown, c?: unknown): void { + (this._legacy.setRequestHandler as (m: string, b: unknown, c?: unknown) => void)(method, b, c); + } + + removeRequestHandler(method: RequestMethod | string): void { + this._legacy.removeRequestHandler(method); + } + + assertCanSetRequestHandler(method: RequestMethod | string): void { + this._legacy.assertCanSetRequestHandler(method); + } + + setNotificationHandler( + method: M, + handler: (notification: NotificationTypeMap[M]) => void | Promise + ): void; + setNotificationHandler

( + method: string, + schemas: { params: P }, + handler: (params: StandardSchemaV1.InferOutput

, notification: Notification) => void | Promise + ): void; + setNotificationHandler(method: string, b: unknown, c?: unknown): void { + (this._legacy.setNotificationHandler as (m: string, b: unknown, c?: unknown) => void)(method, b, c); + } + + get fallbackRequestHandler(): Handler | undefined { + return this._legacy.fallbackRequestHandler; + } + set fallbackRequestHandler(handler: Handler | undefined) { + this._legacy.fallbackRequestHandler = handler; + } + + /** + * Registers new capabilities. This can only be called before connecting to a transport. + * + * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). + */ + registerCapabilities(capabilities: ClientCapabilities): void { + this._legacy.registerCapabilities(capabilities); + } + + /** + * After initialization has completed, this will be populated with the server's reported capabilities. + */ + getServerCapabilities(): ServerCapabilities | undefined { + return this._legacy.getServerCapabilities(); + } + + /** + * After initialization has completed, this will be populated with information about the server's name and version. + */ + getServerVersion(): Implementation | undefined { + return this._legacy.getServerVersion(); + } + + /** + * After initialization has completed, this will be populated with the protocol version negotiated + * during the initialize handshake. When manually reconstructing a transport for reconnection, pass this + * value to the new transport so it continues sending the required `mcp-protocol-version` header. + */ + getNegotiatedProtocolVersion(): string | undefined { + return this._legacy.getNegotiatedProtocolVersion(); + } + + /** + * After initialization has completed, this may be populated with information about the server's instructions. + */ + getInstructions(): string | undefined { + return this._legacy.getInstructions(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // typed request methods (route via _send → falls back to legacy.request()) + // ═══════════════════════════════════════════════════════════════════════ + + /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ + async complete(params: CompleteRequest['params'], options?: RequestOptions) { + return this._send({ method: 'completion/complete', params }, CompleteResultSchema, options); + } + + /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { + return this._send({ method: 'prompts/get', params }, GetPromptResultSchema, options); + } + + /** + * Lists available prompts. Results may be paginated — loop on `nextCursor` to collect all pages. + * + * Returns an empty list if the server does not advertise prompts capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + * + * @example + * ```ts source="./client.examples.ts#Client_listPrompts_pagination" + * const allPrompts: Prompt[] = []; + * let cursor: string | undefined; + * do { + * const { prompts, nextCursor } = await client.listPrompts({ cursor }); + * allPrompts.push(...prompts); + * cursor = nextCursor; + * } while (cursor); + * console.log( + * 'Available prompts:', + * allPrompts.map(p => p.name) + * ); + * ``` + */ + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + if (!this._legacy.getServerCapabilities()?.prompts && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support prompts + console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); + return { prompts: [] }; + } + return this._send({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + } + + /** + * Lists available resources. Results may be paginated — loop on `nextCursor` to collect all pages. + * + * Returns an empty list if the server does not advertise resources capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + * + * @example + * ```ts source="./client.examples.ts#Client_listResources_pagination" + * const allResources: Resource[] = []; + * let cursor: string | undefined; + * do { + * const { resources, nextCursor } = await client.listResources({ cursor }); + * allResources.push(...resources); + * cursor = nextCursor; + * } while (cursor); + * console.log( + * 'Available resources:', + * allResources.map(r => r.name) + * ); + * ``` + */ + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + if (!this._legacy.getServerCapabilities()?.resources && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support resources + console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); + return { resources: [] }; + } + return this._send({ method: 'resources/list', params }, ListResourcesResultSchema, options); + } + + /** + * Lists available resource URI templates for dynamic resources. Results may be paginated — see {@linkcode listResources | listResources()} for the cursor pattern. + * + * Returns an empty list if the server does not advertise resources capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + */ + async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + if (!this._legacy.getServerCapabilities()?.resources && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support resources + console.debug( + 'Client.listResourceTemplates() called but server does not advertise resources capability - returning empty list' + ); + return { resourceTemplates: [] }; + } + return this._send({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + } + + /** Reads the contents of a resource by URI. */ + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { + return this._send({ method: 'resources/read', params }, ReadResourceResultSchema, options); + } + + /** + * Calls a tool on the connected server and returns the result. Automatically validates structured output + * if the tool has an `outputSchema`. + * + * Tool results have two error surfaces: `result.isError` for tool-level failures (the tool ran but reported + * a problem), and thrown {@linkcode ProtocolError} for protocol-level failures or {@linkcode SdkError} for + * SDK-level issues (timeouts, missing capabilities). + * + * @example Basic usage + * ```ts source="./client.examples.ts#Client_callTool_basic" + * const result = await client.callTool({ + * name: 'calculate-bmi', + * arguments: { weightKg: 70, heightM: 1.75 } + * }); + * + * // Tool-level errors are returned in the result, not thrown + * if (result.isError) { + * console.error('Tool error:', result.content); + * return; + * } + * + * console.log(result.content); + * ``` + * + * @example Structured output + * ```ts source="./client.examples.ts#Client_callTool_structuredOutput" + * const result = await client.callTool({ + * name: 'calculate-bmi', + * arguments: { weightKg: 70, heightM: 1.75 } + * }); + * + * // Machine-readable output for the client application + * if (result.structuredContent) { + * console.log(result.structuredContent); // e.g. { bmi: 22.86 } + * } + * ``` + */ + async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + const result = await this._send({ method: 'tools/call', params }, CallToolResultSchema, options); + + // Check if the tool has an outputSchema + const validator = this.getToolOutputValidator(params.name); + if (validator) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ); + } + + // Only validate structured content if present (not when there's an error) + if (result.structuredContent) { + try { + // Validate the structured content against the schema + const validationResult = validator(result.structuredContent); + + if (!validationResult.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof ProtocolError) { + throw error; + } + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + return result; + } + + /** + * Cache validators for tool output schemas. + * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. + */ + private cacheToolMetadata(tools: Tool[]): void { + this._cachedToolOutputValidators.clear(); + + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { + const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + this._cachedToolOutputValidators.set(tool.name, toolValidator); + } + } + } + + /** + * Get cached validator for a tool + */ + private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { + return this._cachedToolOutputValidators.get(toolName); + } + + /** + * Lists available tools. Results may be paginated — loop on `nextCursor` to collect all pages. + * + * Returns an empty list if the server does not advertise tools capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + * + * @example + * ```ts source="./client.examples.ts#Client_listTools_pagination" + * const allTools: Tool[] = []; + * let cursor: string | undefined; + * do { + * const { tools, nextCursor } = await client.listTools({ cursor }); + * allTools.push(...tools); + * cursor = nextCursor; + * } while (cursor); + * console.log( + * 'Available tools:', + * allTools.map(t => t.name) + * ); + * ``` + */ + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + if (!this._legacy.getServerCapabilities()?.tools && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support tools + console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); + return { tools: [] }; + } + const result = await this._send({ method: 'tools/list', params }, ListToolsResultSchema, options); + + // Cache the tools and their output schemas for future validation + this.cacheToolMetadata(result.tools); + + return result; + } +} diff --git a/packages/client/src/client/legacyClient.ts b/packages/client/src/client/legacyClient.ts index 84fde20778..6a8cd69915 100644 --- a/packages/client/src/client/legacyClient.ts +++ b/packages/client/src/client/legacyClient.ts @@ -1,126 +1,54 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; import type { BaseContext, - CallToolRequest, ClientCapabilities, ClientContext, - CompleteRequest, - GetPromptRequest, Implementation, - InputRequiredResult, - JSONRPCMessage, + JSONRPCErrorResponse, JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, - JsonSchemaValidator, jsonSchemaValidator, ListChangedHandlers, ListChangedOptions, - ListPromptsRequest, - ListResourcesRequest, - ListResourceTemplatesRequest, - ListToolsRequest, - LoggingLevel, MessageExtraInfo, Middleware, NotificationMethod, - Progress, - ProgressToken, ProtocolOptions, - ReadResourceRequest, RequestMethod, RequestOptions, ServerCapabilities, - StandardSchemaV1, SubscribeRequest, - SubscriptionFilter, - Tool, Transport, UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - CompleteResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - DEFAULT_REQUEST_TIMEOUT_MSEC, - DiscoverResultSchema, ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, - GetPromptResultSchema, InitializeResultSchema, - InputRequiredResultSchema, - isJSONRPCErrorResponse, - isJSONRPCNotification, - isJSONRPCResultResponse, - isStatelessProtocolVersion, - JSONRPC_VERSION, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, mergeCapabilities, - META_KEYS, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, - ReadResourceResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; -const MRTR_MAX_ROUNDS = 16; -const MAX_INPUT_REQUESTS_PER_ROUND = 16; - -/** Only these methods may appear as `inputRequests` (defense-in-depth; schema also constrains). */ -const MRTR_INPUT_METHODS: ReadonlySet = new Set(['sampling/createMessage', 'elicitation/create', 'roots/list']); - -type ListChangedKinds = Record< - string, - { - filterKey: Exclude; - config: ListChangedOptions; - fetcher: () => Promise; - autoRefresh: boolean; - debounceMs?: number; - } ->; - -/** - * Returns true for `server/discover` failures that should fall through to the - * legacy `initialize` handshake (server doesn't speak 2026-06). Auth failures - * (401/403) are NOT fallbackable: a server that requires auth for `discover` - * will require it for `initialize` too, so falling back would only mask the - * real error and skip the transport's re-auth path. - */ -function isFallbackable(e: unknown): boolean { - if (e instanceof ProtocolError) { - return e.code === ProtocolErrorCode.MethodNotFound; - } - if (e instanceof SdkError) { - const status = (e.data as { status?: number } | undefined)?.status; - // Any 4xx except 401/403 (auth) means the server doesn't speak 2026-06. - // 400 in particular is what a pre-2026 StreamableHTTP server returns for - // a non-initialize POST without an mcp-session-id. - return ( - e.code === SdkErrorCode.InvalidResult || - (typeof status === 'number' && status >= 400 && status < 500 && status !== 401 && status !== 403) - ); - } - return false; -} - /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * * @param schema - The schema to apply defaults to. * @param data - The data to apply defaults to. */ -function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unknown): void { +export function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unknown): void { if (!schema || data === null || typeof data !== 'object') return; // Handle object properties @@ -268,10 +196,7 @@ export class LegacyClient extends Protocol { private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; - private _cachedToolOutputValidators: Map> = new Map(); private _listChangedDebounceTimers: Map> = new Map(); - private _pendingListChangedConfig?: ListChangedHandlers; - private _enforceStrictCapabilities: boolean; /** * Initializes this client with the given name and version information. @@ -283,14 +208,8 @@ export class LegacyClient extends Protocol { super(options); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; this.dispatcher.use(this._validationMiddleware); - - // Store list changed config for setup after connection (when we know server capabilities) - if (options?.listChanged) { - this._pendingListChangedConfig = options.listChanged; - } } protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext { @@ -298,382 +217,70 @@ export class LegacyClient extends Protocol { } // ═══════════════════════════════════════════════════════════════════════ - // 2026 stateless (SEP-2575/2322) + // internal — exposed for the composing Client (client.ts) // ═══════════════════════════════════════════════════════════════════════ - /** Set true by {@linkcode _negotiate} when `server/discover` succeeds. */ - private _isStateless = false; - - /** Log level included in per-request `_meta` (set by {@linkcode setLoggingLevel}). */ - private _logLevel?: LoggingLevel; - /** - * Builds the namespaced `_meta` object this client sends on every 2026-06 - * request: protocol version, client identity, capabilities, log level. + * @internal Routes a request through this instance's `Dispatcher`. + * Called by the composing `Client`'s MRTR resume loop so the same registry + * + `_validationMiddleware` apply to MRTR input requests. */ - private _buildMeta(version?: string): Record { - const meta: Record = { - [META_KEYS.protocolVersion]: version ?? this._negotiatedProtocolVersion, - [META_KEYS.clientInfo]: this._clientInfo, - [META_KEYS.clientCapabilities]: this._capabilities - }; - if (this._logLevel !== undefined) meta[META_KEYS.logLevel] = this._logLevel; - return meta; + _dispatch(request: JSONRPCRequest, ctx: ClientContext): Promise { + return this.dispatcher.dispatch(request, ctx); } /** - * Merges {@linkcode _buildMeta} + `extra` into `params._meta`. Caller-supplied - * `params._meta` keys take precedence. + * @internal Routes a notification through `Protocol._onnotification` so any + * `setNotificationHandler`/`fallbackNotificationHandler` fires. Called by + * `Client._collect` for non-progress notifications on the stateless path. */ - private _withMeta(params: Record | undefined, extra?: Record): Record { - return { ...params, _meta: { ...this._buildMeta(), ...extra, ...(params?._meta as object | undefined) } }; - } - - /** - * Drains a `sendAndReceive` async iterable: routes `notifications/progress` - * with the matching token to `opts.onprogress`, drops any other - * notification (the JSON branch cannot deliver them and the server knows - * this), parses and returns the first response, throws on JSON-RPC error. - */ - private async _collect( - it: AsyncIterable, - opts?: { signal?: AbortSignal; onprogress?: (p: Progress) => void; progressToken?: ProgressToken } - ): Promise> { - for await (const m of it) { - opts?.signal?.throwIfAborted(); - if (isJSONRPCErrorResponse(m)) { - throw new ProtocolError(m.error.code, m.error.message, m.error.data); - } - if (isJSONRPCResultResponse(m)) { - return m.result; - } - if (isJSONRPCNotification(m)) { - if ( - m.method === 'notifications/progress' && - opts?.onprogress && - (m.params as { progressToken?: ProgressToken }).progressToken === opts.progressToken - ) { - opts.onprogress(m.params as Progress); - } else { - // Route other notifications (e.g. notifications/message) through - // Protocol's _onnotification so any handler registered via - // setNotificationHandler fires, with the fallback as last resort. - this._onnotification(m); - } - } - // Anything else (e.g. a stray request) is ignored. - } - if (opts?.signal?.aborted) throw opts.signal.reason ?? new DOMException('Aborted', 'AbortError'); - throw new SdkError(SdkErrorCode.ConnectionClosed, 'Stream ended without a response'); + _routeNotification(notification: JSONRPCNotification): void { + this._onnotification(notification); } - /** - * Routes one client-to-server request. When not stateless (or transport - * lacks `sendAndReceive`), delegates to {@linkcode Protocol.request | request()}. - * Otherwise sends via `sendAndReceive` and runs the MRTR resume loop: - * on `resultType: 'input_required'`, dispatch each input request through - * `this.dispatcher.dispatch` (so {@linkcode _validationMiddleware} runs), - * accumulate `inputResponses` + thread `requestState`, re-send. - */ - private async _send( - request: { method: string; params?: Record }, - schema: T, - options?: RequestOptions - ): Promise> { - const sar = this.transport?.sendAndReceive?.bind(this.transport); - if (!this._isStateless || !sar) { - return this._requestWithSchema(request, schema, options); - } - - const progressToken: ProgressToken | undefined = options?.onprogress ? crypto.randomUUID() : undefined; - const accumulated: Record = {}; - let requestState: string | undefined; - - // Compose `options.signal` + `options.maxTotalTimeout` + a resettable - // per-request `options.timeout` (default 60s, same as Protocol.request) - // into one signal. `resetTimeoutOnProgress` resets the per-request timer - // when progress arrives; `maxTotalTimeout` is never reset. - const timeoutMs = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const timeoutCtl = new AbortController(); - const armTimeout = () => - setTimeout( - () => timeoutCtl.abort(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: timeoutMs })), - timeoutMs - ); - let timeoutHandle = armTimeout(); - const onprogress = (p: Progress) => { - if (options?.resetTimeoutOnProgress) { - clearTimeout(timeoutHandle); - timeoutHandle = armTimeout(); - } - options?.onprogress?.(p); - }; - const parts: AbortSignal[] = [timeoutCtl.signal]; - if (options?.signal) parts.push(options.signal); - if (options?.maxTotalTimeout !== undefined) parts.push(AbortSignal.timeout(options.maxTotalTimeout)); - const signal = parts.length === 1 ? parts[0]! : AbortSignal.any(parts); - - try { - for (let round = 0; round < MRTR_MAX_ROUNDS; round++) { - signal.throwIfAborted(); - // SEP-2322: inputResponses + requestState are params-level fields - // (spec InputResponseRequestParams), not _meta keys. - const params: Record = { ...request.params }; - if (Object.keys(accumulated).length > 0) params.inputResponses = accumulated; - if (requestState !== undefined) params.requestState = requestState; - const metaExtra = progressToken === undefined ? undefined : { progressToken }; - - const raw = await this._collect(sar({ method: request.method, params: this._withMeta(params, metaExtra) }, { signal }), { - signal, - onprogress, - progressToken - }); - - if (raw.resultType !== 'input_required') { - const parsed = await schema['~standard'].validate(raw); - if (parsed.issues) { - throw new SdkError(SdkErrorCode.InvalidResult, `Invalid result: ${JSON.stringify(parsed.issues)}`); - } - return parsed.value; - } - const ir = InputRequiredResultSchema.parse(raw) as InputRequiredResult; - requestState = ir.requestState; - const entries = Object.entries(ir.inputRequests ?? {}); - if (entries.length > MAX_INPUT_REQUESTS_PER_ROUND) { - throw new SdkError( - SdkErrorCode.InvalidResult, - `Too many input requests (${entries.length}); server may issue at most ${MAX_INPUT_REQUESTS_PER_ROUND} per round` - ); - } - for (const [key, irq] of entries) { - signal.throwIfAborted(); - if (!MRTR_INPUT_METHODS.has(irq.method)) { - throw new SdkError(SdkErrorCode.InvalidResult, `inputRequests['${key}'].method '${irq.method}' is not allowed`); - } - // Dispatch through the same middleware chain as legacy - // server-to-client requests so _validationMiddleware applies. - const ctx = this.buildContext({ - sessionId: undefined, - mcpReq: { - id: key, - method: irq.method, - signal, - send: (() => { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'send is not available inside MRTR input handlers'); - }) as ClientContext['mcpReq']['send'], - notify: async () => {} - } - }); - const res = await this.dispatcher.dispatch( - { jsonrpc: JSONRPC_VERSION, id: key, method: irq.method, params: irq.params as Record }, - ctx - ); - if ('error' in res) { - throw new ProtocolError(res.error.code, res.error.message, res.error.data); - } - accumulated[key] = res.result; - } - } - throw new SdkError(SdkErrorCode.RequestTimeout, `MRTR exceeded ${MRTR_MAX_ROUNDS} rounds for ${request.method}`); - } finally { - clearTimeout(timeoutHandle); - } + /** @internal Called by `Client._negotiate` after a successful `server/discover`. */ + _setNegotiated(r: { + serverCapabilities: ServerCapabilities; + serverVersion: Implementation; + instructions: string | undefined; + protocolVersion: string; + }): void { + this._serverCapabilities = r.serverCapabilities; + this._serverVersion = r.serverVersion; + this._instructions = r.instructions; + this._negotiatedProtocolVersion = r.protocolVersion; } - /** - * Probes `server/discover` via `transport.sendAndReceive`. On success, - * marks this client stateless and populates server identity/capabilities - * from the result. On {@linkcode isFallbackable} failure, leaves state - * untouched so {@linkcode connect} falls through to the legacy - * `initialize` handshake. - */ - private async _negotiate(transport: Transport, options?: RequestOptions): Promise { - const sar = transport.sendAndReceive?.bind(transport); - const preferred = this._supportedProtocolVersions.find(v => isStatelessProtocolVersion(v)); - if (!sar || !preferred) return; - - transport.setProtocolVersion?.(preferred); - const signal = - options?.timeout === undefined - ? (options?.signal ?? AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MSEC)) - : options?.signal - ? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeout)]) - : AbortSignal.timeout(options.timeout); - try { - const raw = await this._collect(sar({ method: 'server/discover', params: { _meta: this._buildMeta(preferred) } }, { signal }), { - signal - }); - const drParsed = DiscoverResultSchema.safeParse(raw); - if (drParsed.success) { - const dr = drParsed.data; - // The probe only counts as success when there is a mutual - // *stateless* version; otherwise fall through to legacy initialize. - const negotiated = dr.supportedVersions.find( - v => isStatelessProtocolVersion(v) && this._supportedProtocolVersions.includes(v) - ); - if (negotiated) { - this._serverCapabilities = dr.capabilities; - this._serverVersion = dr.serverInfo; - this._instructions = dr.instructions; - this._negotiatedProtocolVersion = negotiated; - this._isStateless = true; - transport.setProtocolVersion?.(negotiated); - return; - } - } - } catch (error) { - if (!isFallbackable(error)) { - // Reset the version we set before re-throwing so the - // transport is not left advertising a stateless version. - transport.setProtocolVersion?.(this._negotiatedProtocolVersion ?? ''); - throw error; - } - } - // Fallback path: reset the version header so the subsequent legacy - // `_initialize()` (run by `connect()`) can set it. - transport.setProtocolVersion?.(this._negotiatedProtocolVersion ?? ''); + /** @internal */ + get _clientCapabilities(): ClientCapabilities { + return this._capabilities; } /** - * Opens a `subscriptions/listen` stream and yields each notification. - * Throws unless this client negotiated stateless mode and the transport - * supports `sendAndReceive`. Breaking out of the loop sends - * `notifications/cancelled`. + * Throws when no session-based transport is connected. Guards the + * pre-2026 connection-model methods so callers get a directed migration + * error instead of `NotConnected`. */ - async *subscribe(filter: SubscriptionFilter, opts?: { signal?: AbortSignal }): AsyncGenerator { - const sar = this.transport?.sendAndReceive?.bind(this.transport); - if (!this._isStateless || !sar) { + private _assertSession(via: string): void { + if (!this.transport) { throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - 'subscribe() requires a stateless protocol version and a transport that supports sendAndReceive' + SdkErrorCode.SessionRequired, + `${this.constructor.name}.${via} requires a connected pre-2026 session. ` + + 'See https://modelcontextprotocol.io/docs/migration#2026-06' ); } - for await (const m of sar( - { method: 'subscriptions/listen', params: this._withMeta({ notifications: filter }) }, - { signal: opts?.signal } - )) { - if (isJSONRPCNotification(m)) { - yield m; - } else if (isJSONRPCErrorResponse(m)) { - throw new ProtocolError(m.error.code, m.error.message, m.error.data); - } - } - } - - private _listChangedAbort?: AbortController; - - /** - * Stateless backing for `options.listChanged`: opens ONE - * `subscriptions/listen` for all configured list-changed kinds and calls - * the matching `onChanged` per notification (debounced by `debounceMs`). - */ - private async _listChangedLoop(kinds: ListChangedKinds): Promise { - const filter: SubscriptionFilter = {}; - const debounced: Record void> = {}; - const timers = new Map>(); - for (const [method, k] of Object.entries(kinds)) { - filter[k.filterKey] = true; - const { autoRefresh, debounceMs } = k; - const refresh = async () => { - if (!autoRefresh) { - k.config.onChanged(null, null); - return; - } - try { - k.config.onChanged(null, await k.fetcher()); - } catch (error) { - k.config.onChanged(error instanceof Error ? error : new Error(String(error)), null); - } - }; - // eslint-disable-next-line unicorn/consistent-function-scoping -- closes over per-iteration `refresh` - const run = () => - void refresh().catch(error => (this.onerror ?? console.error)(error instanceof Error ? error : new Error(String(error)))); - debounced[method] = debounceMs - ? () => { - const t = timers.get(method); - if (t) clearTimeout(t); - timers.set(method, setTimeout(run, debounceMs)); - } - : run; - } - this._listChangedAbort?.abort(); - this._listChangedAbort = new AbortController(); - const { signal } = this._listChangedAbort; - try { - for await (const n of this.subscribe(filter, { signal })) { - debounced[n.method]?.(); - } - // Stream ended without error and without our abort: surface so the - // caller knows list-changed delivery has stopped. - if (!signal.aborted) { - throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended'); - } - } finally { - for (const t of timers.values()) clearTimeout(t); - timers.clear(); - } } // ═══════════════════════════════════════════════════════════════════════ - // dual-mode (SEP-2575/2567) - // - // `connect()` probes `server/discover` then falls back to legacy - // `initialize`. `_setupListChanged()` and `setLoggingLevel()` branch on - // `_isStateless`. Typed request methods (callTool/listTools/etc.) route - // via `_send`, which falls back to `Protocol.request()` when not - // stateless. These methods appear inline below among session-dependent - // code for diff-minimality; the dual-mode set is: connect, close, - // _initialize, _setupListChanged, setLoggingLevel, callTool, listTools, - // getPrompt, listPrompts, readResource, listResources, - // listResourceTemplates, complete. - // ═══════════════════════════════════════════════════════════════════════ - - override async close(): Promise { - this._isStateless = false; - this._listChangedAbort?.abort(); - this._listChangedAbort = undefined; - await super.close(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // session-dependent (existing — bodies unchanged unless noted dual-mode above) + // session-dependent (existing — bodies unchanged) // // `_initialize()` (extracted verbatim from the previous inline `connect()` // body) performs the legacy `initialize` handshake. `ping`, // `subscribeResource`, `unsubscribeResource`, and - // `_setupListChangedHandler*` use the persistent connection; - // `_listChangedLoop` (above) is the 2026 path. + // `_setupListChangedHandler` use the persistent connection; + // `Client._listChangedLoop` is the 2026 path. // ═══════════════════════════════════════════════════════════════════════ - /** - * Set up handlers for list changed notifications based on config and server capabilities. - * This should only be called after initialization when server capabilities are known. - * Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability. - * @internal - */ - private _setupListChangedHandlers(config: ListChangedHandlers): void { - if (config.tools && this._serverCapabilities?.tools?.listChanged) { - 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 () => { - 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 () => { - const result = await this.listResources(); - return result.resources; - }); - } - } - /** * Registers new capabilities. This can only be called before connecting to a transport. * @@ -777,65 +384,11 @@ export class LegacyClient extends Protocol { } /** - * Connects to a server via the given transport and performs the MCP initialization handshake. - * - * @example Basic usage (stdio) - * ```ts source="./client.examples.ts#Client_connect_stdio" - * const client = new Client({ name: 'my-client', version: '1.0.0' }); - * const transport = new StdioClientTransport({ command: 'my-mcp-server' }); - * await client.connect(transport); - * ``` - * - * @example Streamable HTTP with SSE fallback - * ```ts source="./client.examples.ts#Client_connect_sseFallback" - * const baseUrl = new URL(url); - * - * try { - * // Try modern Streamable HTTP transport first - * const client = new Client({ name: 'my-client', version: '1.0.0' }); - * const transport = new StreamableHTTPClientTransport(baseUrl); - * await client.connect(transport); - * return { client, transport }; - * } catch { - * // Fall back to legacy SSE transport - * const client = new Client({ name: 'my-client', version: '1.0.0' }); - * const transport = new SSEClientTransport(baseUrl); - * await client.connect(transport); - * return { client, transport }; - * } - * ``` - */ - override async connect(transport: Transport, options?: RequestOptions): Promise { - await super.connect(transport); - // When transport sessionId is already set this means we are trying to reconnect. - // Restore the protocol version negotiated during the original initialize handshake - // so HTTP transports include the required mcp-protocol-version header, but skip re-init. - if (transport.sessionId !== undefined) { - if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { - transport.setProtocolVersion(this._negotiatedProtocolVersion); - } - return; - } - try { - // Probe `server/discover` (SEP-2575). If it succeeds, this client is - // stateless and the legacy `initialize` is skipped. - await this._negotiate(transport, options); - if (!this._isStateless) { - await this._initialize(transport, options); - } - this._setupListChanged(); - } catch (error) { - // Disconnect if initialization fails. - void this.close(); - throw error; - } - } - - /** - * Legacy `initialize` handshake; called from {@linkcode connect} when the - * 2026-06 discover probe is unavailable or falls back. + * Legacy `initialize` handshake; called from `Client.connect` when + * the 2026-06 discover probe is unavailable or falls back. + * @internal */ - private async _initialize(transport: Transport, options?: RequestOptions): Promise { + async _initialize(transport: Transport, options?: RequestOptions): Promise { const result = await this._requestWithSchema( { method: 'initialize', @@ -872,48 +425,6 @@ export class LegacyClient extends Protocol { }); } - /** - * Wires `options.listChanged` after capabilities are known. Stateless - * connections use `subscriptions/listen` ({@linkcode _listChangedLoop}); - * legacy connections register notification handlers. - */ - private _setupListChanged(): void { - const config = this._pendingListChangedConfig; - if (!config) return; - if (!this._isStateless) { - this._pendingListChangedConfig = undefined; - this._setupListChangedHandlers(config); - return; - } - const kinds: ListChangedKinds = {}; - const add = ( - method: string, - filterKey: Exclude, - cfg: ListChangedOptions, - fetcher: () => Promise - ): void => { - const parsed = parseSchema(ListChangedOptionsBaseSchema, cfg); - if (!parsed.success) throw new Error(`Invalid ${String(filterKey)} listChanged options: ${parsed.error.message}`); - kinds[method] = { filterKey, config: cfg as ListChangedOptions, fetcher, ...parsed.data }; - }; - if (config.tools && this._serverCapabilities?.tools?.listChanged) { - add('notifications/tools/list_changed', 'toolsListChanged', config.tools, () => this.listTools().then(r => r.tools)); - } - if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { - add('notifications/prompts/list_changed', 'promptsListChanged', config.prompts, () => this.listPrompts().then(r => r.prompts)); - } - if (config.resources && this._serverCapabilities?.resources?.listChanged) { - add('notifications/resources/list_changed', 'resourcesListChanged', config.resources, () => - this.listResources().then(r => r.resources) - ); - } - if (Object.keys(kinds).length > 0) { - this._listChangedLoop(kinds).catch(error => - (this.onerror ?? console.error)(error instanceof Error ? error : new Error(String(error))) - ); - } - } - /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -1079,118 +590,17 @@ export class LegacyClient extends Protocol { * @deprecated `ping` is removed in the 2026-06 protocol. This method requires a pre-2026 connection. */ async ping(options?: RequestOptions) { + this._assertSession('ping'); return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); } - /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ - async complete(params: CompleteRequest['params'], options?: RequestOptions) { - return this._send({ method: 'completion/complete', params }, CompleteResultSchema, options); - } - - /** - * Sets the minimum severity level for log messages sent by the server. - * Stored locally for per-request `_meta.logLevel`; when not stateless, - * also sends the legacy `logging/setLevel` RPC. - */ - async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { - this._logLevel = level; - if (this._isStateless) return {}; - return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); - } - - /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ - async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { - return this._send({ method: 'prompts/get', params }, GetPromptResultSchema, options); - } - - /** - * Lists available prompts. Results may be paginated — loop on `nextCursor` to collect all pages. - * - * Returns an empty list if the server does not advertise prompts capability - * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). - * - * @example - * ```ts source="./client.examples.ts#Client_listPrompts_pagination" - * const allPrompts: Prompt[] = []; - * let cursor: string | undefined; - * do { - * const { prompts, nextCursor } = await client.listPrompts({ cursor }); - * allPrompts.push(...prompts); - * cursor = nextCursor; - * } while (cursor); - * console.log( - * 'Available prompts:', - * allPrompts.map(p => p.name) - * ); - * ``` - */ - async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) { - // Respect capability negotiation: server does not support prompts - console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); - return { prompts: [] }; - } - return this._send({ method: 'prompts/list', params }, ListPromptsResultSchema, options); - } - - /** - * Lists available resources. Results may be paginated — loop on `nextCursor` to collect all pages. - * - * Returns an empty list if the server does not advertise resources capability - * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). - * - * @example - * ```ts source="./client.examples.ts#Client_listResources_pagination" - * const allResources: Resource[] = []; - * let cursor: string | undefined; - * do { - * const { resources, nextCursor } = await client.listResources({ cursor }); - * allResources.push(...resources); - * cursor = nextCursor; - * } while (cursor); - * console.log( - * 'Available resources:', - * allResources.map(r => r.name) - * ); - * ``` - */ - async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { - // Respect capability negotiation: server does not support resources - console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); - return { resources: [] }; - } - return this._send({ method: 'resources/list', params }, ListResourcesResultSchema, options); - } - - /** - * Lists available resource URI templates for dynamic resources. Results may be paginated — see {@linkcode listResources | listResources()} for the cursor pattern. - * - * Returns an empty list if the server does not advertise resources capability - * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). - */ - async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { - // Respect capability negotiation: server does not support resources - console.debug( - 'Client.listResourceTemplates() called but server does not advertise resources capability - returning empty list' - ); - return { resourceTemplates: [] }; - } - return this._send({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); - } - - /** Reads the contents of a resource by URI. */ - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this._send({ method: 'resources/read', params }, ReadResourceResultSchema, options); - } - /** * Subscribes to change notifications for a resource. The server must support resource subscriptions. * * @deprecated Use `client.subscribe({ resourceSubscriptions: [uri] })` when connected to a 2026-06 server. This RPC form requires a pre-2026 connection. */ async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { + this._assertSession('subscribeResource (use client.subscribe)'); return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); } @@ -1200,150 +610,15 @@ export class LegacyClient extends Protocol { * @deprecated Use `client.subscribe()` and break out of the loop / abort its signal to stop, when connected to a 2026-06 server. This RPC form requires a pre-2026 connection. */ async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { + this._assertSession('unsubscribeResource (use client.subscribe)'); return this._requestWithSchema({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); } - /** - * Calls a tool on the connected server and returns the result. Automatically validates structured output - * if the tool has an `outputSchema`. - * - * Tool results have two error surfaces: `result.isError` for tool-level failures (the tool ran but reported - * a problem), and thrown {@linkcode ProtocolError} for protocol-level failures or {@linkcode SdkError} for - * SDK-level issues (timeouts, missing capabilities). - * - * @example Basic usage - * ```ts source="./client.examples.ts#Client_callTool_basic" - * const result = await client.callTool({ - * name: 'calculate-bmi', - * arguments: { weightKg: 70, heightM: 1.75 } - * }); - * - * // Tool-level errors are returned in the result, not thrown - * if (result.isError) { - * console.error('Tool error:', result.content); - * return; - * } - * - * console.log(result.content); - * ``` - * - * @example Structured output - * ```ts source="./client.examples.ts#Client_callTool_structuredOutput" - * const result = await client.callTool({ - * name: 'calculate-bmi', - * arguments: { weightKg: 70, heightM: 1.75 } - * }); - * - * // Machine-readable output for the client application - * if (result.structuredContent) { - * console.log(result.structuredContent); // e.g. { bmi: 22.86 } - * } - * ``` - */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { - const result = await this._send({ method: 'tools/call', params }, CallToolResultSchema, options); - - // Check if the tool has an outputSchema - const validator = this.getToolOutputValidator(params.name); - if (validator) { - // If tool has outputSchema, it MUST return structuredContent (unless it's an error) - if (!result.structuredContent && !result.isError) { - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Tool ${params.name} has an output schema but did not return structured content` - ); - } - - // Only validate structured content if present (not when there's an error) - if (result.structuredContent) { - try { - // Validate the structured content against the schema - const validationResult = validator(result.structuredContent); - - if (!validationResult.valid) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` - ); - } - } catch (error) { - if (error instanceof ProtocolError) { - throw error; - } - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } - - return result; - } - - /** - * Cache validators for tool output schemas. - * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. - */ - private cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); - - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { - const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); - this._cachedToolOutputValidators.set(tool.name, toolValidator); - } - } - } - - /** - * Get cached validator for a tool - */ - private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { - return this._cachedToolOutputValidators.get(toolName); - } - - /** - * Lists available tools. Results may be paginated — loop on `nextCursor` to collect all pages. - * - * Returns an empty list if the server does not advertise tools capability - * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). - * - * @example - * ```ts source="./client.examples.ts#Client_listTools_pagination" - * const allTools: Tool[] = []; - * let cursor: string | undefined; - * do { - * const { tools, nextCursor } = await client.listTools({ cursor }); - * allTools.push(...tools); - * cursor = nextCursor; - * } while (cursor); - * console.log( - * 'Available tools:', - * allTools.map(t => t.name) - * ); - * ``` - */ - async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) { - // Respect capability negotiation: server does not support tools - console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); - return { tools: [] }; - } - const result = await this._send({ method: 'tools/list', params }, ListToolsResultSchema, options); - - // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); - - return result; - } - /** * Set up a single list changed handler. * @internal */ - private _setupListChangedHandler( + _setupListChangedHandler( listType: string, notificationMethod: NotificationMethod, options: ListChangedOptions, @@ -1405,6 +680,7 @@ export class LegacyClient extends Protocol { * @deprecated Under the 2026-06 protocol the server polls roots via MRTR; there is no client-to-server notification path. This form requires a pre-2026 connection. */ async sendRootsListChanged() { + this._assertSession('sendRootsListChanged'); return this.notification({ method: 'notifications/roots/list_changed' }); } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index cd325db2e1..057350f76e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,10 +52,11 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { ClientOptions } from './client/legacyClient.js'; -export { getSupportedElicitationModes, LegacyClient, LegacyClient as Client } from './client/legacyClient.js'; +export type { ClientOptions } from './client/client.js'; +export { Client, getSupportedElicitationModes } from './client/client.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess.js'; +export { LegacyClient } from './client/legacyClient.js'; export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware.js'; export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; export type { SSEClientTransportOptions } from './client/sse.js'; diff --git a/packages/client/test/client/clientSend.test.ts b/packages/client/test/client/clientSend.test.ts index f052b72dba..4d56ba3063 100644 --- a/packages/client/test/client/clientSend.test.ts +++ b/packages/client/test/client/clientSend.test.ts @@ -2,7 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextpro import { DRAFT_PROTOCOL_VERSION, JSONRPC_VERSION, META_KEYS, SdkError } from '@modelcontextprotocol/core'; import { describe, expect, it } from 'vitest'; -import { LegacyClient as Client } from '../../src/client/legacyClient.js'; +import { Client } from '../../src/client/client.js'; /** Minimal transport with a scriptable sendAndReceive. */ function mockTransport(handler: (req: JSONRPCRequest) => AsyncIterable): Transport { From dfae0de750572e20810e6eb3d048aed3e6c12895 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 20 May 2026 17:22:18 +0000 Subject: [PATCH 5/5] test: adapt tests to .legacy getter for session-dependent methods Integration tests that exercise the pre-2026 session-dependent surface (server.createMessage / elicitInput / listRoots / sendLoggingMessage / ping / getClientCapabilities / getClientVersion / oninitialized / createElicitationCompletionNotifier / notification / request / setNotificationHandler; client.sendRootsListChanged / ping / subscribeResource / unsubscribeResource / request / notification) now go through `.legacy`. LegacyTestClient unchanged: it extends NEW Client and pins versions, so `connect()` skips the discover probe exactly as before. clientSend statelessClient() helper updated to set `_transport` / negotiated state on `c.legacy` (private state moved there). --- .../client/test/client/clientSend.test.ts | 4 +- test/conformance/src/everythingServerSetup.ts | 2 +- test/integration/test/client/client.test.ts | 36 ++-- .../test1277.zod.v4.description.test.ts | 2 +- .../test400.optional-tool-params.test.ts | 2 +- test/integration/test/server.test.ts | 94 +++++----- .../test/server/elicitation.test.ts | 58 +++--- test/integration/test/server/mcp.test.ts | 168 +++++++++--------- test/integration/test/standardSchema.test.ts | 71 ++++---- .../stateManagementStreamableHttp.test.ts | 20 +-- .../test/statelessAcceptance.test.ts | 6 +- .../integration/test/taskResumability.test.ts | 2 +- 12 files changed, 237 insertions(+), 228 deletions(-) diff --git a/packages/client/test/client/clientSend.test.ts b/packages/client/test/client/clientSend.test.ts index 4d56ba3063..40cf7a5fb3 100644 --- a/packages/client/test/client/clientSend.test.ts +++ b/packages/client/test/client/clientSend.test.ts @@ -17,8 +17,8 @@ function mockTransport(handler: (req: JSONRPCRequest) => AsyncIterable { // Ignore error if no client is connected. diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index a053c44c16..3f24321e28 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -577,7 +577,7 @@ test('should respect client notification capabilities', async () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // This should work because the client has the roots.listChanged capability - await expect(client.sendRootsListChanged()).resolves.not.toThrow(); + await expect(client.legacy.sendRootsListChanged()).resolves.not.toThrow(); // Create a new client without the roots.listChanged capability const clientWithoutCapability = new LegacyTestClient( @@ -594,7 +594,7 @@ test('should respect client notification capabilities', async () => { await clientWithoutCapability.connect(clientTransport); // This should throw because the client doesn't have the roots.listChanged capability - await expect(clientWithoutCapability.sendRootsListChanged()).rejects.toThrow(/^Client does not support/); + await expect(clientWithoutCapability.legacy.sendRootsListChanged()).rejects.toThrow(/^Client does not support/); }); /*** @@ -631,7 +631,7 @@ test('should respect server notification capabilities', async () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // These should work because the server has the corresponding capabilities - await expect(server.sendLoggingMessage({ level: 'info', data: 'Test' })).resolves.not.toThrow(); + await expect(server.legacy.sendLoggingMessage({ level: 'info', data: 'Test' })).resolves.not.toThrow(); await expect(server.sendResourceListChanged()).resolves.not.toThrow(); // This should throw because the server doesn't have the tools capability @@ -756,7 +756,7 @@ test('should accept form-mode elicitation request when client advertises empty e // Server should be able to send form-mode elicitation request // This works because getSupportedElicitationModes defaults to form mode // when neither form nor url are explicitly declared - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Please provide your username', requestedSchema: { @@ -906,7 +906,7 @@ test('should reject missing-mode elicitation when client only supports URL mode' await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.request({ + server.legacy.request({ method: 'elicitation/create', params: { message: 'Please provide data', @@ -1052,7 +1052,7 @@ test('should apply defaults for form-mode elicitation when applyDefaults is enab await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Please confirm your preferences', requestedSchema: { @@ -1542,7 +1542,7 @@ test('should not activate listChanged handler when server does not advertise cap expect(client.getServerCapabilities()?.tools?.listChanged).toBeFalsy(); // Send a tool list changed notification manually - await server.notification({ method: 'notifications/tools/list_changed' }); + await server.legacy.notification({ method: 'notifications/tools/list_changed' }); await new Promise(resolve => setTimeout(resolve, 100)); // Handler should NOT have been activated because server didn't advertise listChanged @@ -1591,7 +1591,7 @@ test('should activate listChanged handler when server advertises capability', as expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true); // Send a tool list changed notification - await server.notification({ method: 'notifications/tools/list_changed' }); + await server.legacy.notification({ method: 'notifications/tools/list_changed' }); await new Promise(resolve => setTimeout(resolve, 100)); // Handler SHOULD have been called @@ -1649,9 +1649,9 @@ test('should not activate any handlers when server has no listChanged capabiliti expect(caps?.resources?.listChanged).toBeFalsy(); // Send notifications for all three types - await server.notification({ method: 'notifications/tools/list_changed' }); - await server.notification({ method: 'notifications/prompts/list_changed' }); - await server.notification({ method: 'notifications/resources/list_changed' }); + await server.legacy.notification({ method: 'notifications/tools/list_changed' }); + await server.legacy.notification({ method: 'notifications/prompts/list_changed' }); + await server.legacy.notification({ method: 'notifications/resources/list_changed' }); await new Promise(resolve => setTimeout(resolve, 100)); // No handlers should have been activated @@ -1709,8 +1709,8 @@ test('should handle partial listChanged capability support', async () => { expect(client.getServerCapabilities()?.prompts?.listChanged).toBeFalsy(); // Send notifications for both - await server.notification({ method: 'notifications/tools/list_changed' }); - await server.notification({ method: 'notifications/prompts/list_changed' }); + await server.legacy.notification({ method: 'notifications/tools/list_changed' }); + await server.legacy.notification({ method: 'notifications/prompts/list_changed' }); await new Promise(resolve => setTimeout(resolve, 100)); // Tools handler should have been called @@ -2350,7 +2350,7 @@ describe('Client sampling validation with tools', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.createMessage({ + const result = await server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100, tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] @@ -2376,7 +2376,7 @@ describe('Client sampling validation with tools', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.createMessage({ + const result = await server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100, tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] @@ -2400,7 +2400,7 @@ describe('Client sampling validation with tools', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.createMessage({ + const result = await server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100 }); @@ -2424,7 +2424,7 @@ describe('Client sampling validation with tools', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100 }) @@ -2447,7 +2447,7 @@ describe('Client sampling validation with tools', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.createMessage({ + const result = await server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100, tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }], diff --git a/test/integration/test/issues/test1277.zod.v4.description.test.ts b/test/integration/test/issues/test1277.zod.v4.description.test.ts index a8a8d0c7be..b4a67ffa99 100644 --- a/test/integration/test/issues/test1277.zod.v4.description.test.ts +++ b/test/integration/test/issues/test1277.zod.v4.description.test.ts @@ -47,7 +47,7 @@ describe('Issue #1277: Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'prompts/list' }); diff --git a/test/integration/test/issues/test400.optional-tool-params.test.ts b/test/integration/test/issues/test400.optional-tool-params.test.ts index b71d85b819..9bc6bc9868 100644 --- a/test/integration/test/issues/test400.optional-tool-params.test.ts +++ b/test/integration/test/issues/test400.optional-tool-params.test.ts @@ -45,7 +45,7 @@ describe('Issue #400: Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); // Call tool without arguments (arguments is undefined) - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'optional-params-tool' diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index de0f5a8470..9749a2b819 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -51,7 +51,7 @@ describe('Server with standard protocol methods', () => { return {}; }); - server.setNotificationHandler('notifications/initialized', () => { + server.legacy.setNotificationHandler('notifications/initialized', () => { console.log('Client initialized'); }); }); @@ -309,18 +309,18 @@ test('should respect client capabilities', async () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - expect(server.getClientCapabilities()).toEqual({ sampling: {} }); + expect(server.legacy.getClientCapabilities()).toEqual({ sampling: {} }); // This should work because sampling is supported by the client await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [], maxTokens: 10 }) ).resolves.not.toThrow(); // This should still throw because roots are not supported by the client - await expect(server.listRoots()).rejects.toThrow(/Client does not support/); + await expect(server.legacy.listRoots()).rejects.toThrow(/Client does not support/); }); test('should respect client elicitation capabilities', async () => { @@ -365,11 +365,11 @@ test('should respect client elicitation capabilities', async () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // After schema parsing, empty elicitation object should have form capability injected - expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + expect(server.legacy.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); // This should work because elicitation is supported by the client await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your username', requestedSchema: { @@ -400,7 +400,7 @@ test('should respect client elicitation capabilities', async () => { // This should still throw because sampling is not supported by the client await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [], maxTokens: 10 }) @@ -449,11 +449,11 @@ test('should use elicitInput with mode: "form" by default for backwards compatib await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // After schema parsing, empty elicitation object should have form capability injected - expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + expect(server.legacy.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); // This should work because elicitation is supported by the client await expect( - server.elicitInput({ + server.legacy.elicitInput({ message: 'Please provide your username', requestedSchema: { type: 'object', @@ -483,7 +483,7 @@ test('should use elicitInput with mode: "form" by default for backwards compatib // This should still throw because sampling is not supported by the client await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [], maxTokens: 10 }) @@ -524,7 +524,7 @@ test('should throw when elicitInput is called without client form capability', a await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your username', requestedSchema: { @@ -573,7 +573,7 @@ test('should throw when elicitInput is called without client URL capability', as await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'url', message: 'Open the authorization URL', elicitationId: 'elicitation-001', @@ -623,7 +623,7 @@ test('should include form mode when sending elicitation form requests', async () await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.elicitInput({ + server.legacy.elicitInput({ message: 'Confirm action', requestedSchema: { type: 'object', @@ -687,7 +687,7 @@ test('should include url mode when sending elicitation URL requests', async () = await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'url', message: 'Complete verification', elicitationId: 'elicitation-xyz', @@ -738,7 +738,7 @@ test('should reject elicitInput when client response violates requested schema', await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.elicitInput({ + server.legacy.elicitInput({ message: 'Please provide your username', requestedSchema: { type: 'object', @@ -797,7 +797,7 @@ test('should wrap unexpected validator errors during elicitInput', async () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Provide any data', requestedSchema: { @@ -840,9 +840,9 @@ test('should forward notification options when using elicitation completion noti await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const notificationSpy = vi.spyOn(server, 'notification'); + const notificationSpy = vi.spyOn(server.legacy, 'notification'); - const notifier = server.createElicitationCompletionNotifier('elicitation-789', { relatedRequestId: 42 }); + const notifier = server.legacy.createElicitationCompletionNotifier('elicitation-789', { relatedRequestId: 42 }); await notifier(); expect(notificationSpy).toHaveBeenCalledWith( @@ -890,7 +890,7 @@ test('should create notifier that emits elicitation completion notification', as await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const notifier = server.createElicitationCompletionNotifier('elicitation-123'); + const notifier = server.legacy.createElicitationCompletionNotifier('elicitation-123'); await notifier(); await new Promise(resolve => setTimeout(resolve, 0)); @@ -927,7 +927,7 @@ test('should throw when creating notifier if client lacks URL elicitation suppor await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - expect(() => server.createElicitationCompletionNotifier('elicitation-123')).toThrow( + expect(() => server.legacy.createElicitationCompletionNotifier('elicitation-123')).toThrow( 'Client does not support URL elicitation (required for notifications/elicitation/complete)' ); }); @@ -965,7 +965,7 @@ test('should apply back-compat form capability injection when client sends empty await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Verify that the schema preprocessing injected form capability - const clientCapabilities = server.getClientCapabilities(); + const clientCapabilities = server.legacy.getClientCapabilities(); expect(clientCapabilities).toBeDefined(); expect(clientCapabilities?.elicitation).toBeDefined(); expect(clientCapabilities?.elicitation?.form).toBeDefined(); @@ -1010,7 +1010,7 @@ test('should preserve form capability configuration when client enables applyDef await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Verify that the schema preprocessing preserved the form capability configuration - const clientCapabilities = server.getClientCapabilities(); + const clientCapabilities = server.legacy.getClientCapabilities(); expect(clientCapabilities).toBeDefined(); expect(clientCapabilities?.elicitation).toBeDefined(); expect(clientCapabilities?.elicitation?.form).toBeDefined(); @@ -1063,7 +1063,7 @@ test('should validate elicitation response against requested schema', async () = // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -1140,7 +1140,7 @@ test('should reject elicitation response with invalid data', async () => { // Test with invalid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -1215,7 +1215,7 @@ test('should allow elicitation reject and cancel without validation', async () = // Test reject - should not validate await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your name', requestedSchema: schema @@ -1226,7 +1226,7 @@ test('should allow elicitation reject and cancel without validation', async () = // Test cancel - should not validate await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your name', requestedSchema: schema @@ -1256,7 +1256,7 @@ test('should respect server notification capabilities', async () => { // This should work because logging is supported by the server await expect( - server.sendLoggingMessage({ + server.legacy.sendLoggingMessage({ level: 'info', data: 'Test log message' }) @@ -1345,7 +1345,7 @@ test('should handle server cancelling a request', async () => { const controller = new AbortController(); // Issue request but cancel it immediately - const createMessagePromise = server.createMessage( + const createMessagePromise = server.legacy.createMessage( { messages: [], maxTokens: 10 @@ -1409,7 +1409,7 @@ test('should handle request timeout', async () => { // Request with 0 msec timeout should fail immediately await expect( - server.createMessage( + server.legacy.createMessage( { messages: [], maxTokens: 10 @@ -1479,11 +1479,11 @@ test('should respect log level for transport without sessionId', async () => { }); // This one will not make it through - await server.sendLoggingMessage(debugParams); + await server.legacy.sendLoggingMessage(debugParams); expect(clientTransport.onmessage).not.toHaveBeenCalled(); // This one will, triggering the above test in clientTransport.onmessage - await server.sendLoggingMessage(warningParams); + await server.legacy.sendLoggingMessage(warningParams); expect(clientTransport.onmessage).toHaveBeenCalled(); }); @@ -1506,7 +1506,7 @@ describe('createMessage validation', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100, tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] @@ -1532,7 +1532,7 @@ describe('createMessage validation', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100, toolChoice: { mode: 'auto' } @@ -1555,7 +1555,7 @@ describe('createMessage validation', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, @@ -1589,7 +1589,7 @@ describe('createMessage validation', () => { // tool_result without previous tool_use await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } @@ -1615,7 +1615,7 @@ describe('createMessage validation', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, @@ -1642,7 +1642,7 @@ describe('createMessage validation', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100, tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] @@ -1665,7 +1665,7 @@ describe('createMessage validation', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, @@ -1693,7 +1693,7 @@ describe('createMessage validation', () => { // User ignores tool_use and sends text instead await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, @@ -1721,7 +1721,7 @@ describe('createMessage validation', () => { // Parallel tool_use but only one tool_result provided await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { @@ -1758,7 +1758,7 @@ describe('createMessage validation', () => { // Previous request returned tool_use, now sending tool_result without tools param await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, @@ -1786,7 +1786,7 @@ describe('createMessage validation', () => { // Previous request returned tool_use, now sending matching tool_result without tools param await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: 'hello' } }, { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, @@ -1814,7 +1814,7 @@ describe('createMessage validation', () => { // Empty messages array should not crash await expect( - server.createMessage({ + server.legacy.createMessage({ messages: [], maxTokens: 100 }) @@ -1845,7 +1845,7 @@ describe('createMessage backwards compatibility', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Call createMessage WITHOUT tools - const result = await server.createMessage({ + const result = await server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100 }); @@ -1876,7 +1876,7 @@ describe('createMessage backwards compatibility', () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); // Call createMessage WITH tools - verifies the overload works - const result = await server.createMessage({ + const result = await server.legacy.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], maxTokens: 100, tools: [{ name: 'get_weather', inputSchema: { type: 'object' } }] @@ -1950,11 +1950,11 @@ test('should respect log level for transport with sessionId', async () => { }); // This one will not make it through - await server.sendLoggingMessage(debugParams, SESSION_ID); + await server.legacy.sendLoggingMessage(debugParams, SESSION_ID); expect(clientTransport.onmessage).not.toHaveBeenCalled(); // This one will, triggering the above test in clientTransport.onmessage - await server.sendLoggingMessage(warningParams, SESSION_ID); + await server.legacy.sendLoggingMessage(warningParams, SESSION_ID); expect(clientTransport.onmessage).toHaveBeenCalled(); }); diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts index 67c263ea91..c421d37b9f 100644 --- a/test/integration/test/server/elicitation.test.ts +++ b/test/integration/test/server/elicitation.test.ts @@ -70,7 +70,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { name: 'John Doe' } })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'What is your name?', requestedSchema: { @@ -94,7 +94,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { age: 42 } })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'What is your age?', requestedSchema: { @@ -118,7 +118,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { agree: true } })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Do you agree?', requestedSchema: { @@ -172,7 +172,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo required: ['name', 'email', 'age', 'street', 'city', 'zipCode'] } }; - const result = await server.elicitInput(formRequestParams); + const result = await server.legacy.elicitInput(formRequestParams); expect(result).toEqual({ action: 'accept', @@ -190,7 +190,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -215,7 +215,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -237,7 +237,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); await expect( - server.elicitInput({ + server.legacy.elicitInput({ message: 'What is your name?', requestedSchema: { type: 'object', @@ -257,7 +257,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'What is your age?', requestedSchema: { @@ -290,7 +290,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo } }; - await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/); + await expect(server.legacy.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/); }); test(`${validatorName}: should allow decline action without validation`, async () => { @@ -298,7 +298,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo action: 'decline' })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -320,7 +320,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo action: 'cancel' })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -351,7 +351,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo return { action: 'decline' }; }); - const nameResult = await server.elicitInput({ + const nameResult = await server.legacy.elicitInput({ mode: 'form', message: 'What is your name?', requestedSchema: { @@ -361,7 +361,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo } }); - const ageResult = await server.elicitInput({ + const ageResult = await server.legacy.elicitInput({ message: 'What is your age?', requestedSchema: { type: 'object', @@ -370,7 +370,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo } }); - const cityResult = await server.elicitInput({ + const cityResult = await server.legacy.elicitInput({ message: 'What is your city?', requestedSchema: { type: 'object', @@ -397,7 +397,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { name: 'John', nickname: 'Johnny' } })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Enter your name', requestedSchema: { @@ -422,7 +422,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { name: 'John' } })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Enter your name', requestedSchema: { @@ -447,7 +447,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { email: 'user@example.com' } })); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Enter your email', requestedSchema: { @@ -571,7 +571,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ + const result = await server.legacy.elicitInput({ mode: 'form', message: 'Provide your preferences', requestedSchema: testSchemaProperties @@ -601,7 +601,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Enter your email', requestedSchema: { @@ -628,7 +628,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -664,7 +664,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -704,7 +704,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ message: 'Please provide your information', requestedSchema: { type: 'object', @@ -742,7 +742,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -782,7 +782,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -827,7 +827,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -858,7 +858,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ message: 'Please provide your information', requestedSchema: { type: 'object', @@ -892,7 +892,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ message: 'Please provide your information', requestedSchema: { type: 'object', @@ -925,7 +925,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { @@ -960,7 +960,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( - server.elicitInput({ + server.legacy.elicitInput({ mode: 'form', message: 'Please provide your information', requestedSchema: { diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 4157bcda07..dc55345a2a 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -48,7 +48,7 @@ describe('Zod v4', () => { // This should work because we're using the underlying server await expect( - mcpServer.server.sendLoggingMessage({ + mcpServer.server.legacy.sendLoggingMessage({ level: 'info', data: 'Test log message' }) @@ -281,7 +281,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); // Call the tool with progress tracking - await client.request( + await client.legacy.request( { method: 'tools/call', params: { @@ -367,7 +367,7 @@ describe('Zod v4', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const capabilities = mcpServer.server.getClientCapabilities(); + const capabilities = mcpServer.server.legacy.getClientCapabilities(); expect(capabilities?.extensions).toBeDefined(); expect(capabilities?.extensions?.['io.modelcontextprotocol/test-extension']).toEqual({ streaming: true }); }); @@ -457,7 +457,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/list' }); @@ -535,7 +535,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Call the tool and verify we get the updated response - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'test' @@ -609,7 +609,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Verify the schema was updated - const listResult = await client.request({ + const listResult = await client.legacy.request({ method: 'tools/list' }); @@ -621,7 +621,7 @@ describe('Zod v4', () => { }); // Call the tool with the new schema - const callResult = await client.request({ + const callResult = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -696,7 +696,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Verify the outputSchema was updated - const listResult = await client.request({ + const listResult = await client.legacy.request({ method: 'tools/list' }); @@ -709,7 +709,7 @@ describe('Zod v4', () => { }); // Call the tool to verify it works with the updated outputSchema - const callResult = await client.request({ + const callResult = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -905,7 +905,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/list' }); @@ -962,7 +962,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/list' }); @@ -1005,7 +1005,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/list' }); @@ -1045,7 +1045,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); expect(result.tools[0]!.name).toBe('test'); @@ -1092,7 +1092,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); expect(result.tools[0]!.name).toBe('test'); @@ -1140,7 +1140,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); expect(result.tools[0]!.name).toBe('test'); @@ -1191,7 +1191,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -1311,7 +1311,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); // Verify the tool registration includes outputSchema - const listResult = await client.request({ + const listResult = await client.legacy.request({ method: 'tools/list' }); @@ -1327,7 +1327,7 @@ describe('Zod v4', () => { }); // Call the tool and verify it returns valid structuredContent - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -1581,7 +1581,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await client.request({ + await client.legacy.request({ method: 'tools/call', params: { name: 'test-tool' @@ -1622,7 +1622,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'request-id-test' @@ -1682,7 +1682,7 @@ describe('Zod v4', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await client.request({ + await client.legacy.request({ method: 'tools/call', params: { name: 'test-tool' @@ -1727,7 +1727,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -1767,7 +1767,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'error-test' @@ -1811,7 +1811,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); await expect( - client.request({ + client.legacy.request({ method: 'tools/call', params: { name: 'nonexistent-tool' @@ -1853,7 +1853,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); await expect( - client.request({ + client.legacy.request({ method: 'tools/call', params: { name: 'test-tool' @@ -1955,7 +1955,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); expect(result.tools[0]!.name).toBe('test-with-meta'); @@ -1991,7 +1991,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); expect(result.tools[0]!.name).toBe('test-without-meta'); @@ -2076,7 +2076,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -2129,7 +2129,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Read the resource and verify we get the updated content - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/read', params: { uri: 'test://resource' @@ -2199,7 +2199,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Read the resource and verify we get the updated content - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/read', params: { uri: 'test://resource/123' @@ -2302,7 +2302,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Verify both resources are registered - let result = await client.request({ method: 'resources/list' }); + let result = await client.legacy.request({ method: 'resources/list' }); expect(result.resources).toHaveLength(2); @@ -2318,7 +2318,7 @@ describe('Zod v4', () => { expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); // Verify the resource was removed - result = await client.request({ method: 'resources/list' }); + result = await client.legacy.request({ method: 'resources/list' }); expect(result.resources).toHaveLength(1); expect(result.resources[0]!.uri).toBe('test://resource2'); @@ -2361,7 +2361,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Verify template is registered - const result = await client.request({ method: 'resources/templates/list' }); + const result = await client.legacy.request({ method: 'resources/templates/list' }); expect(result.resourceTemplates).toHaveLength(1); expect(notifications).toHaveLength(0); @@ -2376,7 +2376,7 @@ describe('Zod v4', () => { expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); // Verify the template was removed - const result2 = await client.request({ method: 'resources/templates/list' }); + const result2 = await client.legacy.request({ method: 'resources/templates/list' }); expect(result2.resourceTemplates).toHaveLength(0); }); @@ -2421,7 +2421,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -2461,7 +2461,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/templates/list' }); @@ -2514,7 +2514,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -2558,7 +2558,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/read', params: { uri: 'test://resource/books/123' @@ -2687,7 +2687,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); await expect( - client.request({ + client.legacy.request({ method: 'resources/read', params: { uri: 'test://error' @@ -2723,7 +2723,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); await expect( - client.request({ + client.legacy.request({ method: 'resources/read', params: { uri: 'test://nonexistent' @@ -2764,7 +2764,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); await expect( - client.request({ + client.legacy.request({ method: 'resources/read', params: { uri: 'test://resource' @@ -2888,7 +2888,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -2943,7 +2943,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -2992,7 +2992,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/read', params: { uri: 'test://resource' @@ -3042,7 +3042,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'prompts/list' }); @@ -3100,7 +3100,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Call the prompt and verify we get the updated response - const result = await client.request({ + const result = await client.legacy.request({ method: 'prompts/get', params: { name: 'test' @@ -3186,7 +3186,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Verify the schema was updated - const listResult = await client.request({ + const listResult = await client.legacy.request({ method: 'prompts/list' }); @@ -3194,7 +3194,7 @@ describe('Zod v4', () => { expect(listResult.prompts[0]!.arguments!.map(a => a.name).toSorted()).toEqual(['name', 'value']); // Call the prompt with the new schema - const getResult = await client.request({ + const getResult = await client.legacy.request({ method: 'prompts/get', params: { name: 'test', @@ -3326,7 +3326,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); // Verify both prompts are registered - let result = await client.request({ method: 'prompts/list' }); + let result = await client.legacy.request({ method: 'prompts/list' }); expect(result.prompts).toHaveLength(2); expect(result.prompts.map(p => p.name).toSorted()).toEqual(['prompt1', 'prompt2']); @@ -3343,7 +3343,7 @@ describe('Zod v4', () => { expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); // Verify the prompt was removed - result = await client.request({ method: 'prompts/list' }); + result = await client.legacy.request({ method: 'prompts/list' }); expect(result.prompts).toHaveLength(1); expect(result.prompts[0]!.name).toBe('prompt2'); @@ -3387,7 +3387,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'prompts/list' }); @@ -3428,7 +3428,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'prompts/list' }); @@ -3477,7 +3477,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); await expect( - client.request({ + client.legacy.request({ method: 'prompts/get', params: { name: 'test', @@ -3663,7 +3663,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); await expect( - client.request({ + client.legacy.request({ method: 'prompts/get', params: { name: 'nonexistent-prompt' @@ -3795,7 +3795,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -3851,7 +3851,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -3903,7 +3903,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'prompts/get', params: { name: 'request-id-test' @@ -3975,7 +3975,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -4039,7 +4039,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -4078,7 +4078,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'prompts/list' }); @@ -4136,7 +4136,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ method: 'prompts/list' }); + const result = await client.legacy.request({ method: 'prompts/list' }); expect(result.prompts).toHaveLength(1); expect(result.prompts[0]!.name).toBe('test-with-meta'); @@ -4179,7 +4179,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ method: 'prompts/list' }); + const result = await client.legacy.request({ method: 'prompts/list' }); expect(result.prompts).toHaveLength(1); expect(result.prompts[0]!.name).toBe('test-without-meta'); @@ -4247,7 +4247,7 @@ describe('Zod v4', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(4); @@ -4369,7 +4369,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); // Test with microsoft owner - const result1 = await client.request({ + const result1 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -4392,7 +4392,7 @@ describe('Zod v4', () => { expect(result1.completion.total).toBe(3); // Test with facebook owner - const result2 = await client.request({ + const result2 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -4415,7 +4415,7 @@ describe('Zod v4', () => { expect(result2.completion.total).toBe(3); // Test with no resolved context - const result3 = await client.request({ + const result3 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -4489,7 +4489,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); // Test with engineering department - const result1 = await client.request({ + const result1 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -4511,7 +4511,7 @@ describe('Zod v4', () => { expect(result1.completion.values).toEqual(['Alice']); // Test with sales department - const result2 = await client.request({ + const result2 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -4533,7 +4533,7 @@ describe('Zod v4', () => { expect(result2.completion.values).toEqual(['David']); // Test with marketing department - const result3 = await client.request({ + const result3 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -4555,7 +4555,7 @@ describe('Zod v4', () => { expect(result3.completion.values).toEqual(['Grace']); // Test with no resolved context - const result4 = await client.request({ + const result4 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -4606,7 +4606,7 @@ describe('Zod v4', () => { if (!available) { // Ask user if they want to try alternative dates - const result = await mcpServer.server.elicitInput({ + const result = await mcpServer.server.legacy.elicitInput({ message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, requestedSchema: { type: 'object', @@ -5230,7 +5230,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -5285,7 +5285,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/read', params: { uri: 'test://resource' @@ -5367,7 +5367,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -5431,7 +5431,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ + const result = await client.legacy.request({ method: 'resources/list' }); @@ -5504,7 +5504,7 @@ describe('Zod v4', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(4); @@ -5626,7 +5626,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); // Test with microsoft owner - const result1 = await client.request({ + const result1 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -5649,7 +5649,7 @@ describe('Zod v4', () => { expect(result1.completion.total).toBe(3); // Test with facebook owner - const result2 = await client.request({ + const result2 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -5672,7 +5672,7 @@ describe('Zod v4', () => { expect(result2.completion.total).toBe(3); // Test with no resolved context - const result3 = await client.request({ + const result3 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -5746,7 +5746,7 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); // Test with engineering department - const result1 = await client.request({ + const result1 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -5768,7 +5768,7 @@ describe('Zod v4', () => { expect(result1.completion.values).toEqual(['Alice']); // Test with sales department - const result2 = await client.request({ + const result2 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -5790,7 +5790,7 @@ describe('Zod v4', () => { expect(result2.completion.values).toEqual(['David']); // Test with marketing department - const result3 = await client.request({ + const result3 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -5812,7 +5812,7 @@ describe('Zod v4', () => { expect(result3.completion.values).toEqual(['Grace']); // Test with no resolved context - const result4 = await client.request({ + const result4 = await client.legacy.request({ method: 'completion/complete', params: { ref: { @@ -5863,7 +5863,7 @@ describe('Zod v4', () => { if (!available) { // Ask user if they want to try alternative dates - const result = await mcpServer.server.elicitInput({ + const result = await mcpServer.server.legacy.elicitInput({ mode: 'form', message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, requestedSchema: { diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index 67f16c5fa7..20c89cf0ac 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -54,7 +54,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); expect(result.tools[0].name).toBe('greet'); @@ -89,7 +89,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools[0].outputSchema).toMatchObject({ $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -113,7 +113,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'double', arguments: { value: 21 } } }); @@ -130,7 +130,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'double', arguments: { value: 'not a number' } } }); @@ -153,7 +153,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'calculate', arguments: { operation: 'divide' } } }); @@ -173,7 +173,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'greet', arguments: { name: 'Alice' } } }); @@ -209,7 +209,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); expect(result.tools[0].name).toBe('greet'); @@ -237,7 +237,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ method: 'tools/list' }); + const result = await client.legacy.request({ method: 'tools/list' }); expect(result.tools[0].inputSchema.properties).toMatchObject({ city: { type: 'string', description: 'The city name' }, @@ -256,7 +256,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'double', arguments: { value: 21 } } }); @@ -273,7 +273,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'double', arguments: { value: 'not a number' } } }); @@ -297,7 +297,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'calculate', arguments: { operation: 'divide' } } }); @@ -321,14 +321,14 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); // Valid value - const validResult = await client.request({ + const validResult = await client.legacy.request({ method: 'tools/call', params: { name: 'setPercentage', arguments: { percentage: 50 } } }); expect(validResult.isError).toBeFalsy(); // Invalid value (too high) - const invalidResult = await client.request({ + const invalidResult = await client.legacy.request({ method: 'tools/call', params: { name: 'setPercentage', arguments: { percentage: 150 } } }); @@ -360,20 +360,23 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const tools = await client.request({ method: 'tools/list' }); + const tools = await client.legacy.request({ method: 'tools/list' }); expect(tools.tools).toHaveLength(3); // Call each tool - const zodResult = await client.request({ method: 'tools/call', params: { name: 'zod-tool', arguments: { value: 'test' } } }); + const zodResult = await client.legacy.request({ + method: 'tools/call', + params: { name: 'zod-tool', arguments: { value: 'test' } } + }); expect((zodResult.content[0] as TextContent).text).toBe('zod: test'); - const arktypeResult = await client.request({ + const arktypeResult = await client.legacy.request({ method: 'tools/call', params: { name: 'arktype-tool', arguments: { value: 'test' } } }); expect((arktypeResult.content[0] as TextContent).text).toBe('arktype: test'); - const valibotResult = await client.request({ + const valibotResult = await client.legacy.request({ method: 'tools/call', params: { name: 'valibot-tool', arguments: { value: 'test' } } }); @@ -396,14 +399,14 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const listed = await client.request({ method: 'tools/list' }); + const listed = await client.legacy.request({ method: 'tools/list' }); expect(listed.tools[0].inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }); - const result = await client.request({ method: 'tools/call', params: { name: 'greet', arguments: { name: 'World' } } }); + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'greet', arguments: { name: 'World' } } }); expect((result.content[0] as TextContent).text).toBe('Hello, World!'); }); @@ -420,7 +423,10 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ method: 'tools/call', params: { name: 'double', arguments: { count: 'not a number' } } }); + const result = await client.legacy.request({ + method: 'tools/call', + params: { name: 'double', arguments: { count: 'not a number' } } + }); expect(result.isError).toBe(true); const errorText = (result.content[0] as TextContent).text; @@ -442,7 +448,10 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ method: 'tools/call', params: { name: 'greet-default', arguments: { name: 'World' } } }); + const result = await client.legacy.request({ + method: 'tools/call', + params: { name: 'greet-default', arguments: { name: 'World' } } + }); expect((result.content[0] as TextContent).text).toBe('Hello, World!'); }); @@ -456,7 +465,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'double-default', arguments: { count: 'not a number' } } }); @@ -488,7 +497,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); // Test completion - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { type: 'ref/prompt', name: 'greeting' }, @@ -514,7 +523,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { type: 'ref/prompt', name: 'greeting' }, @@ -543,7 +552,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { type: 'ref/prompt', name: 'greeting' }, @@ -569,7 +578,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'completion/complete', params: { ref: { type: 'ref/prompt', name: 'greeting' }, @@ -595,7 +604,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -631,7 +640,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -665,7 +674,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'test', @@ -707,7 +716,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'typed-tool', @@ -740,7 +749,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ + const result = await client.legacy.request({ method: 'tools/call', params: { name: 'typed-tool', diff --git a/test/integration/test/stateManagementStreamableHttp.test.ts b/test/integration/test/stateManagementStreamableHttp.test.ts index 30166f1953..06e6348f5e 100644 --- a/test/integration/test/stateManagementStreamableHttp.test.ts +++ b/test/integration/test/stateManagementStreamableHttp.test.ts @@ -118,7 +118,7 @@ describe('Zod v4', () => { expect(transport1.sessionId).toBeUndefined(); // List available tools - await client1.request({ + await client1.legacy.request({ method: 'tools/list', params: {} }); @@ -135,7 +135,7 @@ describe('Zod v4', () => { expect(transport2.sessionId).toBeUndefined(); // List available tools - await client2.request({ + await client2.legacy.request({ method: 'tools/list', params: {} }); @@ -154,7 +154,7 @@ describe('Zod v4', () => { expect(transport.sessionId).toBeUndefined(); // List available tools - const toolsResult = await client.request({ + const toolsResult = await client.legacy.request({ method: 'tools/list', params: {} }); @@ -167,7 +167,7 @@ describe('Zod v4', () => { ); // List available resources - const resourcesResult = await client.request({ + const resourcesResult = await client.legacy.request({ method: 'resources/list', params: {} }); @@ -176,7 +176,7 @@ describe('Zod v4', () => { expect(resourcesResult).toHaveProperty('resources'); // List available prompts - const promptsResult = await client.request({ + const promptsResult = await client.legacy.request({ method: 'prompts/list', params: {} }); @@ -190,7 +190,7 @@ describe('Zod v4', () => { ); // Call the greeting tool - const greetingResult = await client.request({ + const greetingResult = await client.legacy.request({ method: 'tools/call', params: { name: 'greet', @@ -265,7 +265,7 @@ describe('Zod v4', () => { expect(typeof transport.sessionId).toBe('string'); // List available tools - const toolsResult = await client.request({ + const toolsResult = await client.legacy.request({ method: 'tools/list', params: {} }); @@ -278,7 +278,7 @@ describe('Zod v4', () => { ); // List available resources - const resourcesResult = await client.request({ + const resourcesResult = await client.legacy.request({ method: 'resources/list', params: {} }); @@ -287,7 +287,7 @@ describe('Zod v4', () => { expect(resourcesResult).toHaveProperty('resources'); // List available prompts - const promptsResult = await client.request({ + const promptsResult = await client.legacy.request({ method: 'prompts/list', params: {} }); @@ -301,7 +301,7 @@ describe('Zod v4', () => { ); // Call the greeting tool - const greetingResult = await client.request({ + const greetingResult = await client.legacy.request({ method: 'tools/call', params: { name: 'greet', diff --git a/test/integration/test/statelessAcceptance.test.ts b/test/integration/test/statelessAcceptance.test.ts index 004a74701f..c0ee2b2f74 100644 --- a/test/integration/test/statelessAcceptance.test.ts +++ b/test/integration/test/statelessAcceptance.test.ts @@ -338,9 +338,9 @@ describe('Audit invariants', () => { const server = new Server({ name: 's', version: '1' }, { capabilities: {} }); server.fallbackRequestHandler = async () => ({}); const d = server.statelessHandlers().dispatch; - const before = server.getClientCapabilities(); + const before = server.legacy.getClientCapabilities(); await Promise.all([1, 2, 3, 4, 5].map(i => d(jreq(i, 'x'), { notify: () => {} }))); - expect(server.getClientCapabilities()).toBe(before); - expect(server.getClientVersion()).toBeUndefined(); + expect(server.legacy.getClientCapabilities()).toBe(before); + expect(server.legacy.getClientVersion()).toBeUndefined(); }); }); diff --git a/test/integration/test/taskResumability.test.ts b/test/integration/test/taskResumability.test.ts index da52bccf4e..3b36825847 100644 --- a/test/integration/test/taskResumability.test.ts +++ b/test/integration/test/taskResumability.test.ts @@ -211,7 +211,7 @@ describe('Zod v4', () => { }); expect(lastEventId).toBeUndefined(); // Start the notification tool with event tracking using request - const toolPromise = client1.request( + const toolPromise = client1.legacy.request( { method: 'tools/call', params: {