From c500d6e28d0007ec8014ad64754ec305105baa81 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:11:54 +0000 Subject: [PATCH 01/55] feat(core): add Dispatcher and StreamDriver as new dispatch primitives Dispatcher: stateless handler registry with async-generator dispatch(). Yields notifications then a terminal response. No transport, no correlation state, no timers; one instance serves any number of concurrent requests. StreamDriver: runs a Dispatcher over a persistent Transport pipe. Owns the per-connection state that previously lived on Protocol: request-id correlation, timeouts, progress callbacks, cancellation, notification debouncing. --- packages/client/src/client/clientTransport.ts | 156 ++++++++ packages/core/src/index.ts | 2 + packages/core/src/shared/dispatcher.ts | 262 +++++++++++++ packages/core/src/shared/streamDriver.ts | 356 ++++++++++++++++++ packages/core/test/shared/dispatcher.test.ts | 193 ++++++++++ .../core/test/shared/streamDriver.test.ts | 217 +++++++++++ packages/server/src/server/sessionCompat.ts | 197 ++++++++++ 7 files changed, 1383 insertions(+) create mode 100644 packages/client/src/client/clientTransport.ts create mode 100644 packages/core/src/shared/dispatcher.ts create mode 100644 packages/core/src/shared/streamDriver.ts create mode 100644 packages/core/test/shared/dispatcher.test.ts create mode 100644 packages/core/test/shared/streamDriver.test.ts create mode 100644 packages/server/src/server/sessionCompat.ts diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts new file mode 100644 index 000000000..78b968d56 --- /dev/null +++ b/packages/client/src/client/clientTransport.ts @@ -0,0 +1,156 @@ +import type { + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + Notification, + Progress, + Request, + Transport +} from '@modelcontextprotocol/core'; +import { getResultSchema, isJSONRPCErrorResponse } from '@modelcontextprotocol/core'; + +// TODO(ts-rebuild): replace with `from '@modelcontextprotocol/core'` once the core barrel exports these. +// Dispatcher/StreamDriver are written by a sibling fork to packages/core/src/shared/{dispatcher,streamDriver}.ts. +import type { Dispatcher, RequestOptions } from '../../../core/src/shared/dispatcher.js'; +import { StreamDriver } from '../../../core/src/shared/streamDriver.js'; + +/** + * Per-call options for {@linkcode ClientTransport.fetch}. + */ +export type ClientFetchOptions = { + /** Abort the in-flight request. */ + signal?: AbortSignal; + /** Called for each `notifications/progress` received before the terminal response. */ + onprogress?: (progress: Progress) => void; + /** Called for each non-progress notification received before the terminal response. */ + onnotification?: (notification: JSONRPCNotification) => void; + /** Per-request timeout (ms). */ + timeout?: number; + /** Reset {@linkcode timeout} when a progress notification arrives. */ + resetTimeoutOnProgress?: boolean; + /** Absolute upper bound (ms) regardless of progress. */ + maxTotalTimeout?: number; +}; + +/** + * Request-shaped client transport. One JSON-RPC request in, one terminal + * response out. The transport may be stateful internally (session id, protocol + * version) but the contract is per-call. + * + * This is the 2026-06-native shape. The legacy pipe {@linkcode Transport} + * interface is adapted via {@linkcode pipeAsClientTransport}. + */ +export interface ClientTransport { + /** + * Send one JSON-RPC request and resolve with the terminal response. + * Any progress/notifications received before the response are surfaced + * via the callbacks in {@linkcode ClientFetchOptions}. + */ + fetch(request: JSONRPCRequest, opts?: ClientFetchOptions): Promise; + + /** + * Send a fire-and-forget notification. + */ + notify(notification: Notification): Promise; + + /** + * Open a server→client subscription stream for list-changed and other + * unsolicited notifications. Optional; transports that cannot stream + * (e.g. plain HTTP without SSE GET) omit this. + */ + subscribe?(filter?: string[]): AsyncIterable; + + /** + * Close the transport and release resources. + */ + close(): Promise; + + /** The underlying {@linkcode StreamDriver} when adapted from a pipe. Compat-only. */ + readonly driver?: StreamDriver; +} + +/** + * Type guard distinguishing the legacy pipe-shaped {@linkcode Transport} from + * a request-shaped {@linkcode ClientTransport}. + */ +export function isPipeTransport(t: Transport | ClientTransport): t is Transport { + return typeof (t as Transport).start === 'function' && typeof (t as Transport).send === 'function'; +} + +/** + * Adapt a legacy pipe-shaped {@linkcode Transport} (stdio, SSE, InMemory, the + * v1 SHTTP client transport) into a {@linkcode ClientTransport}. + * + * Correlation, timeouts, progress and cancellation are handled by an internal + * {@linkcode StreamDriver}. The supplied {@linkcode Dispatcher} services any + * server-initiated requests (sampling, elicitation, roots) that arrive on the pipe. + */ +export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher): ClientTransport { + const driver = new StreamDriver(dispatcher, pipe); + let started = false; + const subscribers: Set<(n: JSONRPCNotification) => void> = new Set(); + dispatcher.fallbackNotificationHandler = async n => { + const msg: JSONRPCNotification = { jsonrpc: '2.0', method: n.method, params: n.params }; + for (const s of subscribers) s(msg); + }; + const ensureStarted = async () => { + if (!started) { + started = true; + await driver.start(); + } + }; + return { + driver, + async fetch(request, opts) { + await ensureStarted(); + const schema = getResultSchema(request.method as never); + try { + const result = await driver.request({ method: request.method, params: request.params } as Request, schema, { + signal: opts?.signal, + timeout: opts?.timeout, + resetTimeoutOnProgress: opts?.resetTimeoutOnProgress, + maxTotalTimeout: opts?.maxTotalTimeout, + onprogress: opts?.onprogress + } as RequestOptions); + return { jsonrpc: '2.0', id: request.id, result } as JSONRPCResultResponse; + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + if (typeof e?.code === 'number') { + return { jsonrpc: '2.0', id: request.id, error: { code: e.code, message: e.message ?? 'Error', data: e.data } }; + } + throw error; + } + }, + async notify(notification) { + await ensureStarted(); + await driver.notification(notification); + }, + async *subscribe() { + await ensureStarted(); + let push: ((n: JSONRPCNotification) => void) | undefined; + const queue: JSONRPCNotification[] = []; + let wake: (() => void) | undefined; + push = n => { + queue.push(n); + wake?.(); + }; + subscribers.add(push); + try { + while (true) { + while (queue.length > 0) yield queue.shift()!; + await new Promise(r => (wake = r)); + wake = undefined; + } + } finally { + subscribers.delete(push); + } + }, + async close() { + await driver.close(); + } + }; +} + +/** Re-exported so callers can detect protocol-level errors uniformly. */ +export { isJSONRPCErrorResponse }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e707d9939..75ca8c7d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,8 @@ export * from './auth/errors.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; +export * from './shared/dispatcher.js'; +export * from './shared/streamDriver.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/responseMessage.js'; diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts new file mode 100644 index 000000000..224ab7b06 --- /dev/null +++ b/packages/core/src/shared/dispatcher.ts @@ -0,0 +1,262 @@ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { + AuthInfo, + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + Notification, + NotificationMethod, + NotificationTypeMap, + Request, + RequestId, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap +} from '../types/index.js'; +import { getNotificationSchema, getRequestSchema, getResultSchema, ProtocolErrorCode } from '../types/index.js'; +import type { BaseContext, RequestOptions } from './protocol.js'; +import type { TaskContext } from './taskManager.js'; + +/** + * Per-dispatch environment provided by the caller (driver). Everything is optional; + * a bare {@linkcode Dispatcher.dispatch} call works with no transport at all. + */ +export type DispatchEnv = { + /** + * Sends a request back to the peer (server→client elicitation/sampling, or + * client→server nested calls). Supplied by {@linkcode StreamDriver} when running + * over a persistent pipe. Defaults to throwing {@linkcode SdkErrorCode.NotConnected}. + */ + send?: (request: Request, options?: RequestOptions) => Promise; + + /** Session identifier from the transport, if any. Surfaced as {@linkcode BaseContext.sessionId}. */ + sessionId?: string; + + /** Validated auth token info for HTTP transports. */ + authInfo?: AuthInfo; + + /** Original HTTP {@linkcode globalThis.Request | Request}, if any. */ + httpReq?: globalThis.Request; + + /** Abort signal for the inbound request. If omitted, a fresh controller is created. */ + signal?: AbortSignal; + + /** Task context, if task storage is configured by the caller. */ + task?: TaskContext; +}; + +/** + * One yielded item from {@linkcode Dispatcher.dispatch}. A dispatch yields zero or more + * notifications followed by exactly one terminal response. + */ +export type DispatchOutput = + | { kind: 'notification'; message: JSONRPCNotification } + | { kind: 'response'; message: JSONRPCResponse | JSONRPCErrorResponse }; + +type RawHandler = (request: JSONRPCRequest, ctx: ContextT) => Promise; + +/** + * Stateless JSON-RPC handler registry with a request-in / messages-out + * {@linkcode Dispatcher.dispatch | dispatch()} entry point. + * + * Holds no transport, no correlation state, no timers. One instance can serve + * any number of concurrent requests from any driver. + */ +export class Dispatcher { + protected _requestHandlers: Map> = new Map(); + protected _notificationHandlers: Map Promise> = new Map(); + + /** + * A handler to invoke for any request types that do not have their own handler installed. + */ + fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; + + /** + * A handler to invoke for any notification types that do not have their own handler installed. + */ + fallbackNotificationHandler?: (notification: Notification) => Promise; + + /** + * Subclasses override to enrich the context (e.g. {@linkcode ServerContext}). Default returns base unchanged. + */ + protected buildContext(base: BaseContext, _env: DispatchEnv): ContextT { + return base as ContextT; + } + + /** + * Dispatch one inbound request. Yields any notifications the handler emits via + * `ctx.mcpReq.notify()`, then yields exactly one terminal response. + * + * Never throws for handler errors; they are wrapped as JSON-RPC error responses. + * May throw if iteration itself is misused. + */ + async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + if (handler === undefined) { + yield errorResponse(request.id, ProtocolErrorCode.MethodNotFound, 'Method not found'); + return; + } + + const queue: JSONRPCNotification[] = []; + let wake: (() => void) | undefined; + let done = false; + let final: JSONRPCResponse | JSONRPCErrorResponse | undefined; + + const localAbort = new AbortController(); + if (env.signal) { + if (env.signal.aborted) localAbort.abort(env.signal.reason); + else env.signal.addEventListener('abort', () => localAbort.abort(env.signal!.reason), { once: true }); + } + + const send = + env.send ?? + (async () => { + throw new SdkError( + SdkErrorCode.NotConnected, + 'ctx.mcpReq.send is unavailable: no peer channel. Use the MRTR-native return form for elicitation/sampling, or run via connect()/StreamDriver.' + ); + }); + + const base: BaseContext = { + sessionId: env.sessionId, + mcpReq: { + id: request.id, + method: request.method, + _meta: request.params?._meta, + signal: localAbort.signal, + send: (r: { method: M; params?: Record }, options?: RequestOptions) => + send(r as Request, options) as Promise, + notify: async (n: Notification) => { + if (done) return; + queue.push({ jsonrpc: '2.0', method: n.method, params: n.params } as JSONRPCNotification); + wake?.(); + } + }, + http: env.authInfo || env.httpReq ? { authInfo: env.authInfo } : undefined, + task: env.task + }; + const ctx = this.buildContext(base, env); + + Promise.resolve() + .then(() => handler(request, ctx)) + .then( + result => { + if (localAbort.signal.aborted) { + final = errorResponse(request.id, ProtocolErrorCode.InternalError, 'Request cancelled').message; + } else { + final = { jsonrpc: '2.0', id: request.id, result }; + } + }, + error => { + final = toErrorResponse(request.id, error); + } + ) + .finally(() => { + done = true; + wake?.(); + }); + + while (true) { + while (queue.length > 0) { + yield { kind: 'notification', message: queue.shift()! }; + } + if (done) break; + await new Promise(resolve => { + wake = resolve; + }); + wake = undefined; + } + // Drain anything pushed between done=true and the wake. + while (queue.length > 0) { + yield { kind: 'notification', message: queue.shift()! }; + } + yield { kind: 'response', message: final! }; + } + + /** + * Dispatch one inbound notification to its handler. Errors are reported via the + * returned promise; unknown methods are silently ignored. + */ + async dispatchNotification(notification: JSONRPCNotification): Promise { + const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; + if (handler === undefined) return; + await Promise.resolve().then(() => handler(notification)); + } + + /** + * Registers a handler to invoke when this dispatcher receives a request with the given method. + */ + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise + ): void { + const schema = getRequestSchema(method); + this._requestHandlers.set(method, (request, ctx) => { + const parsed = schema.parse(request) as RequestTypeMap[M]; + return Promise.resolve(handler(parsed, ctx)); + }); + } + + /** Registers a raw handler with no schema parsing. Used for compat shims. */ + setRawRequestHandler(method: string, handler: RawHandler): void { + this._requestHandlers.set(method, handler); + } + + removeRequestHandler(method: string): void { + this._requestHandlers.delete(method); + } + + assertCanSetRequestHandler(method: string): void { + if (this._requestHandlers.has(method)) { + throw new Error(`A request handler for ${method} already exists, which would be overridden`); + } + } + + setNotificationHandler( + method: M, + handler: (notification: NotificationTypeMap[M]) => void | Promise + ): void { + const schema = getNotificationSchema(method); + this._notificationHandlers.set(method, notification => { + const parsed = schema.parse(notification); + return Promise.resolve(handler(parsed)); + }); + } + + removeNotificationHandler(method: string): void { + this._notificationHandlers.delete(method); + } + + /** Convenience: collect a full dispatch into a single response, discarding notifications. */ + async dispatchToResponse(request: JSONRPCRequest, env?: DispatchEnv): Promise { + let resp: JSONRPCResponse | JSONRPCErrorResponse | undefined; + for await (const out of this.dispatch(request, env)) { + if (out.kind === 'response') resp = out.message; + } + return resp!; + } +} + +function errorResponse(id: RequestId, code: number, message: string): { kind: 'response'; message: JSONRPCErrorResponse } { + return { kind: 'response', message: { jsonrpc: '2.0', id, error: { code, message } } }; +} + +function toErrorResponse(id: RequestId, error: unknown): JSONRPCErrorResponse { + const e = error as { code?: unknown; message?: unknown; data?: unknown }; + return { + jsonrpc: '2.0', + id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + message: typeof e?.message === 'string' ? e.message : 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + }; +} + +/** Re-export for convenience; the canonical definition lives in protocol.ts for now. */ +export type { BaseContext, RequestOptions } from './protocol.js'; +export { getResultSchema }; diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts new file mode 100644 index 000000000..ba9e11f07 --- /dev/null +++ b/packages/core/src/shared/streamDriver.ts @@ -0,0 +1,356 @@ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + JSONRPCResultResponse, + MessageExtraInfo, + Notification, + Progress, + ProgressNotification, + Request, + RequestId, + Result +} from '../types/index.js'; +import { + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + ProtocolError, + SUPPORTED_PROTOCOL_VERSIONS +} from '../types/index.js'; +import type { AnySchema, SchemaOutput } from '../util/schema.js'; +import { parseSchema } from '../util/schema.js'; +import type { DispatchEnv, Dispatcher } from './dispatcher.js'; +import { getResultSchema } from './dispatcher.js'; +import type { NotificationOptions, ProgressCallback, RequestOptions } from './protocol.js'; +import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './protocol.js'; +import type { Transport } from './transport.js'; + +type TimeoutInfo = { + timeoutId: ReturnType; + startTime: number; + timeout: number; + maxTotalTimeout?: number; + resetTimeoutOnProgress: boolean; + onTimeout: () => void; +}; + +export type StreamDriverOptions = { + supportedProtocolVersions?: string[]; + debouncedNotificationMethods?: string[]; + /** + * Hook to enrich the per-request {@linkcode DispatchEnv} from transport-supplied + * {@linkcode MessageExtraInfo} (e.g. auth, http req). + */ + buildEnv?: (extra: MessageExtraInfo | undefined, base: DispatchEnv) => DispatchEnv; +}; + +/** + * Runs a {@linkcode Dispatcher} over a persistent bidirectional {@linkcode Transport} + * (stdio, WebSocket, InMemory). Owns all per-connection state: outbound request + * id correlation, timeouts, progress callbacks, cancellation, debouncing. + * + * One driver per pipe. The dispatcher it wraps may be shared. + */ +export class StreamDriver { + private _requestMessageId = 0; + private _responseHandlers: Map void> = new Map(); + private _progressHandlers: Map = new Map(); + private _timeoutInfo: Map = new Map(); + private _requestHandlerAbortControllers: Map = new Map(); + private _pendingDebouncedNotifications = new Set(); + private _supportedProtocolVersions: string[]; + + onclose?: () => void; + onerror?: (error: Error) => void; + + constructor( + readonly dispatcher: Dispatcher, + readonly pipe: Transport, + private _options: StreamDriverOptions = {} + ) { + this._supportedProtocolVersions = _options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + } + + /** + * Wires the pipe's callbacks and starts it. After this resolves, inbound + * requests are dispatched and {@linkcode StreamDriver.request | request()} works. + */ + async start(): Promise { + const prevClose = this.pipe.onclose; + this.pipe.onclose = () => { + try { + prevClose?.(); + } finally { + this._onclose(); + } + }; + + const prevError = this.pipe.onerror; + this.pipe.onerror = (error: Error) => { + prevError?.(error); + this._onerror(error); + }; + + const prevMessage = this.pipe.onmessage; + this.pipe.onmessage = (message, extra) => { + prevMessage?.(message, extra); + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + this._onresponse(message); + } else if (isJSONRPCRequest(message)) { + this._onrequest(message, extra); + } else if (isJSONRPCNotification(message)) { + this._onnotification(message); + } else { + this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); + } + }; + + this.pipe.setSupportedProtocolVersions?.(this._supportedProtocolVersions); + await this.pipe.start(); + } + + async close(): Promise { + await this.pipe.close(); + } + + /** + * Sends a request over the pipe and resolves with the parsed result. + */ + request(req: Request, resultSchema: T, options?: RequestOptions): Promise> { + const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + let onAbort: (() => void) | undefined; + let cleanupId: number | undefined; + + return new Promise>((resolve, reject) => { + options?.signal?.throwIfAborted(); + + const messageId = this._requestMessageId++; + cleanupId = messageId; + const jsonrpcRequest: JSONRPCRequest = { ...req, jsonrpc: '2.0', id: messageId }; + + if (options?.onprogress) { + this._progressHandlers.set(messageId, options.onprogress); + jsonrpcRequest.params = { + ...req.params, + _meta: { ...(req.params?._meta as Record | undefined), progressToken: messageId } + }; + } + + const cancel = (reason: unknown) => { + this._progressHandlers.delete(messageId); + this.pipe + .send( + { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: messageId, reason: String(reason) } + }, + { relatedRequestId, resumptionToken, onresumptiontoken } + ) + .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + const error = reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); + reject(error); + }; + + this._responseHandlers.set(messageId, response => { + if (options?.signal?.aborted) return; + if (response instanceof Error) return reject(response); + try { + const parsed = parseSchema(resultSchema, response.result); + if (parsed.success) resolve(parsed.data as SchemaOutput); + else reject(parsed.error); + } catch (error) { + reject(error); + } + }); + + onAbort = () => cancel(options?.signal?.reason); + options?.signal?.addEventListener('abort', onAbort, { once: true }); + + const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + this._setupTimeout( + messageId, + timeout, + options?.maxTotalTimeout, + () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout })), + options?.resetTimeoutOnProgress ?? false + ); + + this.pipe.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._progressHandlers.delete(messageId); + reject(error); + }); + }).finally(() => { + if (onAbort) options?.signal?.removeEventListener('abort', onAbort); + if (cleanupId !== undefined) { + this._responseHandlers.delete(cleanupId); + this._cleanupTimeout(cleanupId); + } + }); + } + + /** + * Sends a notification over the pipe. Supports debouncing per the constructor option. + */ + async notification(notification: Notification, options?: NotificationOptions): Promise { + const jsonrpc: JSONRPCNotification = { jsonrpc: '2.0', method: notification.method, params: notification.params }; + + const debounced = this._options.debouncedNotificationMethods ?? []; + const canDebounce = debounced.includes(notification.method) && !notification.params && !options?.relatedRequestId; + if (canDebounce) { + if (this._pendingDebouncedNotifications.has(notification.method)) return; + this._pendingDebouncedNotifications.add(notification.method); + Promise.resolve().then(() => { + this._pendingDebouncedNotifications.delete(notification.method); + this.pipe.send(jsonrpc, options).catch(error => this._onerror(error)); + }); + return; + } + await this.pipe.send(jsonrpc, options); + } + + private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { + const abort = new AbortController(); + this._requestHandlerAbortControllers.set(request.id, abort); + + const baseEnv: DispatchEnv = { + signal: abort.signal, + sessionId: this.pipe.sessionId, + authInfo: extra?.authInfo, + httpReq: extra?.request, + send: (r, opts) => this.request(r, getResultSchema(r.method as any), { ...opts, relatedRequestId: request.id }) as Promise + }; + const env = this._options.buildEnv ? this._options.buildEnv(extra, baseEnv) : baseEnv; + + const drain = async () => { + for await (const out of this.dispatcher.dispatch(request, env)) { + if (abort.signal.aborted && out.kind === 'response') return; + await this.pipe.send(out.message, { relatedRequestId: request.id }); + } + }; + drain() + .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) + .finally(() => { + if (this._requestHandlerAbortControllers.get(request.id) === abort) { + this._requestHandlerAbortControllers.delete(request.id); + } + }); + } + + private _onnotification(notification: JSONRPCNotification): void { + if (notification.method === 'notifications/cancelled') { + const requestId = (notification.params as { requestId?: RequestId } | undefined)?.requestId; + if (requestId !== undefined) this._requestHandlerAbortControllers.get(requestId)?.abort((notification.params as any)?.reason); + return; + } + if (notification.method === 'notifications/progress') { + this._onprogress(notification as unknown as ProgressNotification); + return; + } + this.dispatcher + .dispatchNotification(notification) + .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); + } + + private _onprogress(notification: ProgressNotification): void { + const { progressToken, ...params } = notification.params; + const messageId = Number(progressToken); + const handler = this._progressHandlers.get(messageId); + if (!handler) { + this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`)); + return; + } + const responseHandler = this._responseHandlers.get(messageId); + const info = this._timeoutInfo.get(messageId); + if (info && responseHandler && info.resetTimeoutOnProgress) { + try { + this._resetTimeout(messageId); + } catch (error) { + this._responseHandlers.delete(messageId); + this._progressHandlers.delete(messageId); + this._cleanupTimeout(messageId); + responseHandler(error as Error); + return; + } + } + handler(params as Progress); + } + + private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { + const messageId = Number(response.id); + const handler = this._responseHandlers.get(messageId); + if (handler === undefined) { + this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); + return; + } + this._responseHandlers.delete(messageId); + this._cleanupTimeout(messageId); + this._progressHandlers.delete(messageId); + if (isJSONRPCResultResponse(response)) { + handler(response); + } else { + handler(ProtocolError.fromError(response.error.code, response.error.message, response.error.data)); + } + } + + private _onclose(): void { + const responseHandlers = this._responseHandlers; + this._responseHandlers = new Map(); + this._progressHandlers.clear(); + this._pendingDebouncedNotifications.clear(); + for (const info of this._timeoutInfo.values()) clearTimeout(info.timeoutId); + this._timeoutInfo.clear(); + const aborts = this._requestHandlerAbortControllers; + this._requestHandlerAbortControllers = new Map(); + const error = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'); + try { + this.onclose?.(); + } finally { + for (const handler of responseHandlers.values()) handler(error); + for (const c of aborts.values()) c.abort(error); + } + } + + private _onerror(error: Error): void { + this.onerror?.(error); + } + + private _setupTimeout(id: number, timeout: number, maxTotal: number | undefined, onTimeout: () => void, reset: boolean): void { + this._timeoutInfo.set(id, { + timeoutId: setTimeout(onTimeout, timeout), + startTime: Date.now(), + timeout, + maxTotalTimeout: maxTotal, + resetTimeoutOnProgress: reset, + onTimeout + }); + } + + private _resetTimeout(id: number): boolean { + const info = this._timeoutInfo.get(id); + if (!info) return false; + const elapsed = Date.now() - info.startTime; + if (info.maxTotalTimeout && elapsed >= info.maxTotalTimeout) { + this._timeoutInfo.delete(id); + throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { + maxTotalTimeout: info.maxTotalTimeout, + totalElapsed: elapsed + }); + } + clearTimeout(info.timeoutId); + info.timeoutId = setTimeout(info.onTimeout, info.timeout); + return true; + } + + private _cleanupTimeout(id: number): void { + const info = this._timeoutInfo.get(id); + if (info) { + clearTimeout(info.timeoutId); + this._timeoutInfo.delete(id); + } + } +} diff --git a/packages/core/test/shared/dispatcher.test.ts b/packages/core/test/shared/dispatcher.test.ts new file mode 100644 index 000000000..bd240e13d --- /dev/null +++ b/packages/core/test/shared/dispatcher.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'vitest'; + +import { SdkError } from '../../src/errors/sdkErrors.js'; +import type { DispatchOutput } from '../../src/shared/dispatcher.js'; +import { Dispatcher } from '../../src/shared/dispatcher.js'; +import type { JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResultResponse, Result } from '../../src/types/index.js'; +import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; + +const req = (method: string, params?: Record, id = 1): JSONRPCRequest => ({ jsonrpc: '2.0', id, method, params }); + +async function collect(gen: AsyncIterable): Promise { + const out: DispatchOutput[] = []; + for await (const o of gen) out.push(o); + return out; +} + +describe('Dispatcher', () => { + test('dispatch yields a single response for a registered handler', async () => { + const d = new Dispatcher(); + d.setRequestHandler('ping', async () => ({})); + const out = await collect(d.dispatch(req('ping'))); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe('response'); + expect((out[0]!.message as JSONRPCResultResponse).result).toEqual({}); + }); + + test('dispatch yields MethodNotFound for an unregistered method', async () => { + const d = new Dispatcher(); + const out = await collect(d.dispatch(req('tools/list'))); + expect(out).toHaveLength(1); + const msg = out[0]!.message as JSONRPCErrorResponse; + expect(msg.error.code).toBe(ProtocolErrorCode.MethodNotFound); + }); + + test('handler throw is wrapped as InternalError', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('boom', async () => { + throw new Error('kaboom'); + }); + const out = await collect(d.dispatch(req('boom'))); + const msg = out[0]!.message as JSONRPCErrorResponse; + expect(msg.error.code).toBe(ProtocolErrorCode.InternalError); + expect(msg.error.message).toBe('kaboom'); + }); + + test('handler throwing ProtocolError preserves code and data', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('boom', async () => { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad', { hint: 'x' }); + }); + const out = await collect(d.dispatch(req('boom'))); + const msg = out[0]!.message as JSONRPCErrorResponse; + expect(msg.error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(msg.error.data).toEqual({ hint: 'x' }); + }); + + test('ctx.mcpReq.notify yields notifications before the final response', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('work', async (_r, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 1, progress: 0.5 } }); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'hi' } }); + return { ok: true } as Result; + }); + const out = await collect(d.dispatch(req('work'))); + expect(out.map(o => o.kind)).toEqual(['notification', 'notification', 'response']); + expect((out[0]!.message as any).params.progress).toBe(0.5); + expect((out[2]!.message as JSONRPCResultResponse).result).toEqual({ ok: true }); + }); + + test('notifications interleave with async handler work', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('work', async (_r, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: '1' } }); + await new Promise(r => setTimeout(r, 1)); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: '2' } }); + return {} as Result; + }); + const seen: string[] = []; + for await (const o of d.dispatch(req('work'))) { + seen.push(o.kind === 'notification' ? `n:${(o.message.params as any).data}` : 'response'); + } + expect(seen).toEqual(['n:1', 'n:2', 'response']); + }); + + test('ctx.mcpReq.send throws by default with no env.send', async () => { + const d = new Dispatcher(); + let caught: unknown; + d.setRawRequestHandler('elicit', async (_r, ctx) => { + try { + await ctx.mcpReq.send({ method: 'elicitation/create', params: {} }); + } catch (e) { + caught = e; + } + return {} as Result; + }); + await collect(d.dispatch(req('elicit'))); + expect(caught).toBeInstanceOf(SdkError); + expect((caught as Error).message).toMatch(/no peer channel/); + }); + + test('ctx.mcpReq.send delegates to env.send when provided', async () => { + const d = new Dispatcher(); + let sent: unknown; + d.setRawRequestHandler('ask', async (_r, ctx) => { + const r = await ctx.mcpReq.send({ method: 'ping' }); + return { got: r } as Result; + }); + const out = await collect( + d.dispatch(req('ask'), { + send: async r => { + sent = r; + return { pong: true } as Result; + } + }) + ); + expect(sent).toEqual({ method: 'ping' }); + expect((out[0]!.message as JSONRPCResultResponse).result).toEqual({ got: { pong: true } }); + }); + + test('env.signal abort yields a cancelled error response', async () => { + const d = new Dispatcher(); + const ac = new AbortController(); + d.setRawRequestHandler('slow', async (_r, ctx) => { + if (ctx.mcpReq.signal.aborted) return {} as Result; + await new Promise(resolve => ctx.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true })); + return {} as Result; + }); + const gen = d.dispatch(req('slow'), { signal: ac.signal }); + const p = collect(gen); + await Promise.resolve(); + ac.abort('stop'); + const out = await p; + const msg = out[out.length - 1]!.message as JSONRPCErrorResponse; + expect(msg.error.message).toBe('Request cancelled'); + }); + + test('env values surface on context', async () => { + const d = new Dispatcher(); + let seen: any; + d.setRawRequestHandler('echo', async (_r, ctx) => { + seen = { sessionId: ctx.sessionId, auth: ctx.http?.authInfo }; + return {} as Result; + }); + await collect(d.dispatch(req('echo'), { sessionId: 's1', authInfo: { token: 't', clientId: 'c', scopes: [] } })); + expect(seen.sessionId).toBe('s1'); + expect(seen.auth.token).toBe('t'); + }); + + test('dispatchNotification routes to handler and ignores unknown', async () => { + const d = new Dispatcher(); + let got: unknown; + d.setNotificationHandler('notifications/initialized', n => { + got = n.method; + }); + await d.dispatchNotification({ jsonrpc: '2.0', method: 'notifications/initialized' }); + expect(got).toBe('notifications/initialized'); + await expect(d.dispatchNotification({ jsonrpc: '2.0', method: 'unknown/thing' } as any)).resolves.toBeUndefined(); + }); + + test('fallbackRequestHandler is used when no specific handler matches', async () => { + const d = new Dispatcher(); + d.fallbackRequestHandler = async r => ({ echoed: r.method }) as Result; + const out = await collect(d.dispatch(req('whatever/method'))); + expect((out[0]!.message as JSONRPCResultResponse).result).toEqual({ echoed: 'whatever/method' }); + }); + + test('assertCanSetRequestHandler throws on collision', () => { + const d = new Dispatcher(); + d.setRequestHandler('ping', async () => ({})); + expect(() => d.assertCanSetRequestHandler('ping')).toThrow(/already exists/); + }); + + test('setRequestHandler parses request via schema', async () => { + const d = new Dispatcher(); + let parsed: unknown; + d.setRequestHandler('ping', r => { + parsed = r; + return {}; + }); + await collect(d.dispatch(req('ping'))); + expect(parsed).toMatchObject({ method: 'ping' }); + }); + + test('dispatchToResponse returns the terminal response', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('x', async (_r, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'n' } }); + return { v: 1 } as Result; + }); + const r = (await d.dispatchToResponse(req('x'))) as JSONRPCResultResponse; + expect(r.result).toEqual({ v: 1 }); + }); +}); diff --git a/packages/core/test/shared/streamDriver.test.ts b/packages/core/test/shared/streamDriver.test.ts new file mode 100644 index 000000000..dc0312d39 --- /dev/null +++ b/packages/core/test/shared/streamDriver.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { Dispatcher } from '../../src/shared/dispatcher.js'; +import { StreamDriver } from '../../src/shared/streamDriver.js'; +import type { JSONRPCMessage, Progress, Result } from '../../src/types/index.js'; +import { ResultSchema } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +function linkedDrivers(opts: { server?: Dispatcher; client?: Dispatcher } = {}) { + const [cPipe, sPipe] = InMemoryTransport.createLinkedPair(); + const serverDisp = opts.server ?? new Dispatcher(); + const clientDisp = opts.client ?? new Dispatcher(); + const server = new StreamDriver(serverDisp, sPipe); + const client = new StreamDriver(clientDisp, cPipe); + return { server, client, serverDisp, clientDisp, cPipe, sPipe }; +} + +describe('StreamDriver', () => { + test('correlates outbound request with inbound response', async () => { + const { server, client, serverDisp } = linkedDrivers(); + serverDisp.setRequestHandler('ping', async () => ({})); + await server.start(); + await client.start(); + const r = await client.request({ method: 'ping' }, ResultSchema); + expect(r).toEqual({}); + }); + + test('request rejects on JSON-RPC error response', async () => { + const { server, client } = linkedDrivers(); + await server.start(); + await client.start(); + await expect(client.request({ method: 'tools/list' }, ResultSchema)).rejects.toThrow(); + }); + + test('request times out and sends cancellation', async () => { + vi.useFakeTimers(); + const { server, client, sPipe } = linkedDrivers(); + await server.start(); + await client.start(); + const sent: JSONRPCMessage[] = []; + const orig = sPipe.onmessage!; + sPipe.onmessage = m => { + sent.push(m); + // swallow: never respond + }; + void orig; + const p = client.request({ method: 'ping' }, ResultSchema, { timeout: 50 }); + vi.advanceTimersByTime(60); + await expect(p).rejects.toThrow(/timed out/); + expect(sent.some(m => 'method' in m && m.method === 'notifications/cancelled')).toBe(true); + vi.useRealTimers(); + }); + + test('progress callback invoked and resets timeout when configured', async () => { + vi.useFakeTimers(); + const { server, client, serverDisp } = linkedDrivers(); + let resolveHandler!: () => void; + serverDisp.setRawRequestHandler('work', (_r, ctx) => { + void ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: ctx.mcpReq.id, progress: 0.5 } }); + return new Promise(r => { + resolveHandler = () => r({} as Result); + }); + }); + await server.start(); + await client.start(); + const seen: Progress[] = []; + const p = client.request({ method: 'work' as any }, ResultSchema, { + timeout: 100, + resetTimeoutOnProgress: true, + onprogress: pr => seen.push(pr) + }); + await vi.advanceTimersByTimeAsync(0); + expect(seen).toHaveLength(1); + expect(seen[0]!.progress).toBe(0.5); + await vi.advanceTimersByTimeAsync(80); + resolveHandler(); + await vi.advanceTimersByTimeAsync(0); + await expect(p).resolves.toEqual({}); + vi.useRealTimers(); + }); + + test('outbound abort signal cancels request', async () => { + const { server, client, serverDisp } = linkedDrivers(); + serverDisp.setRawRequestHandler('slow', () => new Promise(() => {})); + await server.start(); + await client.start(); + const ac = new AbortController(); + const p = client.request({ method: 'slow' as any }, ResultSchema, { signal: ac.signal, timeout: 10_000 }); + ac.abort('user'); + await expect(p).rejects.toThrow(); + }); + + test('inbound notifications/cancelled aborts the handler', async () => { + const { server, client, serverDisp } = linkedDrivers(); + let aborted = false; + serverDisp.setRawRequestHandler('slow', (_r, ctx) => { + return new Promise(resolve => { + ctx.mcpReq.signal.addEventListener('abort', () => { + aborted = true; + resolve({} as Result); + }); + }); + }); + await server.start(); + await client.start(); + const ac = new AbortController(); + const p = client.request({ method: 'slow' as any }, ResultSchema, { signal: ac.signal, timeout: 10_000 }); + await new Promise(r => setTimeout(r, 0)); + ac.abort('stop'); + await p.catch(() => {}); + await new Promise(r => setTimeout(r, 0)); + expect(aborted).toBe(true); + }); + + test('handler notify flows over pipe and arrives at client dispatcher', async () => { + const { server, client, serverDisp, clientDisp } = linkedDrivers(); + const got: unknown[] = []; + clientDisp.setNotificationHandler('notifications/message', n => { + got.push(n.params); + }); + serverDisp.setRawRequestHandler('work', async (_r, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'hi' } }); + return {} as Result; + }); + await server.start(); + await client.start(); + await client.request({ method: 'work' as any }, ResultSchema); + expect(got).toEqual([{ level: 'info', data: 'hi' }]); + }); + + test('close rejects pending outbound requests', async () => { + const { server, client, serverDisp } = linkedDrivers(); + serverDisp.setRawRequestHandler('slow', () => new Promise(() => {})); + await server.start(); + await client.start(); + const p = client.request({ method: 'slow' as any }, ResultSchema, { timeout: 10_000 }); + await client.close(); + await expect(p).rejects.toThrow(/Connection closed/); + }); + + test('close aborts in-flight inbound handlers', async () => { + const { server, client, serverDisp } = linkedDrivers(); + let abortedReason: unknown; + serverDisp.setRawRequestHandler('slow', (_r, ctx) => { + return new Promise(() => { + ctx.mcpReq.signal.addEventListener('abort', () => { + abortedReason = ctx.mcpReq.signal.reason; + }); + }); + }); + await server.start(); + await client.start(); + client.request({ method: 'slow' as any }, ResultSchema, { timeout: 10_000 }).catch(() => {}); + await new Promise(r => setTimeout(r, 0)); + await server.close(); + expect(abortedReason).toBeDefined(); + }); + + test('debounced notifications coalesce within a tick', async () => { + const [cPipe, sPipe] = InMemoryTransport.createLinkedPair(); + const driver = new StreamDriver(new Dispatcher(), cPipe, { + debouncedNotificationMethods: ['notifications/tools/list_changed'] + }); + const seen: JSONRPCMessage[] = []; + sPipe.onmessage = m => seen.push(m); + await sPipe.start(); + await driver.start(); + void driver.notification({ method: 'notifications/tools/list_changed' }); + void driver.notification({ method: 'notifications/tools/list_changed' }); + void driver.notification({ method: 'notifications/tools/list_changed' }); + await new Promise(r => setTimeout(r, 0)); + expect(seen.filter(m => 'method' in m && m.method === 'notifications/tools/list_changed')).toHaveLength(1); + }); + + test('ctx.mcpReq.send round-trips back through the same driver pair', async () => { + const { server, client, serverDisp, clientDisp } = linkedDrivers(); + let pinged = false; + clientDisp.setRequestHandler('ping', async () => { + pinged = true; + return {}; + }); + let elicited: unknown; + serverDisp.setRawRequestHandler('ask', async (_r, ctx) => { + elicited = await ctx.mcpReq.send({ method: 'ping' }); + return {} as Result; + }); + await server.start(); + await client.start(); + await client.request({ method: 'ask' as any }, ResultSchema); + expect(pinged).toBe(true); + expect(elicited).toEqual({}); + }); + + test('onerror fires for response with unknown id', async () => { + const [cPipe, sPipe] = InMemoryTransport.createLinkedPair(); + const driver = new StreamDriver(new Dispatcher(), cPipe); + const errs: Error[] = []; + driver.onerror = e => errs.push(e); + await driver.start(); + await sPipe.start(); + await sPipe.send({ jsonrpc: '2.0', id: 999, result: {} }); + expect(errs[0]?.message).toMatch(/unknown message ID/); + }); + + test('concurrent requests get distinct ids and resolve independently', async () => { + const { server, client, serverDisp } = linkedDrivers(); + serverDisp.setRawRequestHandler('echo', async r => ({ id: r.id }) as Result); + await server.start(); + await client.start(); + const [a, b, c] = await Promise.all([ + client.request({ method: 'echo' as any }, ResultSchema), + client.request({ method: 'echo' as any }, ResultSchema), + client.request({ method: 'echo' as any }, ResultSchema) + ]); + expect(new Set([a.id, b.id, c.id]).size).toBe(3); + }); +}); diff --git a/packages/server/src/server/sessionCompat.ts b/packages/server/src/server/sessionCompat.ts new file mode 100644 index 000000000..d5cb59a2e --- /dev/null +++ b/packages/server/src/server/sessionCompat.ts @@ -0,0 +1,197 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { isInitializeRequest } from '@modelcontextprotocol/core'; + +/** + * Options for {@linkcode SessionCompat}. + */ +export interface SessionCompatOptions { + /** + * Function that generates a session ID. SHOULD be globally unique and cryptographically secure + * (e.g., a securely generated UUID). + * + * @default `() => crypto.randomUUID()` + */ + sessionIdGenerator?: () => string; + + /** + * Maximum number of concurrent sessions to retain. New `initialize` requests beyond this cap + * are rejected with HTTP 503 + `Retry-After`. Idle sessions are evicted LRU when at the cap. + * + * @default 10000 + */ + maxSessions?: number; + + /** + * Sessions idle (no request received) for longer than this are evicted on the next sweep. + * + * @default 30 * 60_000 (30 minutes) + */ + idleTtlMs?: number; + + /** + * Suggested `Retry-After` value (seconds) returned with 503 when at {@linkcode maxSessions}. + * + * @default 30 + */ + retryAfterSeconds?: number; + + /** Called when a new session is minted. */ + onsessioninitialized?: (sessionId: string) => void | Promise; + + /** Called when a session is deleted (via DELETE) or evicted. */ + onsessionclosed?: (sessionId: string) => void | Promise; +} + +interface SessionEntry { + createdAt: number; + lastSeen: number; + /** Standalone GET subscription stream controller, if one is open. */ + sseController?: ReadableStreamDefaultController; +} + +/** Result of {@linkcode SessionCompat.validate}. */ +export type SessionValidation = + | { ok: true; sessionId: string | undefined; isInitialize: boolean } + | { ok: false; response: Response }; + +function jsonError(status: number, code: number, message: string, headers?: Record): Response { + return Response.json( + { jsonrpc: '2.0', error: { code, message }, id: null }, + { status, headers: { 'Content-Type': 'application/json', ...headers } } + ); +} + +/** + * Bounded, in-memory `mcp-session-id` lifecycle for the pre-2026-06 stateful Streamable HTTP + * protocol. One instance is shared across all requests to a given {@linkcode shttpHandler}. + * + * Sessions are minted when an `initialize` request arrives and validated on every subsequent + * request via the `mcp-session-id` header. Storage is LRU with {@linkcode SessionCompatOptions.maxSessions} + * cap and {@linkcode SessionCompatOptions.idleTtlMs} idle eviction. + */ +export class SessionCompat { + private readonly _sessions = new Map(); + private readonly _generate: () => string; + private readonly _maxSessions: number; + private readonly _idleTtlMs: number; + private readonly _retryAfterSeconds: number; + private readonly _onsessioninitialized?: (sessionId: string) => void | Promise; + private readonly _onsessionclosed?: (sessionId: string) => void | Promise; + + constructor(options: SessionCompatOptions = {}) { + this._generate = options.sessionIdGenerator ?? (() => crypto.randomUUID()); + this._maxSessions = options.maxSessions ?? 10_000; + this._idleTtlMs = options.idleTtlMs ?? 30 * 60_000; + this._retryAfterSeconds = options.retryAfterSeconds ?? 30; + this._onsessioninitialized = options.onsessioninitialized; + this._onsessionclosed = options.onsessionclosed; + } + + /** + * Validates the `mcp-session-id` header for a parsed POST body. If the body contains an + * `initialize` request, mints a new session instead. Ported from + * `WebStandardStreamableHTTPServerTransport.validateSession` + the initialize-detection + * block of `handlePostRequest`. + */ + async validate(req: Request, messages: JSONRPCMessage[]): Promise { + const isInit = messages.some(m => isInitializeRequest(m)); + + if (isInit) { + if (messages.length > 1) { + return { + ok: false, + response: jsonError(400, -32_600, 'Invalid Request: Only one initialization request is allowed') + }; + } + this._evictIdle(); + if (this._sessions.size >= this._maxSessions) { + this._evictOldest(); + } + if (this._sessions.size >= this._maxSessions) { + return { + ok: false, + response: jsonError(503, -32_000, 'Server at session capacity', { + 'Retry-After': String(this._retryAfterSeconds) + }) + }; + } + const id = this._generate(); + const now = Date.now(); + this._sessions.set(id, { createdAt: now, lastSeen: now }); + await Promise.resolve(this._onsessioninitialized?.(id)); + return { ok: true, sessionId: id, isInitialize: true }; + } + + return this.validateHeader(req); + } + + /** + * Validates the `mcp-session-id` header without inspecting a body (for GET/DELETE). + */ + validateHeader(req: Request): SessionValidation { + const headerId = req.headers.get('mcp-session-id'); + if (!headerId) { + return { + ok: false, + response: jsonError(400, -32_000, 'Bad Request: Mcp-Session-Id header is required') + }; + } + const entry = this._sessions.get(headerId); + if (!entry) { + return { ok: false, response: jsonError(404, -32_001, 'Session not found') }; + } + entry.lastSeen = Date.now(); + // Re-insert to maintain Map iteration order as LRU. + this._sessions.delete(headerId); + this._sessions.set(headerId, entry); + return { ok: true, sessionId: headerId, isInitialize: false }; + } + + /** Deletes a session (via DELETE request). */ + async delete(sessionId: string): Promise { + const entry = this._sessions.get(sessionId); + if (!entry) return; + try { + entry.sseController?.close(); + } catch { + // Already closed. + } + this._sessions.delete(sessionId); + await Promise.resolve(this._onsessionclosed?.(sessionId)); + } + + /** Returns true if a standalone GET stream is already open for this session. */ + hasStandaloneStream(sessionId: string): boolean { + return this._sessions.get(sessionId)?.sseController !== undefined; + } + + /** Registers the open standalone GET stream controller for this session. */ + setStandaloneStream(sessionId: string, controller: ReadableStreamDefaultController | undefined): void { + const entry = this._sessions.get(sessionId); + if (entry) entry.sseController = controller; + } + + /** Number of live sessions. */ + get size(): number { + return this._sessions.size; + } + + private _evictIdle(): void { + const cutoff = Date.now() - this._idleTtlMs; + for (const [id, entry] of this._sessions) { + if (entry.lastSeen < cutoff) { + this._sessions.delete(id); + void Promise.resolve(this._onsessionclosed?.(id)); + } + } + } + + private _evictOldest(): void { + const oldest = this._sessions.keys().next(); + if (!oldest.done) { + const id = oldest.value; + this._sessions.delete(id); + void Promise.resolve(this._onsessionclosed?.(id)); + } + } +} From a3d1ae30a1e51c4830f09640057e3afb663a5f2b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:14:05 +0000 Subject: [PATCH 02/55] =?UTF-8?q?feat(server):=20McpServer=20with=20handle?= =?UTF-8?q?()=20=E2=80=94=20merged=20mcp.ts=20+=20server.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/server/compat.ts | 6 + packages/server/src/server/mcpServer.ts | 1432 +++++++++++++++++++++++ packages/server/test/mcpServer.test.ts | 271 +++++ 3 files changed, 1709 insertions(+) create mode 100644 packages/server/src/server/compat.ts create mode 100644 packages/server/src/server/mcpServer.ts create mode 100644 packages/server/test/mcpServer.test.ts diff --git a/packages/server/src/server/compat.ts b/packages/server/src/server/compat.ts new file mode 100644 index 000000000..e04c41d83 --- /dev/null +++ b/packages/server/src/server/compat.ts @@ -0,0 +1,6 @@ +/** + * v1 compat alias. The low-level `Server` class is now the same as `McpServer`. + * @deprecated Import {@linkcode McpServer} from `./mcpServer.js` directly. + */ +export { McpServer as Server } from './mcpServer.js'; +export type { ServerOptions } from './mcpServer.js'; diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts new file mode 100644 index 000000000..9ac4e8e46 --- /dev/null +++ b/packages/server/src/server/mcpServer.ts @@ -0,0 +1,1432 @@ +import type { + AuthInfo, + BaseContext, + BaseMetadata, + CallToolRequest, + CallToolResult, + ClientCapabilities, + CompleteRequestPrompt, + CompleteRequestResourceTemplate, + CompleteResult, + CreateMessageRequest, + CreateMessageRequestParamsBase, + CreateMessageRequestParamsWithTools, + CreateMessageResult, + CreateMessageResultWithTools, + CreateTaskResult, + CreateTaskServerContext, + DispatchEnv, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + GetPromptResult, + Implementation, + InitializeRequest, + InitializeResult, + JSONRPCMessage, + JSONRPCRequest, + JsonSchemaType, + jsonSchemaValidator, + ListPromptsResult, + ListResourcesResult, + ListRootsRequest, + ListToolsResult, + LoggingLevel, + LoggingMessageNotification, + Notification, + NotificationOptions, + Prompt, + PromptReference, + ProtocolOptions, + ReadResourceResult, + Request, + RequestMethod, + RequestOptions, + RequestTypeMap, + Resource, + ResourceTemplateReference, + ResourceUpdatedNotification, + Result, + ResultTypeMap, + ServerCapabilities, + ServerContext, + ServerResult, + StandardSchemaWithJSON, + StreamDriverOptions, + TaskManagerOptions, + Tool, + ToolAnnotations, + ToolExecution, + ToolResultContent, + ToolUseContent, + Transport, + Variables +} from '@modelcontextprotocol/core'; +import { + assertCompleteRequestPrompt, + assertCompleteRequestResourceTemplate, + CallToolRequestSchema, + CallToolResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + CreateTaskResultSchema, + Dispatcher, + ElicitResultSchema, + EmptyResultSchema, + isJSONRPCRequest, + LATEST_PROTOCOL_VERSION, + ListRootsResultSchema, + LoggingLevelSchema, + mergeCapabilities, + parseSchema, + promptArgumentsFromStandardSchema, + ProtocolError, + ProtocolErrorCode, + SdkError, + SdkErrorCode, + standardSchemaToJsonSchema, + StreamDriver, + SUPPORTED_PROTOCOL_VERSIONS, + UriTemplate, + validateAndWarnToolName, + validateStandardSchema +} from '@modelcontextprotocol/core'; +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; + +import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; +import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; +import { getCompleter, isCompletable } from './completable.js'; + +/** + * Extended tasks capability that includes runtime configuration (store, messageQueue). + * The runtime-only fields are stripped before advertising capabilities to clients. + */ +export type ServerTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; + +export type ServerOptions = Omit & { + /** + * Capabilities to advertise as being supported by this server. + */ + capabilities?: Omit & { + tasks?: ServerTasksCapabilityWithRuntime; + }; + + /** + * Optional instructions describing how to use the server and its features. + */ + instructions?: string; + + /** + * JSON Schema validator for elicitation response validation. + * + * @default {@linkcode DefaultJsonSchemaValidator} + */ + jsonSchemaValidator?: jsonSchemaValidator; +}; + +/** + * MCP server. Holds tool/resource/prompt registries and exposes both a stateless + * {@linkcode McpServer.handle | handle()} entry point (for HTTP/gRPC/serverless drivers) + * and a {@linkcode McpServer.connect | connect()} entry point (for stdio/WebSocket pipes). + * + * One instance can serve any number of concurrent requests. + */ +export class McpServer extends Dispatcher { + private _driver?: StreamDriver; + + private _clientCapabilities?: ClientCapabilities; + private _clientVersion?: Implementation; + private _capabilities: ServerCapabilities; + private _instructions?: string; + private _jsonSchemaValidator: jsonSchemaValidator; + private _supportedProtocolVersions: string[]; + private _experimental?: { tasks: ExperimentalMcpServerTasks }; + private _loggingLevels = new Map(); + private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); + + private _registeredResources: { [uri: string]: RegisteredResource } = {}; + private _registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate } = {}; + private _registeredTools: { [name: string]: RegisteredTool } = {}; + private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + + private _toolHandlersInitialized = false; + private _completionHandlerInitialized = false; + private _resourceHandlersInitialized = false; + private _promptHandlersInitialized = false; + + /** + * Callback for when initialization has fully completed. + */ + oninitialized?: () => void; + + /** + * Callback for when a connected transport is closed. + */ + onclose?: () => void; + + /** + * Callback for when an error occurs. + */ + onerror?: (error: Error) => void; + + constructor( + private _serverInfo: Implementation, + private _options?: ServerOptions + ) { + super(); + this._capabilities = _options?.capabilities ? { ..._options.capabilities } : {}; + this._instructions = _options?.instructions; + this._jsonSchemaValidator = _options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + + // Strip runtime-only fields from advertised capabilities + if (_options?.capabilities?.tasks) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = + _options.capabilities.tasks; + this._capabilities.tasks = wireCapabilities; + } + + this.setRequestHandler('initialize', request => this._oninitialize(request)); + this.setRequestHandler('ping', () => ({})); + this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); + + if (this._capabilities.logging) { + this._registerLoggingHandler(); + } + } + + // ─────────────────────────────────────────────────────────────────────── + // Direct dispatch (Proposal 1) + // ─────────────────────────────────────────────────────────────────────── + + /** + * Handle one inbound request without a transport. Yields any notifications the handler + * emits via `ctx.mcpReq.notify()`, then yields exactly one terminal response. + */ + async *handle(request: JSONRPCRequest, env?: DispatchEnv): AsyncGenerator { + for await (const out of this.dispatch(request, env)) { + yield out.message; + } + } + + /** + * Convenience entry for HTTP request/response drivers. Parses the body, dispatches each + * request, and returns a JSON response. SSE streaming is handled by `shttpHandler`, not here. + */ + async handleHttp(req: globalThis.Request, opts?: { authInfo?: AuthInfo }): Promise { + let body: unknown; + try { + body = await req.json(); + } catch { + return jsonResponse(400, { jsonrpc: '2.0', id: null, error: { code: ProtocolErrorCode.ParseError, message: 'Parse error' } }); + } + const messages = Array.isArray(body) ? body : [body]; + const env: DispatchEnv = { authInfo: opts?.authInfo, httpReq: req }; + const responses: JSONRPCMessage[] = []; + for (const m of messages) { + if (!isJSONRPCRequest(m)) { + if (m && typeof m === 'object' && 'method' in m) { + await this.dispatchNotification(m).catch(() => {}); + } + continue; + } + for await (const out of this.dispatch(m, env)) { + if (out.kind === 'response') responses.push(out.message); + } + } + if (responses.length === 0) return new Response(null, { status: 202 }); + return jsonResponse(200, responses.length === 1 ? responses[0] : responses); + } + + // ─────────────────────────────────────────────────────────────────────── + // Persistent-pipe transport (compat: builds a StreamDriver) + // ─────────────────────────────────────────────────────────────────────── + + /** + * Attaches to the given transport, starts it, and starts listening for messages. + * Builds a {@linkcode StreamDriver} internally. + */ + async connect(transport: Transport): Promise { + if (this._driver) { + throw new SdkError(SdkErrorCode.AlreadyConnected, 'Server is already connected to a transport'); + } + const driverOpts: StreamDriverOptions = { + supportedProtocolVersions: this._supportedProtocolVersions, + debouncedNotificationMethods: this._options?.debouncedNotificationMethods + }; + this._driver = new StreamDriver(this, transport, driverOpts); + this._driver.onclose = () => { + this._driver = undefined; + this.onclose?.(); + }; + this._driver.onerror = error => this.onerror?.(error); + await this._driver.start(); + } + + /** + * Closes the connection. + */ + async close(): Promise { + await this._driver?.close(); + } + + /** + * Checks if the server is connected to a transport. + */ + isConnected(): boolean { + return this._driver !== undefined; + } + + get transport(): Transport | undefined { + return this._driver?.pipe; + } + + /** + * Returns this instance. Kept so v1 code that reaches `mcpServer.server.X` keeps working. + * @deprecated Call methods directly on `McpServer`. + */ + get server(): this { + return this; + } + + /** + * Access experimental features. + * @experimental + */ + get experimental(): { tasks: ExperimentalMcpServerTasks } { + if (!this._experimental) { + // ExperimentalMcpServerTasks is currently typed against the old mcp.ts McpServer; the + // structural surface it actually uses (server.registerCapabilities, _createRegisteredTool, + // etc.) is present on this class. Cast until tasks helper is re-typed in step 3. + this._experimental = { tasks: new ExperimentalMcpServerTasks(this as never) }; + } + return this._experimental; + } + + // ─────────────────────────────────────────────────────────────────────── + // Context building + // ─────────────────────────────────────────────────────────────────────── + + protected override buildContext(base: BaseContext, env: DispatchEnv): ServerContext { + const hasHttpInfo = base.http || env.httpReq; + return { + ...base, + mcpReq: { + ...base.mcpReq, + log: (level, data, logger) => + base.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } }), + elicitInput: (params, options) => this._elicitInputViaCtx(base, params, options), + requestSampling: (params, options) => this._createMessageViaCtx(base, params, options) + }, + http: hasHttpInfo ? { ...base.http, req: env.httpReq } : undefined + }; + } + + private async _elicitInputViaCtx( + base: BaseContext, + params: ElicitRequestFormParams | ElicitRequestURLParams, + options?: RequestOptions + ): Promise { + const mode = (params.mode ?? 'form') as 'form' | 'url'; + const formParams = mode === 'form' && params.mode !== 'form' ? { ...params, mode: 'form' as const } : params; + const result = (await base.mcpReq.send({ method: 'elicitation/create', params: formParams }, options)) as ElicitResult; + return this._validateElicitResult(result, mode === 'form' ? (formParams as ElicitRequestFormParams) : undefined); + } + + private async _createMessageViaCtx( + base: BaseContext, + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise { + return base.mcpReq.send({ method: 'sampling/createMessage', params }, options) as Promise< + CreateMessageResult | CreateMessageResultWithTools + >; + } + + // ─────────────────────────────────────────────────────────────────────── + // Capabilities & initialize + // ─────────────────────────────────────────────────────────────────────── + + private async _oninitialize(request: InitializeRequest): Promise { + const requestedVersion = request.params.protocolVersion; + this._clientCapabilities = request.params.capabilities; + this._clientVersion = request.params.clientInfo; + + const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + ? requestedVersion + : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); + + this._driver?.pipe.setProtocolVersion?.(protocolVersion); + + return { + protocolVersion, + capabilities: this.getCapabilities(), + serverInfo: this._serverInfo, + ...(this._instructions && { instructions: this._instructions }) + }; + } + + /** + * After initialization, populated with the client's reported capabilities. + */ + getClientCapabilities(): ClientCapabilities | undefined { + return this._clientCapabilities; + } + + /** + * After initialization, populated with the client's name and version. + */ + getClientVersion(): Implementation | undefined { + return this._clientVersion; + } + + /** + * Returns the current server capabilities. + */ + getCapabilities(): ServerCapabilities { + return this._capabilities; + } + + /** + * Registers new capabilities. Can only be called before connecting to a transport. + */ + registerCapabilities(capabilities: ServerCapabilities): void { + if (this._driver) { + throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register capabilities after connecting to transport'); + } + const hadLogging = !!this._capabilities.logging; + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + if (!hadLogging && this._capabilities.logging) { + this._registerLoggingHandler(); + } + } + + /** + * Override request handler registration to enforce server-side validation for `tools/call`. + */ + public override setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise + ): void { + if (method === 'tools/call') { + const wrapped = async (request: RequestTypeMap[M], ctx: ServerContext): Promise => { + const validated = parseSchema(CallToolRequestSchema, request); + if (!validated.success) { + const msg = validated.error instanceof Error ? validated.error.message : String(validated.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${msg}`); + } + const { params } = validated.data; + const result = await Promise.resolve(handler(request, ctx)); + if (params.task) { + const taskValidation = parseSchema(CreateTaskResultSchema, result); + if (!taskValidation.success) { + const msg = taskValidation.error instanceof Error ? taskValidation.error.message : String(taskValidation.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${msg}`); + } + return taskValidation.data; + } + const resultValidation = parseSchema(CallToolResultSchema, result); + if (!resultValidation.success) { + const msg = resultValidation.error instanceof Error ? resultValidation.error.message : String(resultValidation.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${msg}`); + } + return resultValidation.data; + }; + return super.setRequestHandler(method, wrapped); + } + return super.setRequestHandler(method, handler); + } + + // ─────────────────────────────────────────────────────────────────────── + // Server→client requests (only work when connected via StreamDriver) + // ─────────────────────────────────────────────────────────────────────── + + private _requireDriver(): StreamDriver { + if (!this._driver) { + throw new SdkError( + SdkErrorCode.NotConnected, + 'Server is not connected to a stream transport. Use ctx.mcpReq.* inside handlers, or the MRTR-native return form, or call connect().' + ); + } + return this._driver; + } + + private _driverRequest(req: Request, schema: { parse(v: unknown): T }, options?: RequestOptions): Promise { + return this._requireDriver().request(req, schema as never, options) as Promise; + } + + async ping(): Promise { + return this._driverRequest({ method: 'ping' }, EmptyResultSchema); + } + + /** + * Request LLM sampling from the client. Only available when connected via {@linkcode connect}. + * Inside a request handler, prefer `ctx.mcpReq.requestSampling`. + */ + async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; + async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise; + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise { + if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); + } + if (params.messages.length > 0) { + const lastMessage = params.messages.at(-1)!; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some(c => c.type === 'tool_result'); + const previousMessage = params.messages.length > 1 ? params.messages.at(-2) : undefined; + const previousContent = previousMessage + ? Array.isArray(previousMessage.content) + ? previousMessage.content + : [previousMessage.content] + : []; + const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); + if (hasToolResults) { + if (lastContent.some(c => c.type !== 'tool_result')) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'The last message must contain only tool_result content if any is present' + ); + } + if (!hasPreviousToolUse) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'tool_result blocks are not matching any tool_use from the previous message' + ); + } + } + if (hasPreviousToolUse) { + const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => (c as ToolUseContent).id)); + const toolResultIds = new Set( + lastContent.filter(c => c.type === 'tool_result').map(c => (c as ToolResultContent).toolUseId) + ); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'ids of tool_result blocks and tool_use blocks from previous message do not match' + ); + } + } + } + if (params.tools) { + return this._driverRequest({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + } + return this._driverRequest({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + } + + /** + * Creates an elicitation request. Only available when connected via {@linkcode connect}. + * Inside a request handler, prefer `ctx.mcpReq.elicitInput`. + */ + async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + const mode = (params.mode ?? 'form') as 'form' | 'url'; + switch (mode) { + case 'url': { + if (this._clientCapabilities && !this._clientCapabilities.elicitation?.url) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); + } + const urlParams = params as ElicitRequestURLParams; + return this._driverRequest({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + } + case 'form': { + if (this._clientCapabilities && !this._clientCapabilities.elicitation?.form) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); + } + const formParams: ElicitRequestFormParams = + params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; + const result = await this._driverRequest({ method: 'elicitation/create', params: formParams }, ElicitResultSchema, options); + return this._validateElicitResult(result, formParams); + } + } + } + + private _validateElicitResult(result: ElicitResult, formParams?: ElicitRequestFormParams): ElicitResult { + if (result.action === 'accept' && result.content && formParams?.requestedSchema) { + try { + const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); + const validation = validator(result.content); + if (!validation.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${validation.errorMessage}` + ); + } + } catch (error) { + if (error instanceof ProtocolError) throw error; + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + return result; + } + + createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions): () => Promise { + if (this._clientCapabilities && !this._clientCapabilities.elicitation?.url) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + 'Client does not support URL elicitation (required for notifications/elicitation/complete)' + ); + } + return () => this.notification({ method: 'notifications/elicitation/complete', params: { elicitationId } }, options); + } + + async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { + return this._driverRequest({ method: 'roots/list', params }, ListRootsResultSchema, options); + } + + // ─────────────────────────────────────────────────────────────────────── + // Outbound notifications + // ─────────────────────────────────────────────────────────────────────── + + /** + * Sends a notification over the connected transport. No-op when not connected. + */ + async notification(notification: Notification, options?: NotificationOptions): Promise { + await this._driver?.notification(notification, options); + } + + async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise { + if (this._capabilities.logging && !this._isMessageIgnored(params.level, sessionId)) { + return this.notification({ method: 'notifications/message', params }); + } + } + + async sendResourceUpdated(params: ResourceUpdatedNotification['params']): Promise { + return this.notification({ method: 'notifications/resources/updated', params }); + } + + async sendResourceListChanged(): Promise { + if (this.isConnected()) return this.notification({ method: 'notifications/resources/list_changed' }); + } + + async sendToolListChanged(): Promise { + if (this.isConnected()) return this.notification({ method: 'notifications/tools/list_changed' }); + } + + async sendPromptListChanged(): Promise { + if (this.isConnected()) return this.notification({ method: 'notifications/prompts/list_changed' }); + } + + private _registerLoggingHandler(): void { + this.setRequestHandler('logging/setLevel', async (request, ctx) => { + const transportSessionId = ctx.sessionId || ctx.http?.req?.headers.get('mcp-session-id') || undefined; + const { level } = request.params; + const parsed = parseSchema(LoggingLevelSchema, level); + if (parsed.success) { + this._loggingLevels.set(transportSessionId, parsed.data); + } + return {}; + }); + } + + private _isMessageIgnored(level: LoggingLevel, sessionId?: string): boolean { + const currentLevel = this._loggingLevels.get(sessionId); + return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! : false; + } + + // ─────────────────────────────────────────────────────────────────────── + // Tool/Resource/Prompt registries + // ─────────────────────────────────────────────────────────────────────── + + private setToolRequestHandlers(): void { + if (this._toolHandlersInitialized) return; + this.assertCanSetRequestHandler('tools/list'); + this.assertCanSetRequestHandler('tools/call'); + this.registerCapabilities({ tools: { listChanged: this.getCapabilities().tools?.listChanged ?? true } }); + + this.setRequestHandler( + 'tools/list', + (): ListToolsResult => ({ + tools: Object.entries(this._registeredTools) + .filter(([, tool]) => tool.enabled) + .map(([name, tool]): Tool => { + const def: Tool = { + name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema + ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, + execution: tool.execution, + _meta: tool._meta + }; + if (tool.outputSchema) { + def.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + } + return def; + }) + }) + ); + + this.setRequestHandler('tools/call', async (request, ctx): Promise => { + const tool = this._registeredTools[request.params.name]; + if (!tool) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); + } + if (!tool.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); + } + try { + const isTaskRequest = !!request.params.task; + const taskSupport = tool.execution?.taskSupport; + const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); + if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask` + ); + } + if (taskSupport === 'required' && !isTaskRequest) { + throw new ProtocolError( + ProtocolErrorCode.MethodNotFound, + `Tool ${request.params.name} requires task augmentation (taskSupport: 'required')` + ); + } + if (taskSupport === 'optional' && !isTaskRequest && isTaskHandler) { + return await this.handleAutomaticTaskPolling(tool, request, ctx); + } + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const result = await this.executeToolHandler(tool, args, ctx); + if (isTaskRequest) return result; + await this.validateToolOutput(tool, result, request.params.name); + return result; + } catch (error) { + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { + throw error; + } + return this.createToolError(error instanceof Error ? error.message : String(error)); + } + }); + + this._toolHandlersInitialized = true; + } + + private createToolError(errorMessage: string): CallToolResult { + return { content: [{ type: 'text', text: errorMessage }], isError: true }; + } + + private async validateToolInput< + ToolType extends RegisteredTool, + Args extends ToolType['inputSchema'] extends infer InputSchema + ? InputSchema extends StandardSchemaWithJSON + ? StandardSchemaWithJSON.InferOutput + : undefined + : undefined + >(tool: ToolType, args: Args, toolName: string): Promise { + if (!tool.inputSchema) return undefined as Args; + const parsed = await validateStandardSchema(tool.inputSchema, args ?? {}); + if (!parsed.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Input validation error: Invalid arguments for tool ${toolName}: ${parsed.error}` + ); + } + return parsed.data as unknown as Args; + } + + private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { + if (!tool.outputSchema) return; + if (!('content' in result)) return; + if (result.isError) return; + if (!result.structuredContent) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` + ); + } + const parsed = await validateStandardSchema(tool.outputSchema, result.structuredContent); + if (!parsed.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Output validation error: Invalid structured content for tool ${toolName}: ${parsed.error}` + ); + } + } + + private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { + return tool.executor(args, ctx); + } + + private async handleAutomaticTaskPolling( + tool: RegisteredTool, + request: RequestT, + ctx: ServerContext + ): Promise { + if (!ctx.task?.store) { + throw new Error('No task store provided for task-capable tool.'); + } + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const createTaskResult = (await tool.executor(args, ctx)) as CreateTaskResult; + const taskId = createTaskResult.task.taskId; + let task = createTaskResult.task; + const pollInterval = task.pollInterval ?? 5000; + while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + const updated = await ctx.task.store.getTask(taskId); + if (!updated) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`); + } + task = updated; + } + return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult; + } + + private setCompletionRequestHandler(): void { + if (this._completionHandlerInitialized) return; + this.assertCanSetRequestHandler('completion/complete'); + this.registerCapabilities({ completions: {} }); + this.setRequestHandler('completion/complete', async (request): Promise => { + switch (request.params.ref.type) { + case 'ref/prompt': { + assertCompleteRequestPrompt(request); + return this.handlePromptCompletion(request, request.params.ref); + } + case 'ref/resource': { + assertCompleteRequestResourceTemplate(request); + return this.handleResourceCompletion(request, request.params.ref); + } + default: + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`); + } + }); + this._completionHandlerInitialized = true; + } + + private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise { + const prompt = this._registeredPrompts[ref.name]; + if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`); + if (!prompt.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); + if (!prompt.argsSchema) return EMPTY_COMPLETION_RESULT; + const promptShape = getSchemaShape(prompt.argsSchema); + const field = unwrapOptionalSchema(promptShape?.[request.params.argument.name]); + if (!isCompletable(field)) return EMPTY_COMPLETION_RESULT; + const completer = getCompleter(field); + if (!completer) return EMPTY_COMPLETION_RESULT; + const suggestions = await completer(request.params.argument.value, request.params.context); + return createCompletionResult(suggestions); + } + + private async handleResourceCompletion( + request: CompleteRequestResourceTemplate, + ref: ResourceTemplateReference + ): Promise { + const template = Object.values(this._registeredResourceTemplates).find(t => t.resourceTemplate.uriTemplate.toString() === ref.uri); + if (!template) { + if (this._registeredResources[ref.uri]) return EMPTY_COMPLETION_RESULT; + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`); + } + const completer = template.resourceTemplate.completeCallback(request.params.argument.name); + if (!completer) return EMPTY_COMPLETION_RESULT; + const suggestions = await completer(request.params.argument.value, request.params.context); + return createCompletionResult(suggestions); + } + + private setResourceRequestHandlers(): void { + if (this._resourceHandlersInitialized) return; + this.assertCanSetRequestHandler('resources/list'); + this.assertCanSetRequestHandler('resources/templates/list'); + this.assertCanSetRequestHandler('resources/read'); + this.registerCapabilities({ resources: { listChanged: this.getCapabilities().resources?.listChanged ?? true } }); + + this.setRequestHandler('resources/list', async (_request, ctx) => { + const resources = Object.entries(this._registeredResources) + .filter(([_, r]) => r.enabled) + .map(([uri, r]) => ({ uri, name: r.name, ...r.metadata })); + const templateResources: Resource[] = []; + for (const template of Object.values(this._registeredResourceTemplates)) { + if (!template.resourceTemplate.listCallback) continue; + const result = await template.resourceTemplate.listCallback(ctx); + for (const resource of result.resources) { + templateResources.push({ ...template.metadata, ...resource }); + } + } + return { resources: [...resources, ...templateResources] }; + }); + + this.setRequestHandler('resources/templates/list', async () => { + const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, t]) => ({ + name, + uriTemplate: t.resourceTemplate.uriTemplate.toString(), + ...t.metadata + })); + return { resourceTemplates }; + }); + + this.setRequestHandler('resources/read', async (request, ctx) => { + const uri = new URL(request.params.uri); + const resource = this._registeredResources[uri.toString()]; + if (resource) { + if (!resource.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); + } + return resource.readCallback(uri, ctx); + } + for (const template of Object.values(this._registeredResourceTemplates)) { + const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); + if (variables) return template.readCallback(uri, variables, ctx); + } + throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); + }); + + this._resourceHandlersInitialized = true; + } + + private setPromptRequestHandlers(): void { + if (this._promptHandlersInitialized) return; + this.assertCanSetRequestHandler('prompts/list'); + this.assertCanSetRequestHandler('prompts/get'); + this.registerCapabilities({ prompts: { listChanged: this.getCapabilities().prompts?.listChanged ?? true } }); + + this.setRequestHandler( + 'prompts/list', + (): ListPromptsResult => ({ + prompts: Object.entries(this._registeredPrompts) + .filter(([, p]) => p.enabled) + .map( + ([name, p]): Prompt => ({ + name, + title: p.title, + description: p.description, + arguments: p.argsSchema ? promptArgumentsFromStandardSchema(p.argsSchema) : undefined, + _meta: p._meta + }) + ) + }) + ); + + this.setRequestHandler('prompts/get', async (request, ctx): Promise => { + const prompt = this._registeredPrompts[request.params.name]; + if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); + if (!prompt.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); + return prompt.handler(request.params.arguments, ctx); + }); + + this._promptHandlersInitialized = true; + } + + /** + * Registers a resource with a config object and callback. + */ + registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; + registerResource( + name: string, + uriOrTemplate: string | ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceCallback | ReadResourceTemplateCallback + ): RegisteredResource | RegisteredResourceTemplate { + if (typeof uriOrTemplate === 'string') { + if (this._registeredResources[uriOrTemplate]) throw new Error(`Resource ${uriOrTemplate} is already registered`); + const r = this._createRegisteredResource( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return r; + } else { + if (this._registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`); + const r = this._createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return r; + } + } + + private _createRegisteredResource( + name: string, + title: string | undefined, + uri: string, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceCallback + ): RegisteredResource { + const r: RegisteredResource = { + name, + title, + metadata, + readCallback, + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ uri: null }), + update: updates => { + if (updates.uri !== undefined && updates.uri !== uri) { + delete this._registeredResources[uri]; + if (updates.uri) this._registeredResources[updates.uri] = r; + } + if (updates.name !== undefined) r.name = updates.name; + if (updates.title !== undefined) r.title = updates.title; + if (updates.metadata !== undefined) r.metadata = updates.metadata; + if (updates.callback !== undefined) r.readCallback = updates.callback; + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.sendResourceListChanged(); + } + }; + this._registeredResources[uri] = r; + return r; + } + + private _createRegisteredResourceTemplate( + name: string, + title: string | undefined, + template: ResourceTemplate, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate { + const r: RegisteredResourceTemplate = { + resourceTemplate: template, + title, + metadata, + readCallback, + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + delete this._registeredResourceTemplates[name]; + if (updates.name) this._registeredResourceTemplates[updates.name] = r; + } + if (updates.title !== undefined) r.title = updates.title; + if (updates.template !== undefined) r.resourceTemplate = updates.template; + if (updates.metadata !== undefined) r.metadata = updates.metadata; + if (updates.callback !== undefined) r.readCallback = updates.callback; + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.sendResourceListChanged(); + } + }; + this._registeredResourceTemplates[name] = r; + const variableNames = template.uriTemplate.variableNames; + const hasCompleter = Array.isArray(variableNames) && variableNames.some(v => !!template.completeCallback(v)); + if (hasCompleter) this.setCompletionRequestHandler(); + return r; + } + + private _createRegisteredPrompt( + name: string, + title: string | undefined, + description: string | undefined, + argsSchema: StandardSchemaWithJSON | undefined, + callback: PromptCallback, + _meta: Record | undefined + ): RegisteredPrompt { + let currentArgsSchema = argsSchema; + let currentCallback = callback; + const r: RegisteredPrompt = { + title, + description, + argsSchema, + _meta, + handler: createPromptHandler(name, argsSchema, callback), + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + delete this._registeredPrompts[name]; + if (updates.name) this._registeredPrompts[updates.name] = r; + } + if (updates.title !== undefined) r.title = updates.title; + if (updates.description !== undefined) r.description = updates.description; + if (updates._meta !== undefined) r._meta = updates._meta; + let needsRegen = false; + if (updates.argsSchema !== undefined) { + r.argsSchema = updates.argsSchema; + currentArgsSchema = updates.argsSchema; + needsRegen = true; + } + if (updates.callback !== undefined) { + currentCallback = updates.callback as PromptCallback; + needsRegen = true; + } + if (needsRegen) r.handler = createPromptHandler(name, currentArgsSchema, currentCallback); + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.sendPromptListChanged(); + } + }; + this._registeredPrompts[name] = r; + if (argsSchema) { + const shape = getSchemaShape(argsSchema); + if (shape) { + const hasCompletable = Object.values(shape).some(f => isCompletable(unwrapOptionalSchema(f))); + if (hasCompletable) this.setCompletionRequestHandler(); + } + } + return r; + } + + private _createRegisteredTool( + name: string, + title: string | undefined, + description: string | undefined, + inputSchema: StandardSchemaWithJSON | undefined, + outputSchema: StandardSchemaWithJSON | undefined, + annotations: ToolAnnotations | undefined, + execution: ToolExecution | undefined, + _meta: Record | undefined, + handler: AnyToolHandler + ): RegisteredTool { + validateAndWarnToolName(name); + let currentHandler = handler; + const r: RegisteredTool = { + title, + description, + inputSchema, + outputSchema, + annotations, + execution, + _meta, + handler, + executor: createToolExecutor(inputSchema, handler), + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + if (typeof updates.name === 'string') validateAndWarnToolName(updates.name); + delete this._registeredTools[name]; + if (updates.name) this._registeredTools[updates.name] = r; + } + if (updates.title !== undefined) r.title = updates.title; + if (updates.description !== undefined) r.description = updates.description; + let needsRegen = false; + if (updates.paramsSchema !== undefined) { + r.inputSchema = updates.paramsSchema; + needsRegen = true; + } + if (updates.callback !== undefined) { + r.handler = updates.callback; + currentHandler = updates.callback as AnyToolHandler; + needsRegen = true; + } + if (needsRegen) r.executor = createToolExecutor(r.inputSchema, currentHandler); + if (updates.outputSchema !== undefined) r.outputSchema = updates.outputSchema; + if (updates.annotations !== undefined) r.annotations = updates.annotations; + if (updates._meta !== undefined) r._meta = updates._meta; + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.sendToolListChanged(); + } + }; + this._registeredTools[name] = r; + this.setToolRequestHandlers(); + this.sendToolListChanged(); + return r; + } + + /** + * Registers a tool with a config object and callback. + */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback + ): RegisteredTool { + if (this._registeredTools[name]) throw new Error(`Tool ${name} is already registered`); + const { title, description, inputSchema, outputSchema, annotations, _meta } = config; + return this._createRegisteredTool( + name, + title, + description, + inputSchema, + outputSchema, + annotations, + { taskSupport: 'forbidden' }, + _meta, + cb as ToolCallback + ); + } + + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { title?: string; description?: string; argsSchema?: Args; _meta?: Record }, + cb: PromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); + const { title, description, argsSchema, _meta } = config; + const r = this._createRegisteredPrompt(name, title, description, argsSchema, cb as PromptCallback, _meta); + this.setPromptRequestHandlers(); + this.sendPromptListChanged(); + return r; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// ResourceTemplate +// ─────────────────────────────────────────────────────────────────────────── + +/** + * A callback to complete one variable within a resource template's URI template. + */ +export type CompleteResourceTemplateCallback = ( + value: string, + context?: { arguments?: Record } +) => string[] | Promise; + +/** + * A resource template combines a URI pattern with optional functionality to enumerate + * all resources matching that pattern. + */ +export class ResourceTemplate { + private _uriTemplate: UriTemplate; + + constructor( + uriTemplate: string | UriTemplate, + private _callbacks: { + list: ListResourcesCallback | undefined; + complete?: { [variable: string]: CompleteResourceTemplateCallback }; + } + ) { + this._uriTemplate = typeof uriTemplate === 'string' ? new UriTemplate(uriTemplate) : uriTemplate; + } + + get uriTemplate(): UriTemplate { + return this._uriTemplate; + } + + get listCallback(): ListResourcesCallback | undefined { + return this._callbacks.list; + } + + completeCallback(variable: string): CompleteResourceTemplateCallback | undefined { + return this._callbacks.complete?.[variable]; + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Public types +// ─────────────────────────────────────────────────────────────────────────── + +export type BaseToolCallback< + SendResultT extends Result, + Ctx extends ServerContext, + Args extends StandardSchemaWithJSON | undefined +> = Args extends StandardSchemaWithJSON + ? (args: StandardSchemaWithJSON.InferOutput, ctx: Ctx) => SendResultT | Promise + : (ctx: Ctx) => SendResultT | Promise; + +export type ToolCallback = BaseToolCallback< + CallToolResult, + ServerContext, + Args +>; + +export type AnyToolHandler = ToolCallback | ToolTaskHandler; + +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; + +export type RegisteredTool = { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + execution?: ToolExecution; + _meta?: Record; + handler: AnyToolHandler; + /** @hidden */ + executor: ToolExecutor; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + _meta?: Record; + callback?: ToolCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +export type ResourceMetadata = Omit; +export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; +export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; + +export type RegisteredResource = { + name: string; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string; + title?: string; + uri?: string | null; + metadata?: ResourceMetadata; + callback?: ReadResourceCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +export type ReadResourceTemplateCallback = ( + uri: URL, + variables: Variables, + ctx: ServerContext +) => ReadResourceResult | Promise; + +export type RegisteredResourceTemplate = { + resourceTemplate: ResourceTemplate; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceTemplateCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + template?: ResourceTemplate; + metadata?: ResourceMetadata; + callback?: ReadResourceTemplateCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +export type PromptCallback = Args extends StandardSchemaWithJSON + ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise + : (ctx: ServerContext) => GetPromptResult | Promise; + +type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; +type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; +type TaskHandlerInternal = { + createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; +}; + +export type RegisteredPrompt = { + title?: string; + description?: string; + argsSchema?: StandardSchemaWithJSON; + _meta?: Record; + /** @hidden */ + handler: PromptHandler; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + callback?: PromptCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Helpers +// ─────────────────────────────────────────────────────────────────────────── + +const EMPTY_OBJECT_JSON_SCHEMA = { type: 'object' as const, properties: {} }; +const EMPTY_COMPLETION_RESULT: CompleteResult = { completion: { values: [], hasMore: false } }; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { + const values = suggestions.map(String).slice(0, 100); + return { completion: { values, total: suggestions.length, hasMore: suggestions.length > 100 } }; +} + +function createToolExecutor( + inputSchema: StandardSchemaWithJSON | undefined, + handler: AnyToolHandler +): ToolExecutor { + const isTaskHandler = 'createTask' in handler; + if (isTaskHandler) { + const th = handler as TaskHandlerInternal; + return async (args, ctx) => { + if (!ctx.task?.store) throw new Error('No task store provided.'); + const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; + if (inputSchema) return th.createTask(args, taskCtx); + return (th.createTask as (ctx: CreateTaskServerContext) => CreateTaskResult | Promise)(taskCtx); + }; + } + if (inputSchema) { + const cb = handler as ToolCallbackInternal; + return async (args, ctx) => cb(args, ctx); + } + const cb = handler as (ctx: ServerContext) => CallToolResult | Promise; + return async (_args, ctx) => cb(ctx); +} + +function createPromptHandler( + name: string, + argsSchema: StandardSchemaWithJSON | undefined, + callback: PromptCallback +): PromptHandler { + if (argsSchema) { + const typed = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; + return async (args, ctx) => { + const parsed = await validateStandardSchema(argsSchema, args); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parsed.error}`); + } + return typed(parsed.data, ctx); + }; + } + const typed = callback as (ctx: ServerContext) => GetPromptResult | Promise; + return async (_args, ctx) => typed(ctx); +} + +function getSchemaShape(schema: unknown): Record | undefined { + const c = schema as { shape?: unknown }; + if (c.shape && typeof c.shape === 'object') return c.shape as Record; + return undefined; +} + +function isOptionalSchema(schema: unknown): boolean { + return (schema as { type?: string } | null | undefined)?.type === 'optional'; +} + +function unwrapOptionalSchema(schema: unknown): unknown { + if (!isOptionalSchema(schema)) return schema; + const c = schema as { def?: { innerType?: unknown } }; + return c.def?.innerType ?? schema; +} diff --git a/packages/server/test/mcpServer.test.ts b/packages/server/test/mcpServer.test.ts new file mode 100644 index 000000000..f2262335b --- /dev/null +++ b/packages/server/test/mcpServer.test.ts @@ -0,0 +1,271 @@ +import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCRequest, JSONRPCResultResponse } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isJSONRPCErrorResponse, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type R = JSONRPCResultResponse & { result: any }; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { McpServer, ResourceTemplate } from '../src/server/mcpServer.js'; + +const req = (id: number, method: string, params?: Record): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method, + params +}); + +const initReq = (id = 0): JSONRPCRequest => + req(id, 'initialize', { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { elicitation: { form: {} } }, + clientInfo: { name: 't', version: '1' } + }); + +async function collect(it: AsyncIterable): Promise { + const out: JSONRPCMessage[] = []; + for await (const m of it) out.push(m); + return out; +} + +async function lastResponse(it: AsyncIterable): Promise { + const all = await collect(it); + const last = all[all.length - 1]; + if (!isJSONRPCResultResponse(last) && !isJSONRPCErrorResponse(last)) throw new Error('no terminal response'); + return last; +} + +describe('McpServer.handle()', () => { + it('responds to initialize with serverInfo and capabilities', async () => { + const s = new McpServer({ name: 'srv', version: '1.0.0' }, { instructions: 'hi' }); + const r = (await lastResponse(s.handle(initReq(1)))) as R; + expect(r.id).toBe(1); + expect(r.result.serverInfo).toEqual({ name: 'srv', version: '1.0.0' }); + expect(r.result.instructions).toBe('hi'); + expect(r.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + }); + + it('responds to ping', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const r = (await lastResponse(s.handle(req(1, 'ping')))) as R; + expect(r.result).toEqual({}); + }); + + it('returns MethodNotFound for unknown method', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const r = (await lastResponse(s.handle(req(1, 'nope/nope' as never)))) as JSONRPCErrorResponse; + expect(r.error.code).toBe(-32601); + }); + + it('registerTool + tools/list returns the tool', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('echo', { description: 'd', inputSchema: z.object({ x: z.string() }) }, async ({ x }) => ({ + content: [{ type: 'text', text: x }] + })); + const r = (await lastResponse(s.handle(req(1, 'tools/list')))) as R; + expect(r.result.tools).toHaveLength(1); + expect(r.result.tools[0].name).toBe('echo'); + expect(r.result.tools[0].inputSchema.type).toBe('object'); + }); + + it('tools/call invokes handler with validated args', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('echo', { inputSchema: z.object({ x: z.string() }) }, async ({ x }) => ({ + content: [{ type: 'text', text: `got ${x}` }] + })); + const r = (await lastResponse(s.handle(req(1, 'tools/call', { name: 'echo', arguments: { x: 'hi' } })))) as R; + expect(r.result.content[0].text).toBe('got hi'); + }); + + it('tools/call with invalid args returns isError result', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('echo', { inputSchema: z.object({ x: z.string() }) }, async ({ x }) => ({ + content: [{ type: 'text', text: x }] + })); + const r = (await lastResponse(s.handle(req(1, 'tools/call', { name: 'echo', arguments: { x: 42 } })))) as R; + expect(r.result.isError).toBe(true); + }); + + it('tools/call with unknown tool returns InvalidParams error response', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('a', {}, async () => ({ content: [] })); + const r = (await lastResponse(s.handle(req(1, 'tools/call', { name: 'b', arguments: {} })))) as JSONRPCErrorResponse; + expect(r.error.code).toBe(-32602); + expect(r.error.message).toContain('not found'); + }); + + it('handle yields notifications then a terminal response', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('progress', {}, async ctx => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 1, progress: 0.5 } }); + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 1, progress: 1.0 } }); + return { content: [{ type: 'text', text: 'done' }] }; + }); + const msgs = await collect(s.handle(req(1, 'tools/call', { name: 'progress', arguments: {} }))); + expect(msgs).toHaveLength(3); + expect((msgs[0] as { method: string }).method).toBe('notifications/progress'); + expect((msgs[1] as { method: string }).method).toBe('notifications/progress'); + expect(isJSONRPCResultResponse(msgs[2])).toBe(true); + }); + + it('ctx.mcpReq.elicitInput throws when no peer channel (handle without env.send)', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('ask', {}, async ctx => { + await ctx.mcpReq.elicitInput({ message: 'q', requestedSchema: { type: 'object', properties: {} } }); + return { content: [] }; + }); + const r = (await lastResponse(s.handle(req(1, 'tools/call', { name: 'ask', arguments: {} })))) as R; + expect(r.result.isError).toBe(true); + expect(r.result.content[0].text).toContain('MRTR-native'); + }); + + it('ctx.mcpReq.elicitInput resolves when env.send provided', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('ask', {}, async ctx => { + const er = await ctx.mcpReq.elicitInput({ message: 'q', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: er.action }] }; + }); + const r = (await lastResponse( + s.handle(req(1, 'tools/call', { name: 'ask', arguments: {} }), { + send: async () => ({ action: 'accept', content: {} }) + }) + )) as R; + expect(r.result.content[0].text).toBe('accept'); + }); + + it('registerResource + resources/list + resources/read', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerResource('cfg', 'config://app', { mimeType: 'text/plain' }, async uri => ({ + contents: [{ uri: uri.href, text: 'v' }] + })); + const list = (await lastResponse(s.handle(req(1, 'resources/list')))) as R; + expect(list.result.resources[0].uri).toBe('config://app'); + const read = (await lastResponse(s.handle(req(2, 'resources/read', { uri: 'config://app' })))) as R; + expect(read.result.contents[0].text).toBe('v'); + }); + + it('registerResource with template + resources/read matches', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerResource('user', new ResourceTemplate('users://{id}', { list: undefined }), {}, async (uri, { id }) => ({ + contents: [{ uri: uri.href, text: String(id) }] + })); + const r = (await lastResponse(s.handle(req(1, 'resources/read', { uri: 'users://abc' })))) as R; + expect(r.result.contents[0].text).toBe('abc'); + }); + + it('registerPrompt + prompts/list + prompts/get', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerPrompt('p', { argsSchema: z.object({ q: z.string() }) }, ({ q }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: q } }] + })); + const list = (await lastResponse(s.handle(req(1, 'prompts/list')))) as R; + expect(list.result.prompts[0].name).toBe('p'); + const get = (await lastResponse(s.handle(req(2, 'prompts/get', { name: 'p', arguments: { q: 'hi' } })))) as R; + expect(get.result.messages[0].content.text).toBe('hi'); + }); + + it('RegisteredTool.disable hides from tools/list', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const t = s.registerTool('x', {}, async () => ({ content: [] })); + t.disable(); + const r = (await lastResponse(s.handle(req(1, 'tools/list')))) as R; + expect(r.result.tools).toHaveLength(0); + }); + + it('handleHttp parses body and returns JSON response', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const httpReq = new Request('http://x/mcp', { + method: 'POST', + body: JSON.stringify(req(1, 'ping')), + headers: { 'content-type': 'application/json' } + }); + const res = await s.handleHttp(httpReq); + expect(res.status).toBe(200); + const body = (await res.json()) as R; + expect(body.id).toBe(1); + expect(body.result).toEqual({}); + }); + + it('handleHttp returns 400 on parse error', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const res = await s.handleHttp(new Request('http://x/mcp', { method: 'POST', body: '{broken' })); + expect(res.status).toBe(400); + }); + + it('handleHttp returns 202 for notification-only body', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const res = await s.handleHttp( + new Request('http://x/mcp', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + }) + ); + expect(res.status).toBe(202); + }); +}); + +describe('McpServer compat / .server / connect()', () => { + it('.server === this', () => { + const s = new McpServer({ name: 's', version: '1' }); + expect(s.server).toBe(s); + }); + + it('isConnected reflects connect/close', async () => { + const s = new McpServer({ name: 's', version: '1' }); + expect(s.isConnected()).toBe(false); + const [a, b] = InMemoryTransport.createLinkedPair(); + await s.connect(a); + expect(s.isConnected()).toBe(true); + expect(s.transport).toBe(a); + void b; + await s.close(); + expect(s.isConnected()).toBe(false); + }); + + it('connect() then peer can send tools/list', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('t', {}, async () => ({ content: [] })); + const [serverPipe, clientPipe] = InMemoryTransport.createLinkedPair(); + await s.connect(serverPipe); + await clientPipe.start(); + + const responses: JSONRPCMessage[] = []; + clientPipe.onmessage = m => responses.push(m); + + await clientPipe.send(initReq(0)); + await clientPipe.send(req(1, 'tools/list')); + await new Promise(r => setTimeout(r, 10)); + + const listResp = responses.find(m => isJSONRPCResultResponse(m) && m.id === 1) as R; + expect(listResp.result.tools[0].name).toBe('t'); + }); + + it('connect() twice throws AlreadyConnected', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const [a] = InMemoryTransport.createLinkedPair(); + await s.connect(a); + const [c] = InMemoryTransport.createLinkedPair(); + await expect(s.connect(c)).rejects.toThrow(); + }); + + it('elicitInput() instance method throws NotConnected when no driver', async () => { + const s = new McpServer({ name: 's', version: '1' }); + await expect( + s.elicitInput({ message: 'q', requestedSchema: { type: 'object', properties: {} } }) + ).rejects.toThrow(/not connected/i); + }); + + it('registerCapabilities throws after connect', async () => { + const s = new McpServer({ name: 's', version: '1' }); + const [a] = InMemoryTransport.createLinkedPair(); + await s.connect(a); + expect(() => s.registerCapabilities({ logging: {} })).toThrow(); + }); + + it('initialize via handle() populates getClientCapabilities', async () => { + const s = new McpServer({ name: 's', version: '1' }); + await lastResponse(s.handle(initReq(0))); + expect(s.getClientCapabilities()?.elicitation?.form).toBeDefined(); + expect(s.getClientVersion()?.name).toBe('t'); + }); +}); From d814138787a83cb01ce2b775b3efa4c8cb1a2f41 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:14:32 +0000 Subject: [PATCH 03/55] feat(server): McpServer extends Dispatcher with handle(); shttpHandler + SessionCompat - McpServer (1432 LOC): merged mcp.ts + server.ts, extends Dispatcher - shttpHandler (398 LOC): request/response SHTTP core, no stream-mapping - sessionCompat (197 LOC): bounded LRU 2025-11 session compat - 97 server tests passing, typecheck clean workspace-wide --- packages/client/src/client/clientV2.ts | 469 ++++++++++++++++++ .../client/src/client/streamableHttpV2.ts | 287 +++++++++++ packages/client/test/client/clientV2.test.ts | 284 +++++++++++ packages/server/src/server/shttpHandler.ts | 398 +++++++++++++++ .../server/test/server/shttpHandler.test.ts | 251 ++++++++++ 5 files changed, 1689 insertions(+) create mode 100644 packages/client/src/client/clientV2.ts create mode 100644 packages/client/src/client/streamableHttpV2.ts create mode 100644 packages/client/test/client/clientV2.test.ts create mode 100644 packages/server/src/server/shttpHandler.ts create mode 100644 packages/server/test/server/shttpHandler.test.ts diff --git a/packages/client/src/client/clientV2.ts b/packages/client/src/client/clientV2.ts new file mode 100644 index 000000000..a1953e1f9 --- /dev/null +++ b/packages/client/src/client/clientV2.ts @@ -0,0 +1,469 @@ +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; +import type { + CallToolRequest, + ClientCapabilities, + ClientContext, + CompleteRequest, + GetPromptRequest, + Implementation, + JSONRPCNotification, + JSONRPCRequest, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + ListChangedHandlers, + ListPromptsRequest, + ListResourcesRequest, + ListResourceTemplatesRequest, + ListToolsRequest, + LoggingLevel, + Notification, + NotificationMethod, + ReadResourceRequest, + Request, + RequestMethod, + RequestOptions, + RequestTypeMap, + Result, + ResultTypeMap, + ServerCapabilities, + SubscribeRequest, + Tool, + Transport, + UnsubscribeRequest +} from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + CompleteResultSchema, + EmptyResultSchema, + GetPromptResultSchema, + InitializeResultSchema, + LATEST_PROTOCOL_VERSION, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListToolsResultSchema, + mergeCapabilities, + ReadResourceResultSchema, + parseSchema, + ProtocolError, + ProtocolErrorCode, + SdkError, + SdkErrorCode, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +// TODO(ts-rebuild): replace with `from '@modelcontextprotocol/core'` once the core barrel exports Dispatcher. +import { Dispatcher } from '../../../core/src/shared/dispatcher.js'; + +import type { AnySchema, SchemaOutput } from '../../../core/src/util/schema.js'; +import type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; +import { isJSONRPCErrorResponse, isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; + +/** + * Loose envelope for the (draft) 2026-06 MRTR `input_required` result. Typed + * minimally so this compiles before the spec types land; runtime detection is + * by shape. + */ +type InputRequiredEnvelope = { + ResultType: 'input_required'; + InputRequests: Record }>; +}; +function isInputRequired(r: unknown): r is InputRequiredEnvelope { + return ( + typeof r === 'object' && + r !== null && + (r as { ResultType?: unknown }).ResultType === 'input_required' && + typeof (r as { InputRequests?: unknown }).InputRequests === 'object' + ); +} + +const MRTR_INPUT_RESPONSES_META_KEY = 'modelcontextprotocol.io/mrtr/inputResponses'; +const DEFAULT_MRTR_MAX_ROUNDS = 16; + +export type ClientOptions = { + /** Capabilities to advertise to the server. */ + capabilities?: ClientCapabilities; + /** Validator for tool `outputSchema`. Defaults to the runtime-appropriate Ajv/CF validator. */ + jsonSchemaValidator?: jsonSchemaValidator; + /** Handlers for `notifications/*_list_changed`. */ + listChanged?: ListChangedHandlers; + /** Protocol versions this client supports. First entry is preferred. */ + supportedProtocolVersions?: string[]; + /** + * If true, list* methods throw on missing server capability instead of + * returning empty. Default false. + */ + enforceStrictCapabilities?: boolean; + /** + * Upper bound on MRTR rounds for one logical request before throwing + * {@linkcode SdkErrorCode.InternalError}. Default 16. + */ + mrtrMaxRounds?: number; +}; + +/** + * MCP client built on a request-shaped {@linkcode ClientTransport}. + * + * - 2026-06-native: every request is independent; `request()` runs the MRTR + * loop, servicing `input_required` rounds via locally registered handlers. + * - 2025-11-compat: {@linkcode connect} accepts the legacy pipe-shaped + * {@linkcode Transport} and runs the initialize handshake. + */ +export class Client { + private _ct?: ClientTransport; + private _localDispatcher: Dispatcher = new Dispatcher(); + private _capabilities: ClientCapabilities; + private _serverCapabilities?: ServerCapabilities; + private _serverVersion?: Implementation; + private _instructions?: string; + private _negotiatedProtocolVersion?: string; + private _supportedProtocolVersions: string[]; + private _enforceStrictCapabilities: boolean; + private _mrtrMaxRounds: number; + private _jsonSchemaValidator: jsonSchemaValidator; + private _cachedToolOutputValidators: Map> = new Map(); + private _cachedKnownTaskTools: Set = new Set(); + private _cachedRequiredTaskTools: Set = new Set(); + private _requestMessageId = 0; + private _pendingListChangedConfig?: ListChangedHandlers; + + onclose?: () => void; + onerror?: (error: Error) => void; + + constructor( + private _clientInfo: Implementation, + options?: ClientOptions + ) { + this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._supportedProtocolVersions = options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + this._mrtrMaxRounds = options?.mrtrMaxRounds ?? DEFAULT_MRTR_MAX_ROUNDS; + this._pendingListChangedConfig = options?.listChanged; + this._localDispatcher.setRequestHandler('ping', async () => ({})); + } + + /** + * Connects to a server. Accepts either a {@linkcode ClientTransport} + * (2026-06-native, request-shaped) or a legacy pipe {@linkcode Transport} + * (stdio, SSE, the v1 SHTTP class). Pipe transports are adapted via + * {@linkcode pipeAsClientTransport} and the 2025-11 initialize handshake + * is performed. + */ + async connect(transport: Transport | ClientTransport, options?: RequestOptions): Promise { + if (isPipeTransport(transport)) { + this._ct = pipeAsClientTransport(transport, this._localDispatcher); + this._ct.driver!.onclose = () => this.onclose?.(); + this._ct.driver!.onerror = e => this.onerror?.(e); + const skipInit = transport.sessionId !== undefined; + if (skipInit) { + if (this._negotiatedProtocolVersion && transport.setProtocolVersion) { + transport.setProtocolVersion(this._negotiatedProtocolVersion); + } + return; + } + try { + await this._initializeHandshake(options, v => transport.setProtocolVersion?.(v)); + } catch (error) { + void this.close(); + throw error; + } + return; + } + this._ct = transport; + try { + await this._discoverOrInitialize(options); + } catch (error) { + void this.close(); + throw error; + } + } + + async close(): Promise { + const ct = this._ct; + this._ct = undefined; + await ct?.close(); + this.onclose?.(); + } + + get transport(): Transport | undefined { + return this._ct?.driver?.pipe; + } + + /** Register additional capabilities. Must be called before {@linkcode connect}. */ + registerCapabilities(capabilities: ClientCapabilities): void { + if (this._ct) throw new Error('Cannot register capabilities after connecting to transport'); + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + } + + getServerCapabilities(): ServerCapabilities | undefined { + return this._serverCapabilities; + } + getServerVersion(): Implementation | undefined { + return this._serverVersion; + } + getNegotiatedProtocolVersion(): string | undefined { + return this._negotiatedProtocolVersion; + } + getInstructions(): string | undefined { + return this._instructions; + } + + /** + * Register a handler for server-initiated requests (sampling, elicitation, + * roots, ping). In MRTR mode these handlers service `input_required` rounds. + * In pipe mode they are dispatched directly by the {@linkcode StreamDriver}. + */ + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise + ): void { + this._localDispatcher.setRequestHandler(method, handler); + } + removeRequestHandler(method: string): void { + this._localDispatcher.removeRequestHandler(method); + } + setNotificationHandler(method: M, handler: (n: Notification) => void | Promise): void { + this._localDispatcher.setNotificationHandler(method, handler as never); + } + removeNotificationHandler(method: string): void { + this._localDispatcher.removeNotificationHandler(method); + } + set fallbackNotificationHandler(h: ((n: Notification) => Promise) | undefined) { + this._localDispatcher.fallbackNotificationHandler = h; + } + + /** Low-level: send one typed request. Runs the MRTR loop. */ + async request(req: { method: M; params?: RequestTypeMap[M]['params'] }, options?: RequestOptions) { + const schema = (await import('@modelcontextprotocol/core')).getResultSchema(req.method); + return this._request({ method: req.method, params: req.params }, schema, options) as Promise; + } + + /** Low-level: send a notification to the server. */ + async notification(n: Notification): Promise { + if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + await this._ct.notify(n); + } + + // -- typed RPC sugar (ported from client.ts) ------------------------------ + + async ping(options?: RequestOptions) { + return this._request({ method: 'ping' }, EmptyResultSchema, options); + } + async complete(params: CompleteRequest['params'], options?: RequestOptions) { + return this._request({ method: 'completion/complete', params }, CompleteResultSchema, options); + } + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + return this._request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + } + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { + return this._request({ method: 'prompts/get', params }, GetPromptResultSchema, options); + } + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) return { prompts: [] }; + return this._request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + } + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) return { resources: [] }; + return this._request({ method: 'resources/list', params }, ListResourcesResultSchema, options); + } + async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) return { resourceTemplates: [] }; + return this._request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + } + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { + return this._request({ method: 'resources/read', params }, ReadResourceResultSchema, options); + } + async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { + return this._request({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + } + async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { + return this._request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + } + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) return { tools: [] }; + const result = await this._request({ method: 'tools/list', params }, ListToolsResultSchema, options); + this._cacheToolMetadata(result.tools); + return result; + } + async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + if (this._cachedRequiredTaskTools.has(params.name)) { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` + ); + } + const result = await this._request({ method: 'tools/call', params }, CallToolResultSchema, options); + const validator = this._cachedToolOutputValidators.get(params.name); + if (validator) { + if (!result.structuredContent && !result.isError) { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ); + } + if (result.structuredContent) { + const v = validator(result.structuredContent); + if (!v.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${v.errorMessage}` + ); + } + } + } + return result; + } + async sendRootsListChanged() { + return this.notification({ method: 'notifications/roots/list_changed' }); + } + + // -- internals ----------------------------------------------------------- + + private async _request(req: Request, resultSchema: T, options?: RequestOptions): Promise> { + if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + let inputResponses: Record = {}; + for (let round = 0; round < this._mrtrMaxRounds; round++) { + const id = this._requestMessageId++; + const meta = { + ...(req.params?._meta as Record | undefined), + ...(round > 0 ? { [MRTR_INPUT_RESPONSES_META_KEY]: inputResponses } : {}) + }; + const jr: JSONRPCRequest = { + jsonrpc: '2.0', + id, + method: req.method, + params: req.params || round > 0 ? { ...req.params, _meta: Object.keys(meta).length > 0 ? meta : undefined } : undefined + }; + const opts: ClientFetchOptions = { + signal: options?.signal, + timeout: options?.timeout, + resetTimeoutOnProgress: options?.resetTimeoutOnProgress, + maxTotalTimeout: options?.maxTotalTimeout, + onprogress: options?.onprogress, + onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(e => this.onerror?.(e)) + }; + const resp = await this._ct.fetch(jr, opts); + if (isJSONRPCErrorResponse(resp)) { + throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); + } + const raw = resp.result; + if (isInputRequired(raw)) { + inputResponses = { ...inputResponses, ...(await this._serviceInputRequests(raw.InputRequests)) }; + continue; + } + const parsed = parseSchema(resultSchema, raw); + if (!parsed.success) throw parsed.error; + return parsed.data as SchemaOutput; + } + throw new ProtocolError(ProtocolErrorCode.InternalError, `MRTR exceeded ${this._mrtrMaxRounds} rounds for ${req.method}`); + } + + private async _serviceInputRequests( + reqs: Record }> + ): Promise> { + const out: Record = {}; + for (const [key, ir] of Object.entries(reqs)) { + const synthetic: JSONRPCRequest = { jsonrpc: '2.0', id: `mrtr:${key}`, method: ir.method, params: ir.params }; + const resp = await this._localDispatcher.dispatchToResponse(synthetic); + if (isJSONRPCErrorResponse(resp)) { + throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); + } + out[key] = resp.result; + } + return out; + } + + private async _initializeHandshake(options: RequestOptions | undefined, setProtocolVersion: (v: string) => void): Promise { + const result = await this._request( + { + method: 'initialize', + params: { + protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, + InitializeResultSchema, + options + ); + if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } + this._serverCapabilities = result.capabilities; + this._serverVersion = result.serverInfo; + this._negotiatedProtocolVersion = result.protocolVersion; + this._instructions = result.instructions; + setProtocolVersion(result.protocolVersion); + await this.notification({ method: 'notifications/initialized' }); + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } + + private async _discoverOrInitialize(options?: RequestOptions): Promise { + // 2026-06: try server/discover, fall back to initialize. Discover schema + // is not yet in spec types, so probe and accept the result loosely. + try { + const resp = await this._ct!.fetch( + { jsonrpc: '2.0', id: this._requestMessageId++, method: 'server/discover' as RequestMethod }, + { timeout: options?.timeout, signal: options?.signal } + ); + if (!isJSONRPCErrorResponse(resp)) { + const r = resp.result as { capabilities?: ServerCapabilities; serverInfo?: Implementation; instructions?: string }; + this._serverCapabilities = r.capabilities; + this._serverVersion = r.serverInfo; + this._instructions = r.instructions; + return; + } + if (resp.error.code !== ProtocolErrorCode.MethodNotFound) { + throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); + } + } catch (error) { + if (!(error instanceof ProtocolError) || (error as ProtocolError).code !== ProtocolErrorCode.MethodNotFound) { + // Non-method-not-found error from discover: surface it. + if (error instanceof ProtocolError) throw error; + } + } + await this._initializeHandshake(options, () => {}); + } + + private _cacheToolMetadata(tools: Tool[]): void { + this._cachedToolOutputValidators.clear(); + this._cachedKnownTaskTools.clear(); + this._cachedRequiredTaskTools.clear(); + for (const tool of tools) { + if (tool.outputSchema) { + this._cachedToolOutputValidators.set(tool.name, this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType)); + } + const ts = tool.execution?.taskSupport; + if (ts === 'required' || ts === 'optional') this._cachedKnownTaskTools.add(tool.name); + if (ts === 'required') this._cachedRequiredTaskTools.add(tool.name); + } + } + + private _setupListChangedHandlers(config: ListChangedHandlers): void { + const wire = (kind: 'tools' | 'prompts' | 'resources', notif: NotificationMethod, fetch: () => Promise) => { + const c = config[kind]; + if (!c) return; + const cap = this._serverCapabilities?.[kind] as { listChanged?: boolean } | undefined; + if (!cap?.listChanged) return; + this._localDispatcher.setNotificationHandler(notif, async () => { + if (c.autoRefresh === false) return c.onChanged(null, null); + try { + c.onChanged(null, (await fetch()) as never); + } catch (e) { + c.onChanged(e instanceof Error ? e : new Error(String(e)), null); + } + }); + }; + wire('tools', 'notifications/tools/list_changed', async () => (await this.listTools()).tools); + wire('prompts', 'notifications/prompts/list_changed', async () => (await this.listPrompts()).prompts ?? []); + wire('resources', 'notifications/resources/list_changed', async () => (await this.listResources()).resources ?? []); + } +} + +export type { ClientTransport, ClientFetchOptions } from './clientTransport.js'; +export { pipeAsClientTransport, isPipeTransport } from './clientTransport.js'; diff --git a/packages/client/src/client/streamableHttpV2.ts b/packages/client/src/client/streamableHttpV2.ts new file mode 100644 index 000000000..cf30b6a18 --- /dev/null +++ b/packages/client/src/client/streamableHttpV2.ts @@ -0,0 +1,287 @@ +import type { FetchLike, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, JSONRPCResultResponse } from '@modelcontextprotocol/core'; +import { + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCResultResponse, + JSONRPCMessageSchema, + normalizeHeaders, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; + +import type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; + +export interface StreamableHttpReconnectionOptions { + initialReconnectionDelay: number; + maxReconnectionDelay: number; + reconnectionDelayGrowFactor: number; + maxRetries: number; +} + +const DEFAULT_RECONNECT: StreamableHttpReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30_000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2 +}; + +export type StreamableHttpClientTransportV2Options = { + /** + * Custom `fetch`. Auth composes here via `withOAuth(fetch)` middleware + * instead of being baked into the transport. + */ + fetch?: FetchLike; + /** Extra headers/init merged into every request. */ + requestInit?: RequestInit; + /** Reconnection backoff for resumable SSE responses. */ + reconnectionOptions?: StreamableHttpReconnectionOptions; + /** + * Seed session id for reconnecting to an existing session + * (2025-11 stateful servers). + */ + sessionId?: string; + /** Seed protocol version header for reconnect-without-init. */ + protocolVersion?: string; +}; + +/** + * Request-shaped Streamable HTTP client transport (Proposal 9). One POST per + * {@linkcode fetch}; the response body may be JSON or an SSE stream. Progress + * and other notifications are surfaced via {@linkcode ClientFetchOptions} + * callbacks; the returned promise resolves with the terminal response. + * + * Auth retry is intentionally not implemented here. Compose via + * `withOAuth(fetch)` and pass as {@linkcode StreamableHttpClientTransportV2Options.fetch}. + * + * The transport is stateful internally for 2025-11 compat: it captures + * `mcp-session-id` from response headers and echoes it on subsequent requests. + * That state is private; nothing on the {@linkcode ClientTransport} contract + * exposes it. + */ +export class StreamableHttpClientTransportV2 implements ClientTransport { + private _fetch: FetchLike; + private _requestInit?: RequestInit; + private _sessionId?: string; + private _protocolVersion?: string; + private _reconnect: StreamableHttpReconnectionOptions; + private _abort = new AbortController(); + private _serverRetryMs?: number; + + constructor( + private _url: URL, + opts: StreamableHttpClientTransportV2Options = {} + ) { + this._fetch = opts.fetch ?? fetch; + this._requestInit = opts.requestInit; + this._sessionId = opts.sessionId; + this._protocolVersion = opts.protocolVersion; + this._reconnect = opts.reconnectionOptions ?? DEFAULT_RECONNECT; + } + + get sessionId(): string | undefined { + return this._sessionId; + } + setProtocolVersion(v: string): void { + this._protocolVersion = v; + } + + async fetch(request: JSONRPCRequest, opts: ClientFetchOptions = {}): Promise { + return this._fetchOnce(request, opts, undefined, 0); + } + + async notify(n: { method: string; params?: unknown }): Promise { + const headers = this._headers(); + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json, text/event-stream'); + const res = await this._fetch(this._url, { + ...this._requestInit, + method: 'POST', + headers, + body: JSON.stringify({ jsonrpc: '2.0', method: n.method, params: n.params }), + signal: this._abort.signal + }); + const sid = res.headers.get('mcp-session-id'); + if (sid) this._sessionId = sid; + await res.text?.().catch(() => {}); + if (!res.ok && res.status !== 202) { + throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Notification POST failed: ${res.status}`, { status: res.status }); + } + } + + async *subscribe(): AsyncIterable { + // 2026-06 messages/listen replaces the standalone GET stream. For now, + // open a GET SSE for 2025-11 compat. Best-effort: 405 means unsupported. + const headers = this._headers(); + headers.set('accept', 'text/event-stream'); + const res = await this._fetch(this._url, { ...this._requestInit, method: 'GET', headers, signal: this._abort.signal }); + if (res.status === 405 || !res.ok || !res.body) { + await res.text?.().catch(() => {}); + return; + } + const reader = res.body + .pipeThrough(new TextDecoderStream() as unknown as ReadableWritablePair) + .pipeThrough(new EventSourceParserStream({ onRetry: ms => (this._serverRetryMs = ms) })) + .getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) return; + if (!value.data) continue; + const msg = JSONRPCMessageSchema.parse(JSON.parse(value.data)); + if (isJSONRPCNotification(msg)) yield msg; + } + } finally { + reader.releaseLock(); + } + } + + async close(): Promise { + this._abort.abort(); + } + + /** Explicitly terminate a 2025-11 session via DELETE. */ + async terminateSession(): Promise { + if (!this._sessionId) return; + const headers = this._headers(); + const res = await this._fetch(this._url, { ...this._requestInit, method: 'DELETE', headers, signal: this._abort.signal }); + await res.text?.().catch(() => {}); + if (!res.ok && res.status !== 405) { + throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${res.statusText}`, { + status: res.status + }); + } + this._sessionId = undefined; + } + + private _headers(): Headers { + const h: Record = {}; + if (this._sessionId) h['mcp-session-id'] = this._sessionId; + if (this._protocolVersion) h['mcp-protocol-version'] = this._protocolVersion; + return new Headers({ ...h, ...normalizeHeaders(this._requestInit?.headers) }); + } + + private _delay(attempt: number): number { + if (this._serverRetryMs !== undefined) return this._serverRetryMs; + const { initialReconnectionDelay: i, reconnectionDelayGrowFactor: g, maxReconnectionDelay: m } = this._reconnect; + return Math.min(i * Math.pow(g, attempt), m); + } + + private _link(a: AbortSignal | undefined, b: AbortSignal): AbortSignal { + if (!a) return b; + if (typeof (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any === 'function') { + return (AbortSignal as unknown as { any: (s: AbortSignal[]) => AbortSignal }).any([a, b]); + } + const c = new AbortController(); + const onA = () => c.abort(a.reason); + const onB = () => c.abort(b.reason); + if (a.aborted) c.abort(a.reason); + else a.addEventListener('abort', onA, { once: true }); + if (b.aborted) c.abort(b.reason); + else b.addEventListener('abort', onB, { once: true }); + return c.signal; + } + + private async _fetchOnce( + request: JSONRPCRequest, + opts: ClientFetchOptions, + lastEventId: string | undefined, + attempt: number + ): Promise { + const headers = this._headers(); + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json, text/event-stream'); + if (lastEventId) headers.set('last-event-id', lastEventId); + const signal = this._link(opts.signal, this._abort.signal); + const isResume = lastEventId !== undefined; + const init: RequestInit = isResume + ? { ...this._requestInit, method: 'GET', headers, signal } + : { ...this._requestInit, method: 'POST', headers, body: JSON.stringify(request), signal }; + const res = await this._fetch(this._url, init); + const sid = res.headers.get('mcp-session-id'); + if (sid) this._sessionId = sid; + if (!res.ok) { + const text = await res.text?.().catch(() => null); + throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint (HTTP ${res.status}): ${text}`, { + status: res.status, + text + }); + } + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('text/event-stream')) { + return this._readSse(res, request, opts, attempt); + } + if (ct.includes('application/json')) { + const data = await res.json(); + const messages = Array.isArray(data) ? data : [data]; + let terminal: JSONRPCResultResponse | JSONRPCErrorResponse | undefined; + for (const m of messages) { + const msg = JSONRPCMessageSchema.parse(m); + if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) terminal = msg; + else if (isJSONRPCNotification(msg)) this._routeNotification(msg, opts); + } + if (!terminal) { + throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'JSON response contained no terminal response'); + } + return terminal; + } + await res.text?.().catch(() => {}); + throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${ct}`, { contentType: ct }); + } + + private async _readSse( + res: Response, + request: JSONRPCRequest, + opts: ClientFetchOptions, + attempt: number + ): Promise { + if (!res.body) throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'SSE response has no body'); + let lastEventId: string | undefined; + let primed = false; + const reader = res.body + .pipeThrough(new TextDecoderStream() as unknown as ReadableWritablePair) + .pipeThrough(new EventSourceParserStream({ onRetry: ms => (this._serverRetryMs = ms) })) + .getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value.id) { + lastEventId = value.id; + primed = true; + } + if (!value.data) continue; + if (value.event && value.event !== 'message') continue; + const msg = JSONRPCMessageSchema.parse(JSON.parse(value.data)); + if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) { + return msg; + } + if (isJSONRPCNotification(msg)) this._routeNotification(msg, opts); + } + } catch { + // fallthrough to resume below + } finally { + try { + reader.releaseLock(); + } catch { + /* noop */ + } + } + if (primed && attempt < this._reconnect.maxRetries && !this._abort.signal.aborted && !opts.signal?.aborted) { + await new Promise(r => setTimeout(r, this._delay(attempt))); + return this._fetchOnce(request, opts, lastEventId, attempt + 1); + } + throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, 'SSE stream ended without a terminal response'); + } + + private _routeNotification(msg: JSONRPCNotification, opts: ClientFetchOptions): void { + if (msg.method === 'notifications/progress' && opts.onprogress) { + const { progressToken: _t, ...progress } = (msg.params ?? {}) as Record; + opts.onprogress(progress as never); + return; + } + opts.onnotification?.(msg); + } +} + +type ReadableWritablePair = { readable: ReadableStream; writable: WritableStream }; diff --git a/packages/client/test/client/clientV2.test.ts b/packages/client/test/client/clientV2.test.ts new file mode 100644 index 000000000..527111e9e --- /dev/null +++ b/packages/client/test/client/clientV2.test.ts @@ -0,0 +1,284 @@ +import type { + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + Notification +} from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ClientFetchOptions, ClientTransport } from '../../src/client/clientTransport.js'; +import { isPipeTransport } from '../../src/client/clientTransport.js'; +import { Client } from '../../src/client/clientV2.js'; + +type FetchResp = JSONRPCResultResponse | JSONRPCErrorResponse; + +function mockTransport(handler: (req: JSONRPCRequest, opts?: ClientFetchOptions) => Promise | FetchResp): { + ct: ClientTransport; + sent: JSONRPCRequest[]; + notified: Notification[]; +} { + const sent: JSONRPCRequest[] = []; + const notified: Notification[] = []; + const ct: ClientTransport = { + async fetch(req, opts) { + sent.push(req); + return handler(req, opts); + }, + async notify(n) { + notified.push(n); + }, + async close() {} + }; + return { ct, sent, notified }; +} + +const ok = (id: JSONRPCRequest['id'], result: unknown): JSONRPCResultResponse => ({ jsonrpc: '2.0', id, result }) as JSONRPCResultResponse; +const err = (id: JSONRPCRequest['id'], code: number, message: string): JSONRPCErrorResponse => ({ + jsonrpc: '2.0', + id, + error: { code, message } +}); + +const initResult = (caps: Record = { tools: { listChanged: true } }) => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: caps, + serverInfo: { name: 's', version: '1.0.0' } +}); + +describe('Client (V2)', () => { + describe('connect via ClientTransport', () => { + it('falls back to initialize when server/discover is MethodNotFound, populates server caps', async () => { + const { ct, sent, notified } = mockTransport(req => { + if (req.method === 'server/discover') return err(req.id, ProtocolErrorCode.MethodNotFound, 'nope'); + if (req.method === 'initialize') return ok(req.id, initResult()); + return err(req.id, ProtocolErrorCode.MethodNotFound, 'unexpected'); + }); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(ct); + expect(sent[0]?.method).toBe('server/discover'); + expect(sent.find(r => r.method === 'initialize')).toBeDefined(); + expect(c.getServerCapabilities()?.tools).toBeDefined(); + expect(c.getServerVersion()?.name).toBe('s'); + expect(notified.find(n => n.method === 'notifications/initialized')).toBeDefined(); + }); + + it('uses server/discover result directly when supported (2026-06)', async () => { + const { ct, sent } = mockTransport(req => { + if (req.method === 'server/discover') { + return ok(req.id, { capabilities: { tools: {} }, serverInfo: { name: 'd', version: '2' } }); + } + throw new Error('should not reach'); + }); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(ct); + expect(sent.some(r => r.method === 'initialize')).toBe(false); + expect(c.getServerVersion()?.name).toBe('d'); + }); + + it('isPipeTransport correctly distinguishes the two shapes', () => { + const [a] = InMemoryTransport.createLinkedPair(); + const { ct } = mockTransport(r => ok(r.id, {})); + expect(isPipeTransport(a)).toBe(true); + expect(isPipeTransport(ct)).toBe(false); + }); + }); + + describe('typed RPC sugar', () => { + async function connected(handler: (req: JSONRPCRequest, opts?: ClientFetchOptions) => FetchResp | Promise) { + const m = mockTransport((req, opts) => { + if (req.method === 'server/discover') return ok(req.id, { capabilities: { tools: {}, prompts: {}, resources: {} } }); + return handler(req, opts); + }); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(m.ct); + return { c, ...m }; + } + + it('callTool returns the result', async () => { + const { c } = await connected(r => + r.method === 'tools/call' ? ok(r.id, { content: [{ type: 'text', text: 'hi' }] }) : err(r.id, -32601, 'nope') + ); + const result = await c.callTool({ name: 'x', arguments: {} }); + expect(result.content[0]).toEqual({ type: 'text', text: 'hi' }); + }); + + it('listTools caches output validators and callTool enforces them', async () => { + const tools = [ + { name: 'typed', inputSchema: { type: 'object' }, outputSchema: { type: 'object', properties: { n: { type: 'number' } } } } + ]; + const { c } = await connected(r => { + if (r.method === 'tools/list') return ok(r.id, { tools }); + if (r.method === 'tools/call') return ok(r.id, { content: [], structuredContent: { n: 'not-a-number' } }); + return err(r.id, -32601, 'nope'); + }); + await c.listTools(); + await expect(c.callTool({ name: 'typed', arguments: {} })).rejects.toThrow(ProtocolError); + }); + + it('callTool rejects when tool with outputSchema returns no structuredContent', async () => { + const { c } = await connected(r => { + if (r.method === 'tools/list') { + return ok(r.id, { tools: [{ name: 't', inputSchema: { type: 'object' }, outputSchema: { type: 'object' } }] }); + } + if (r.method === 'tools/call') return ok(r.id, { content: [] }); + return err(r.id, -32601, 'nope'); + }); + await c.listTools(); + await expect(c.callTool({ name: 't', arguments: {} })).rejects.toThrow(/structured content/); + }); + + it('list* return empty when capability missing and not strict', async () => { + const { ct } = mockTransport(r => + r.method === 'server/discover' ? ok(r.id, { capabilities: {} }) : err(r.id, -32601, 'nope') + ); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(ct); + expect(await c.listTools()).toEqual({ tools: [] }); + expect(await c.listPrompts()).toEqual({ prompts: [] }); + expect(await c.listResources()).toEqual({ resources: [] }); + }); + + it('throws ProtocolError on JSON-RPC error response', async () => { + const { c } = await connected(r => err(r.id, ProtocolErrorCode.InvalidParams, 'bad')); + await expect(c.ping()).rejects.toBeInstanceOf(ProtocolError); + }); + + it('passes onprogress through to transport', async () => { + const seen: unknown[] = []; + const { c } = await connected(async (r, opts) => { + opts?.onprogress?.({ progress: 1, total: 2 }); + return ok(r.id, { content: [] }); + }); + await c.callTool({ name: 'x', arguments: {} }, { onprogress: p => seen.push(p) }); + expect(seen).toEqual([{ progress: 1, total: 2 }]); + }); + }); + + describe('MRTR loop', () => { + it('re-sends with inputResponses when server returns input_required, resolves on complete', async () => { + let round = 0; + const elicitArgs = { + method: 'elicitation/create', + params: { message: 'q', requestedSchema: { type: 'object', properties: {} } } + }; + const { ct, sent } = mockTransport(r => { + if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'tools/call') { + round++; + if (round === 1) return ok(r.id, { ResultType: 'input_required', InputRequests: { ask: elicitArgs } }); + return ok(r.id, { content: [{ type: 'text', text: 'done' }] }); + } + return err(r.id, -32601, 'nope'); + }); + const c = new Client({ name: 'c', version: '1' }, { capabilities: { elicitation: {} } }); + c.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { x: 1 } })); + await c.connect(ct); + const result = await c.callTool({ name: 't', arguments: {} }); + expect(result.content[0]).toEqual({ type: 'text', text: 'done' }); + expect(round).toBe(2); + const second = sent.filter(r => r.method === 'tools/call')[1]; + const meta = second?.params?._meta as Record | undefined; + const irs = meta?.['modelcontextprotocol.io/mrtr/inputResponses'] as Record | undefined; + expect(irs?.ask).toEqual({ action: 'accept', content: { x: 1 } }); + }); + + it('throws if no handler is registered for an InputRequest method', async () => { + const { ct } = mockTransport(r => { + if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'tools/call') { + return ok(r.id, { ResultType: 'input_required', InputRequests: { s: { method: 'sampling/createMessage' } } }); + } + return err(r.id, -32601, 'nope'); + }); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(ct); + await expect(c.callTool({ name: 't', arguments: {} })).rejects.toThrow(); + }); + + it('caps rounds at mrtrMaxRounds', async () => { + const { ct } = mockTransport(r => { + if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + return ok(r.id, { ResultType: 'input_required', InputRequests: { p: { method: 'ping' } } }); + }); + const c = new Client({ name: 'c', version: '1' }, { mrtrMaxRounds: 3 }); + await c.connect(ct); + await expect(c.callTool({ name: 't', arguments: {} })).rejects.toThrow(/MRTR exceeded 3/); + }); + }); + + describe('connect via legacy pipe Transport (2025-11 compat)', () => { + it('runs initialize handshake over an InMemoryTransport pair', async () => { + const [clientPipe, serverPipe] = InMemoryTransport.createLinkedPair(); + // Minimal hand-rolled server end of the pipe. + serverPipe.onmessage = msg => { + if ('method' in msg && msg.method === 'initialize' && 'id' in msg) { + void serverPipe.send({ jsonrpc: '2.0', id: msg.id, result: initResult() } as JSONRPCResultResponse); + } + if ('method' in msg && msg.method === 'tools/list' && 'id' in msg) { + void serverPipe.send({ jsonrpc: '2.0', id: msg.id, result: { tools: [] } } as JSONRPCResultResponse); + } + }; + await serverPipe.start(); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(clientPipe); + expect(c.getServerCapabilities()?.tools).toBeDefined(); + expect(c.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + const r = await c.listTools(); + expect(r.tools).toEqual([]); + await c.close(); + }); + + it('skips re-init when transport already has a sessionId', async () => { + const [clientPipe, serverPipe] = InMemoryTransport.createLinkedPair(); + (clientPipe as { sessionId?: string }).sessionId = 'existing'; + const seen: string[] = []; + serverPipe.onmessage = msg => { + if ('method' in msg) seen.push(msg.method); + }; + await serverPipe.start(); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(clientPipe); + expect(seen).not.toContain('initialize'); + }); + }); + + describe('handler registration', () => { + it('setRequestHandler is used for MRTR servicing and pipe-mode dispatch alike', async () => { + const handler = vi.fn(async () => ({ roots: [] })); + const c = new Client({ name: 'c', version: '1' }, { capabilities: { roots: {} } }); + c.setRequestHandler('roots/list', handler); + // Exercise via MRTR path: + const { ct } = mockTransport(r => { + if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'tools/call') { + return ok(r.id, { ResultType: 'input_required', InputRequests: { r: { method: 'roots/list' } } }); + } + return ok(r.id, { content: [] }); + }); + await c.connect(ct); + // First call hits input_required → roots/list handler, second resolves. + // We don't await because the second mock branch never returns complete; instead + // verify the handler was invoked at least once via the MRTR servicing path. + const p = c.callTool({ name: 't', arguments: {} }).catch(() => {}); + await new Promise(r => setTimeout(r, 0)); + expect(handler).toHaveBeenCalled(); + void p; + }); + + it('routes per-request notifications from transport to local notification handlers', async () => { + const got: JSONRPCNotification[] = []; + const { ct } = mockTransport(async (r, opts) => { + if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + opts?.onnotification?.({ jsonrpc: '2.0', method: 'notifications/message', params: { level: 'info', data: 'x' } }); + return ok(r.id, { content: [] }); + }); + const c = new Client({ name: 'c', version: '1' }); + c.setNotificationHandler('notifications/message', n => void got.push(n as JSONRPCNotification)); + await c.connect(ct); + await c.callTool({ name: 't', arguments: {} }); + expect(got).toHaveLength(1); + }); + }); +}); diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts new file mode 100644 index 000000000..4b4845aa7 --- /dev/null +++ b/packages/server/src/server/shttpHandler.ts @@ -0,0 +1,398 @@ +import type { AuthInfo, DispatchEnv, DispatchOutput, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + isInitializeRequest, + isJSONRPCNotification, + isJSONRPCRequest, + JSONRPCMessageSchema, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +import type { SessionCompat } from './sessionCompat.js'; + +export type StreamId = string; +export type EventId = string; + +/** + * Interface for resumability support via event storage. + */ +export interface EventStore { + /** + * Stores an event for later retrieval. + * @param streamId ID of the stream the event belongs to + * @param message The JSON-RPC message to store + * @returns The generated event ID for the stored event + */ + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; + + /** + * Replays events stored after the given event ID, calling `send` for each. + * @returns The stream ID the replayed events belong to + */ + replayEventsAfter(lastEventId: EventId, opts: { send: (eventId: EventId, message: JSONRPCMessage) => Promise }): Promise; +} + +/** + * Structural interface for the server passed to {@linkcode shttpHandler}. Matches the + * {@linkcode Dispatcher} surface; `McpServer` (which extends `Dispatcher`) satisfies it. + */ +export interface McpServerLike { + dispatch(request: JSONRPCRequest, env?: DispatchEnv): AsyncIterable; + dispatchNotification(notification: JSONRPCNotification): Promise; +} + +/** + * Options for {@linkcode shttpHandler}. + */ +export interface ShttpHandlerOptions { + /** + * If `true`, return a single `application/json` response instead of an SSE stream. + * Progress notifications yielded by handlers are dropped in this mode. + * + * @default false + */ + enableJsonResponse?: boolean; + + /** + * Pre-2026-06 session compatibility. When provided, the handler validates the + * `mcp-session-id` header, mints a session on `initialize`, and supports the + * standalone GET subscription stream and DELETE session termination. When omitted, + * the handler is stateless: GET/DELETE return 405. + */ + session?: SessionCompat; + + /** + * Event store for SSE resumability via `Last-Event-ID`. When configured, every + * outgoing SSE event is persisted and a priming event is sent at stream start. + */ + eventStore?: EventStore; + + /** + * Retry interval in milliseconds, sent in the SSE `retry` field of priming events. + */ + retryInterval?: number; + + /** + * Protocol versions accepted in the `mcp-protocol-version` header. + * + * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} + */ + supportedProtocolVersions?: string[]; + + /** Called for non-fatal errors (validation failures, stream write errors). */ + onerror?: (error: Error) => void; +} + +/** + * Per-request extras passed alongside the {@linkcode Request}. + */ +export interface ShttpRequestExtra { + /** Pre-parsed body (e.g. from `express.json()`). When omitted, `req.json()` is used. */ + parsedBody?: unknown; + /** Validated auth token info from upstream middleware. */ + authInfo?: AuthInfo; +} + +function jsonError(status: number, code: number, message: string, extra?: { headers?: Record; data?: string }): Response { + const error: { code: number; message: string; data?: string } = { code, message }; + if (extra?.data !== undefined) error.data = extra.data; + return Response.json( + { jsonrpc: '2.0', error, id: null }, + { status, headers: { 'Content-Type': 'application/json', ...extra?.headers } } + ); +} + +function writeSSEEvent( + controller: ReadableStreamDefaultController, + encoder: InstanceType, + message: JSONRPCMessage, + eventId?: string +): boolean { + try { + let data = 'event: message\n'; + if (eventId) data += `id: ${eventId}\n`; + data += `data: ${JSON.stringify(message)}\n\n`; + controller.enqueue(encoder.encode(data)); + return true; + } catch { + return false; + } +} + +const SSE_HEADERS: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' +}; + +/** + * Creates a Web-standard `(Request) => Promise` handler for the MCP Streamable HTTP + * transport, driven by {@linkcode McpServerLike.dispatch} per request. + * + * No `_streamMapping`, `_requestToStreamMapping`, or `relatedRequestId` routing — the response + * stream is in lexical scope of the request that opened it. Session lifecycle (when enabled) + * lives in the supplied {@linkcode SessionCompat}, not on this handler. + */ +export function shttpHandler( + server: McpServerLike, + options: ShttpHandlerOptions = {} +): (req: Request, extra?: ShttpRequestExtra) => Promise { + const enableJsonResponse = options.enableJsonResponse ?? false; + const session = options.session; + const eventStore = options.eventStore; + const retryInterval = options.retryInterval; + const supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + const onerror = options.onerror; + + function validateProtocolVersion(req: Request): Response | undefined { + const v = req.headers.get('mcp-protocol-version'); + if (v !== null && !supportedProtocolVersions.includes(v)) { + const msg = `Bad Request: Unsupported protocol version: ${v} (supported versions: ${supportedProtocolVersions.join(', ')})`; + onerror?.(new Error(msg)); + return jsonError(400, -32_000, msg); + } + return undefined; + } + + async function writePrimingEvent( + controller: ReadableStreamDefaultController, + encoder: InstanceType, + streamId: string, + protocolVersion: string + ): Promise { + if (!eventStore) return; + if (protocolVersion < '2025-11-25') return; + const primingId = await eventStore.storeEvent(streamId, {} as JSONRPCMessage); + const retry = retryInterval !== undefined ? `retry: ${retryInterval}\n` : ''; + controller.enqueue(encoder.encode(`id: ${primingId}\n${retry}data: \n\n`)); + } + + async function emit( + controller: ReadableStreamDefaultController, + encoder: InstanceType, + streamId: string, + message: JSONRPCMessage + ): Promise { + const eventId = eventStore ? await eventStore.storeEvent(streamId, message) : undefined; + if (!writeSSEEvent(controller, encoder, message, eventId)) { + onerror?.(new Error('Failed to write SSE event')); + } + } + + async function handlePost(req: Request, extra?: ShttpRequestExtra): Promise { + const accept = req.headers.get('accept'); + if (!accept?.includes('application/json') || !accept.includes('text/event-stream')) { + onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); + return jsonError(406, -32_000, 'Not Acceptable: Client must accept both application/json and text/event-stream'); + } + + const ct = req.headers.get('content-type'); + if (!ct?.includes('application/json')) { + onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); + return jsonError(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); + } + + let raw: unknown; + if (extra?.parsedBody !== undefined) { + raw = extra.parsedBody; + } else { + try { + raw = await req.json(); + } catch (e) { + onerror?.(e as Error); + return jsonError(400, -32_700, 'Parse error: Invalid JSON'); + } + } + + let messages: JSONRPCMessage[]; + try { + messages = Array.isArray(raw) ? raw.map(m => JSONRPCMessageSchema.parse(m)) : [JSONRPCMessageSchema.parse(raw)]; + } catch (e) { + onerror?.(e as Error); + return jsonError(400, -32_700, 'Parse error: Invalid JSON-RPC message'); + } + + let sessionId: string | undefined; + let isInitialize = false; + if (session) { + const v = await session.validate(req, messages); + if (!v.ok) return v.response; + sessionId = v.sessionId; + isInitialize = v.isInitialize; + } + if (!isInitialize) { + const protoErr = validateProtocolVersion(req); + if (protoErr) return protoErr; + } + + const requests = messages.filter(isJSONRPCRequest); + const notifications = messages.filter(isJSONRPCNotification); + + for (const n of notifications) { + void server.dispatchNotification(n).catch(e => onerror?.(e as Error)); + } + + if (requests.length === 0) { + return new Response(null, { status: 202 }); + } + + const initReq = messages.find(m => isInitializeRequest(m)); + const clientProtocolVersion = + initReq && isInitializeRequest(initReq) + ? initReq.params.protocolVersion + : (req.headers.get('mcp-protocol-version') ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + + const env: DispatchEnv = { sessionId, authInfo: extra?.authInfo, httpReq: req }; + + if (enableJsonResponse) { + const responses: JSONRPCMessage[] = []; + for (const r of requests) { + for await (const out of server.dispatch(r, env)) { + if (out.kind === 'response') responses.push(out.message); + } + } + const headers: Record = { 'Content-Type': 'application/json' }; + if (sessionId !== undefined) headers['mcp-session-id'] = sessionId; + const body = responses.length === 1 ? responses[0] : responses; + return Response.json(body, { status: 200, headers }); + } + + const streamId = crypto.randomUUID(); + const encoder = new TextEncoder(); + const headers: Record = { ...SSE_HEADERS }; + if (sessionId !== undefined) headers['mcp-session-id'] = sessionId; + + const readable = new ReadableStream({ + start: controller => { + void (async () => { + try { + await writePrimingEvent(controller, encoder, streamId, clientProtocolVersion); + for (const r of requests) { + for await (const out of server.dispatch(r, env)) { + await emit(controller, encoder, streamId, out.message); + } + } + } catch (e) { + onerror?.(e as Error); + } finally { + try { + controller.close(); + } catch { + // Already closed. + } + } + })(); + } + }); + + return new Response(readable, { status: 200, headers }); + } + + async function handleGet(req: Request): Promise { + if (!session) { + return jsonError(405, -32_000, 'Method Not Allowed: stateless handler does not support GET stream', { + headers: { Allow: 'POST' } + }); + } + + const accept = req.headers.get('accept'); + if (!accept?.includes('text/event-stream')) { + onerror?.(new Error('Not Acceptable: Client must accept text/event-stream')); + return jsonError(406, -32_000, 'Not Acceptable: Client must accept text/event-stream'); + } + + const v = session.validateHeader(req); + if (!v.ok) return v.response; + const protoErr = validateProtocolVersion(req); + if (protoErr) return protoErr; + const sessionId = v.sessionId!; + + if (eventStore) { + const lastEventId = req.headers.get('last-event-id'); + if (lastEventId) { + return replayEvents(lastEventId, sessionId); + } + } + + if (session.hasStandaloneStream(sessionId)) { + onerror?.(new Error('Conflict: Only one SSE stream is allowed per session')); + return jsonError(409, -32_000, 'Conflict: Only one SSE stream is allowed per session'); + } + + const headers: Record = { ...SSE_HEADERS, 'mcp-session-id': sessionId }; + const readable = new ReadableStream({ + start: controller => { + session.setStandaloneStream(sessionId, controller); + }, + cancel: () => { + session.setStandaloneStream(sessionId, undefined); + } + }); + return new Response(readable, { headers }); + } + + async function replayEvents(lastEventId: string, sessionId: string): Promise { + if (!eventStore) { + return jsonError(400, -32_000, 'Event store not configured'); + } + const encoder = new TextEncoder(); + const headers: Record = { ...SSE_HEADERS, 'mcp-session-id': sessionId }; + const readable = new ReadableStream({ + start: controller => { + void (async () => { + try { + await eventStore.replayEventsAfter(lastEventId, { + send: async (eventId, message) => { + writeSSEEvent(controller, encoder, message, eventId); + } + }); + if (session) session.setStandaloneStream(sessionId, controller); + } catch (e) { + onerror?.(e as Error); + try { + controller.close(); + } catch { + // Already closed. + } + } + })(); + }, + cancel: () => { + session?.setStandaloneStream(sessionId, undefined); + } + }); + return new Response(readable, { headers }); + } + + async function handleDelete(req: Request): Promise { + if (!session) { + return jsonError(405, -32_000, 'Method Not Allowed: stateless handler does not support session DELETE', { + headers: { Allow: 'POST' } + }); + } + const v = session.validateHeader(req); + if (!v.ok) return v.response; + const protoErr = validateProtocolVersion(req); + if (protoErr) return protoErr; + await session.delete(v.sessionId!); + return new Response(null, { status: 200 }); + } + + return async (req: Request, extra?: ShttpRequestExtra): Promise => { + try { + switch (req.method) { + case 'POST': + return await handlePost(req, extra); + case 'GET': + return await handleGet(req); + case 'DELETE': + return await handleDelete(req); + default: + return jsonError(405, -32_000, 'Method not allowed.', { headers: { Allow: 'GET, POST, DELETE' } }); + } + } catch (e) { + onerror?.(e as Error); + return jsonError(400, -32_700, 'Parse error', { data: String(e) }); + } + }; +} diff --git a/packages/server/test/server/shttpHandler.test.ts b/packages/server/test/server/shttpHandler.test.ts new file mode 100644 index 000000000..2377db9e3 --- /dev/null +++ b/packages/server/test/server/shttpHandler.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; + +import type { DispatchEnv, DispatchOutput, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; + +import { SessionCompat } from '../../src/server/sessionCompat.js'; +import type { McpServerLike } from '../../src/server/shttpHandler.js'; +import { shttpHandler } from '../../src/server/shttpHandler.js'; + +/** Minimal in-test dispatcher: maps method name → result, with optional pre-yield notification. */ +function fakeServer( + handlers: Record unknown>, + opts: { preNotify?: JSONRPCNotification } = {} +): McpServerLike { + return { + async *dispatch(req: JSONRPCRequest, _env?: DispatchEnv): AsyncIterable { + if (opts.preNotify) { + yield { kind: 'notification', message: opts.preNotify }; + } + const h = handlers[req.method]; + if (!h) { + yield { + kind: 'response', + message: { jsonrpc: '2.0', id: req.id, error: { code: -32_601, message: 'Method not found' } } + }; + return; + } + yield { kind: 'response', message: { jsonrpc: '2.0', id: req.id, result: h(req) as Record } }; + }, + async dispatchNotification(_n: JSONRPCNotification): Promise { + return; + } + }; +} + +const ACCEPT_BOTH = 'application/json, text/event-stream'; + +function post(body: unknown, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: ACCEPT_BOTH, ...headers }, + body: JSON.stringify(body) + }); +} + +const initialize = (id: number | string = 1): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 't', version: '1' }, capabilities: {} } +}); + +const toolsList = (id: number | string = 1): JSONRPCRequest => ({ jsonrpc: '2.0', id, method: 'tools/list', params: {} }); + +async function readSSE(res: Response): Promise { + const text = await res.text(); + const out: JSONRPCMessage[] = []; + for (const block of text.split('\n\n')) { + const dataLine = block.split('\n').find(l => l.startsWith('data: ')); + if (!dataLine) continue; + const payload = dataLine.slice('data: '.length); + if (payload.trim() === '') continue; + out.push(JSON.parse(payload)); + } + return out; +} + +describe('shttpHandler — stateless', () => { + const server = fakeServer({ + 'tools/list': () => ({ tools: [{ name: 'echo', inputSchema: { type: 'object' } }] }), + initialize: () => ({ protocolVersion: '2025-11-25', serverInfo: { name: 's', version: '1' }, capabilities: {} }) + }); + + it('POST → SSE response with one result event', async () => { + const handler = shttpHandler(server); + const res = await handler(post(toolsList())); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/event-stream'); + const msgs = await readSSE(res); + expect(msgs).toHaveLength(1); + expect(msgs[0]).toMatchObject({ id: 1, result: { tools: [{ name: 'echo' }] } }); + }); + + it('POST with enableJsonResponse → application/json body', async () => { + const handler = shttpHandler(server, { enableJsonResponse: true }); + const res = await handler(post(toolsList())); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = await res.json(); + expect(body).toMatchObject({ id: 1, result: { tools: expect.any(Array) } }); + }); + + it('POST batch → SSE with one response per request, in order', async () => { + const handler = shttpHandler(server); + const res = await handler(post([toolsList(1), toolsList(2)])); + const msgs = await readSSE(res); + expect(msgs.map(m => (m as { id: number }).id)).toEqual([1, 2]); + }); + + it('POST notification only → 202', async () => { + const handler = shttpHandler(server); + const res = await handler(post({ jsonrpc: '2.0', method: 'notifications/initialized' })); + expect(res.status).toBe(202); + }); + + it('handler-yielded notification precedes the response in SSE', async () => { + const progress: JSONRPCNotification = { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 1, progress: 0.5 } + }; + const s = fakeServer({ 'tools/list': () => ({ tools: [] }) }, { preNotify: progress }); + const handler = shttpHandler(s); + const msgs = await readSSE(await handler(post(toolsList()))); + expect(msgs).toHaveLength(2); + expect((msgs[0] as JSONRPCNotification).method).toBe('notifications/progress'); + expect(msgs[1]).toMatchObject({ id: 1, result: { tools: [] } }); + }); + + it('bad Content-Type → 415', async () => { + const handler = shttpHandler(server); + const req = new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'text/plain', accept: ACCEPT_BOTH }, + body: '{}' + }); + expect((await handler(req)).status).toBe(415); + }); + + it('Accept missing text/event-stream → 406', async () => { + const handler = shttpHandler(server); + const req = new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(toolsList()) + }); + expect((await handler(req)).status).toBe(406); + }); + + it('invalid JSON body → 400 with code -32700', async () => { + const handler = shttpHandler(server); + const req = new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: ACCEPT_BOTH }, + body: '{not json' + }); + const res = await handler(req); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_700); + }); + + it('unsupported HTTP method → 405', async () => { + const handler = shttpHandler(server); + const res = await handler(new Request('http://localhost/mcp', { method: 'PUT' })); + expect(res.status).toBe(405); + }); + + it('unsupported mcp-protocol-version header → 400', async () => { + const handler = shttpHandler(server); + const res = await handler(post(toolsList(), { 'mcp-protocol-version': '1999-01-01' })); + expect(res.status).toBe(400); + }); + + it('GET without session compat → 405', async () => { + const handler = shttpHandler(server); + const res = await handler(new Request('http://localhost/mcp', { method: 'GET', headers: { accept: 'text/event-stream' } })); + expect(res.status).toBe(405); + }); + + it('DELETE without session compat → 405', async () => { + const handler = shttpHandler(server); + const res = await handler(new Request('http://localhost/mcp', { method: 'DELETE' })); + expect(res.status).toBe(405); + }); +}); + +describe('shttpHandler — with SessionCompat', () => { + const server = fakeServer({ + initialize: () => ({ protocolVersion: '2025-11-25', serverInfo: { name: 's', version: '1' }, capabilities: {} }), + 'tools/list': () => ({ tools: [] }) + }); + + it('initialize mints a session and returns mcp-session-id header', async () => { + const session = new SessionCompat(); + const handler = shttpHandler(server, { session, enableJsonResponse: true }); + const res = await handler(post(initialize())); + expect(res.status).toBe(200); + const sid = res.headers.get('mcp-session-id'); + expect(sid).toBeTruthy(); + expect(session.size).toBe(1); + }); + + it('non-initialize without mcp-session-id → 400', async () => { + const session = new SessionCompat(); + const handler = shttpHandler(server, { session }); + const res = await handler(post(toolsList())); + expect(res.status).toBe(400); + }); + + it('wrong mcp-session-id → 404', async () => { + const session = new SessionCompat(); + const handler = shttpHandler(server, { session }); + await handler(post(initialize())); + const res = await handler(post(toolsList(), { 'mcp-session-id': 'nope' })); + expect(res.status).toBe(404); + }); + + it('correct mcp-session-id → 200', async () => { + const session = new SessionCompat(); + const handler = shttpHandler(server, { session, enableJsonResponse: true }); + const initRes = await handler(post(initialize())); + const sid = initRes.headers.get('mcp-session-id')!; + const res = await handler(post(toolsList(), { 'mcp-session-id': sid, 'mcp-protocol-version': '2025-11-25' })); + expect(res.status).toBe(200); + }); + + it('DELETE removes the session', async () => { + const session = new SessionCompat(); + const handler = shttpHandler(server, { session }); + const initRes = await handler(post(initialize())); + const sid = initRes.headers.get('mcp-session-id')!; + const del = await handler( + new Request('http://localhost/mcp', { + method: 'DELETE', + headers: { 'mcp-session-id': sid, 'mcp-protocol-version': '2025-11-25' } + }) + ); + expect(del.status).toBe(200); + expect(session.size).toBe(0); + }); + + it('rejects initialize with 503 + Retry-After when at maxSessions', async () => { + const session = new SessionCompat({ maxSessions: 1, idleTtlMs: 60_000 }); + const handler = shttpHandler(server, { session, enableJsonResponse: true }); + const r1 = await handler(post(initialize(1))); + expect(r1.status).toBe(200); + const r2 = await handler(post(initialize(2))); + // maxSessions=1 + idleTtlMs=60s: first session is fresh so LRU eviction frees nothing → cap hit. + // (SessionCompat evicts the oldest before rejecting; with a single fresh session that oldest IS evicted, + // so cap is only actually hit when eviction can't make room. Use maxSessions=0 to force.) + // Re-test with maxSessions=0 to assert the 503 path deterministically. + const session0 = new SessionCompat({ maxSessions: 0 }); + const handler0 = shttpHandler(server, { session: session0, enableJsonResponse: true }); + const r0 = await handler0(post(initialize())); + expect(r0.status).toBe(503); + expect(r0.headers.get('retry-after')).toBeTruthy(); + // r2 above will have evicted r1's session and succeeded; assert that behavior too. + expect(r2.status).toBe(200); + expect(session.size).toBe(1); + }); +}); From c904faefa028aa269cb515183aa7dd84afa347e0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:16:19 +0000 Subject: [PATCH 04/55] fix(client): use @modelcontextprotocol/core barrel imports in clientV2/clientTransport --- packages/client/src/client/clientTransport.ts | 4 ++-- packages/client/src/client/clientV2.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index 78b968d56..7084b1ef8 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -12,8 +12,8 @@ import { getResultSchema, isJSONRPCErrorResponse } from '@modelcontextprotocol/c // TODO(ts-rebuild): replace with `from '@modelcontextprotocol/core'` once the core barrel exports these. // Dispatcher/StreamDriver are written by a sibling fork to packages/core/src/shared/{dispatcher,streamDriver}.ts. -import type { Dispatcher, RequestOptions } from '../../../core/src/shared/dispatcher.js'; -import { StreamDriver } from '../../../core/src/shared/streamDriver.js'; +import type { Dispatcher, RequestOptions } from '@modelcontextprotocol/core'; +import { StreamDriver } from '@modelcontextprotocol/core'; /** * Per-call options for {@linkcode ClientTransport.fetch}. diff --git a/packages/client/src/client/clientV2.ts b/packages/client/src/client/clientV2.ts index a1953e1f9..8e2f2f302 100644 --- a/packages/client/src/client/clientV2.ts +++ b/packages/client/src/client/clientV2.ts @@ -54,9 +54,9 @@ import { } from '@modelcontextprotocol/core'; // TODO(ts-rebuild): replace with `from '@modelcontextprotocol/core'` once the core barrel exports Dispatcher. -import { Dispatcher } from '../../../core/src/shared/dispatcher.js'; +import { Dispatcher } from '@modelcontextprotocol/core'; -import type { AnySchema, SchemaOutput } from '../../../core/src/util/schema.js'; +import type { AnySchema, SchemaOutput } from '@modelcontextprotocol/core'; import type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; import { isJSONRPCErrorResponse, isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; From 786b05e18ced56d5333812f862ec857642291284 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:20:06 +0000 Subject: [PATCH 05/55] feat(server): swap public exports to new McpServer; clear leaked wrangler port in CF test --- packages/server/src/index.ts | 12 ++++++++---- .../test/server/cloudflareWorkers.test.ts | 6 ++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6e1bba28d..cc650d8f2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -21,13 +21,17 @@ export type { RegisteredResourceTemplate, RegisteredTool, ResourceMetadata, + ServerOptions, ToolCallback -} from './server/mcp.js'; -export { McpServer, ResourceTemplate } from './server/mcp.js'; +} from './server/mcpServer.js'; +export { McpServer, ResourceTemplate } from './server/mcpServer.js'; +export { Server } from './server/compat.js'; +export type { ShttpHandlerOptions } from './server/shttpHandler.js'; +export { shttpHandler } from './server/shttpHandler.js'; +export type { SessionCompatOptions } from './server/sessionCompat.js'; +export { SessionCompat } from './server/sessionCompat.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 { StdioServerTransport } from './server/stdio.js'; export type { EventId, diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 9c2d73a40..64580d6a3 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -26,6 +26,12 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { let env: TestEnv | null = null; beforeAll(async () => { + // Clear any wrangler instance leaked by a previous run before claiming the port. + try { + execSync(`lsof -ti:${PORT} -sTCP:LISTEN | xargs -r kill -9`, { stdio: 'ignore' }); + } catch { + /* nothing listening */ + } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-worker-test-')); // Pack server package From 875a25e2a95796ba9f7b226130abad8b6e8fe566 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:25:34 +0000 Subject: [PATCH 06/55] feat: wire TaskManager into StreamDriver/McpServer; restore capability asserts + request() + reconnect for v1 compat Integration tests 74 fail -> 0. All suites green. --- packages/core/src/index.ts | 2 +- packages/core/src/shared/streamDriver.ts | 124 +++++++++++- .../src/experimental/tasks/mcpServer.ts | 12 +- .../server/src/experimental/tasks/server.ts | 2 +- packages/server/src/server/mcp.ts | 2 +- packages/server/src/server/mcpServer.ts | 186 ++++++++++++++++-- packages/server/src/server/server.ts | 2 +- packages/server/test/mcpServer.test.ts | 5 +- 8 files changed, 305 insertions(+), 30 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 75ca8c7d4..5428760d6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,7 @@ export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/responseMessage.js'; export * from './shared/stdio.js'; -export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; +export type { InboundContext, InboundResult, RequestTaskStore, TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index ba9e11f07..81174030f 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -27,6 +27,8 @@ import type { DispatchEnv, Dispatcher } from './dispatcher.js'; import { getResultSchema } from './dispatcher.js'; import type { NotificationOptions, ProgressCallback, RequestOptions } from './protocol.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './protocol.js'; +import type { InboundContext, TaskManagerHost, TaskManagerOptions } from './taskManager.js'; +import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport } from './transport.js'; type TimeoutInfo = { @@ -46,6 +48,14 @@ export type StreamDriverOptions = { * {@linkcode MessageExtraInfo} (e.g. auth, http req). */ buildEnv?: (extra: MessageExtraInfo | undefined, base: DispatchEnv) => DispatchEnv; + /** + * A pre-constructed and already-bound {@linkcode TaskManager}. When provided the + * driver uses it directly. When omitted, the driver constructs one from + * {@linkcode StreamDriverOptions.tasks | tasks} (or a {@linkcode NullTaskManager}) and binds it itself. + */ + taskManager?: TaskManager; + tasks?: TaskManagerOptions; + enforceStrictCapabilities?: boolean; }; /** @@ -63,6 +73,7 @@ export class StreamDriver { private _requestHandlerAbortControllers: Map = new Map(); private _pendingDebouncedNotifications = new Set(); private _supportedProtocolVersions: string[]; + private _taskManager: TaskManager; onclose?: () => void; onerror?: (error: Error) => void; @@ -73,6 +84,38 @@ export class StreamDriver { private _options: StreamDriverOptions = {} ) { this._supportedProtocolVersions = _options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + if (_options.taskManager) { + this._taskManager = _options.taskManager; + } else { + this._taskManager = _options.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); + this._bindTaskManager(); + } + } + + get taskManager(): TaskManager { + return this._taskManager; + } + + /** Exposed so a {@linkcode TaskManagerHost} owned outside the driver can clear progress callbacks. */ + removeProgressHandler(token: number): void { + this._progressHandlers.delete(token); + } + + private _bindTaskManager(): void { + const host: TaskManagerHost = { + request: (r, schema, opts) => this.request(r, schema, opts), + notification: (n, opts) => this.notification(n, opts), + reportError: e => this._onerror(e), + removeProgressHandler: t => this.removeProgressHandler(t), + registerHandler: (method, handler) => this.dispatcher.setRawRequestHandler(method, handler), + sendOnResponseStream: async (message, relatedRequestId) => { + await this.pipe.send(message, { relatedRequestId }); + }, + enforceStrictCapabilities: this._options.enforceStrictCapabilities === true, + assertTaskCapability: () => {}, + assertTaskHandlerCapability: () => {} + }; + this._taskManager.bind(host); } /** @@ -180,10 +223,30 @@ export class StreamDriver { options?.resetTimeoutOnProgress ?? false ); - this.pipe.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + const sideChannelResponse = (resp: JSONRPCResultResponse | Error) => { + const h = this._responseHandlers.get(messageId); + if (h) h(resp); + else this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); + }; + + let queued = false; + try { + queued = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, sideChannelResponse, error => { + this._progressHandlers.delete(messageId); + reject(error); + }).queued; + } catch (error) { this._progressHandlers.delete(messageId); reject(error); - }); + return; + } + + if (!queued) { + this.pipe.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._progressHandlers.delete(messageId); + reject(error); + }); + } }).finally(() => { if (onAbort) options?.signal?.removeEventListener('abort', onAbort); if (cleanupId !== undefined) { @@ -197,10 +260,17 @@ export class StreamDriver { * Sends a notification over the pipe. Supports debouncing per the constructor option. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - const jsonrpc: JSONRPCNotification = { jsonrpc: '2.0', method: notification.method, params: notification.params }; + const taskResult = await this._taskManager.processOutboundNotification(notification, options); + if (taskResult.queued) return; + const jsonrpc: JSONRPCNotification = taskResult.jsonrpcNotification ?? { + jsonrpc: '2.0', + method: notification.method, + params: notification.params + }; const debounced = this._options.debouncedNotificationMethods ?? []; - const canDebounce = debounced.includes(notification.method) && !notification.params && !options?.relatedRequestId; + const canDebounce = + debounced.includes(notification.method) && !notification.params && !options?.relatedRequestId && !options?.relatedTask; if (canDebounce) { if (this._pendingDebouncedNotifications.has(notification.method)) return; this._pendingDebouncedNotifications.add(notification.method); @@ -217,19 +287,51 @@ export class StreamDriver { const abort = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abort); + const inboundCtx: InboundContext = { + sessionId: this.pipe.sessionId, + sendNotification: (n, opts) => this.notification(n, { ...opts, relatedRequestId: request.id }), + sendRequest: (r, schema, opts) => this.request(r, schema, { ...opts, relatedRequestId: request.id }) + }; + const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); + const baseEnv: DispatchEnv = { signal: abort.signal, sessionId: this.pipe.sessionId, authInfo: extra?.authInfo, httpReq: extra?.request, - send: (r, opts) => this.request(r, getResultSchema(r.method as any), { ...opts, relatedRequestId: request.id }) as Promise + task: taskResult.taskContext, + send: (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as any), opts) as Promise }; const env = this._options.buildEnv ? this._options.buildEnv(extra, baseEnv) : baseEnv; const drain = async () => { + if (taskResult.validateInbound) { + try { + taskResult.validateInbound(); + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + const errResp: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : -32603, + message: e?.message ?? 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + }; + const routed = await taskResult.routeResponse(errResp); + if (!routed) await this.pipe.send(errResp, { relatedRequestId: request.id }); + return; + } + } for await (const out of this.dispatcher.dispatch(request, env)) { - if (abort.signal.aborted && out.kind === 'response') return; - await this.pipe.send(out.message, { relatedRequestId: request.id }); + if (out.kind === 'notification') { + await taskResult.sendNotification({ method: out.message.method, params: out.message.params }); + } else { + if (abort.signal.aborted) return; + const routed = await taskResult.routeResponse(out.message); + if (!routed) await this.pipe.send(out.message, { relatedRequestId: request.id }); + } } }; drain() @@ -282,6 +384,9 @@ export class StreamDriver { private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); + const taskResult = this._taskManager.processInboundResponse(response, messageId); + if (taskResult.consumed) return; + const handler = this._responseHandlers.get(messageId); if (handler === undefined) { this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); @@ -289,7 +394,9 @@ export class StreamDriver { } this._responseHandlers.delete(messageId); this._cleanupTimeout(messageId); - this._progressHandlers.delete(messageId); + if (!taskResult.preserveProgress) { + this._progressHandlers.delete(messageId); + } if (isJSONRPCResultResponse(response)) { handler(response); } else { @@ -301,6 +408,7 @@ export class StreamDriver { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); + this._taskManager.onClose(); this._pendingDebouncedNotifications.clear(); for (const info of this._timeoutInfo.values()) clearTimeout(info.timeoutId); this._timeoutInfo.clear(); diff --git a/packages/server/src/experimental/tasks/mcpServer.ts b/packages/server/src/experimental/tasks/mcpServer.ts index b7c28c40d..730ebe28d 100644 --- a/packages/server/src/experimental/tasks/mcpServer.ts +++ b/packages/server/src/experimental/tasks/mcpServer.ts @@ -7,8 +7,9 @@ import type { StandardSchemaWithJSON, TaskToolExecution, ToolAnnotations, ToolExecution } from '@modelcontextprotocol/core'; -import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js'; +import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcpServer.js'; import type { ToolTaskHandler } from './interfaces.js'; +import { ExperimentalServerTasks } from './server.js'; /** * Internal interface for accessing {@linkcode McpServer}'s private _createRegisteredTool method. @@ -38,8 +39,13 @@ interface McpServerInternal { * * @experimental */ -export class ExperimentalMcpServerTasks { - constructor(private readonly _mcpServer: McpServer) {} +export class ExperimentalMcpServerTasks extends ExperimentalServerTasks { + private readonly _mcpServer: McpServer; + + constructor(mcpServer: McpServer) { + super(mcpServer); + this._mcpServer = mcpServer; + } /** * Registers a task-based tool with a config object and handler. diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index 2e7b205fd..20fe8cd0a 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -24,7 +24,7 @@ import type { } from '@modelcontextprotocol/core'; import { getResultSchema, GetTaskPayloadResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; -import type { Server } from '../../server/server.js'; +import type { McpServer as Server } from '../../server/mcpServer.js'; /** * Experimental task features for low-level MCP servers. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 6c2699997..512d1119a 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -86,7 +86,7 @@ export class McpServer { get experimental(): { tasks: ExperimentalMcpServerTasks } { if (!this._experimental) { this._experimental = { - tasks: new ExperimentalMcpServerTasks(this) + tasks: new ExperimentalMcpServerTasks(this as never) }; } return this._experimental; diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 9ac4e8e46..49cb64021 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -34,6 +34,7 @@ import type { LoggingLevel, LoggingMessageNotification, Notification, + NotificationMethod, NotificationOptions, Prompt, PromptReference, @@ -53,6 +54,7 @@ import type { ServerResult, StandardSchemaWithJSON, StreamDriverOptions, + TaskManagerHost, TaskManagerOptions, Tool, ToolAnnotations, @@ -63,8 +65,10 @@ import type { Variables } from '@modelcontextprotocol/core'; import { + assertClientRequestTaskCapability, assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + assertToolsCallTaskCapability, CallToolRequestSchema, CallToolResultSchema, CreateMessageResultSchema, @@ -73,11 +77,14 @@ import { Dispatcher, ElicitResultSchema, EmptyResultSchema, + extractTaskManagerOptions, + getResultSchema, isJSONRPCRequest, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, + NullTaskManager, parseSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -87,6 +94,7 @@ import { standardSchemaToJsonSchema, StreamDriver, SUPPORTED_PROTOCOL_VERSIONS, + TaskManager, UriTemplate, validateAndWarnToolName, validateStandardSchema @@ -141,6 +149,7 @@ export class McpServer extends Dispatcher { private _jsonSchemaValidator: jsonSchemaValidator; private _supportedProtocolVersions: string[]; private _experimental?: { tasks: ExperimentalMcpServerTasks }; + private _taskManager: TaskManager; private _loggingLevels = new Map(); private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); @@ -187,6 +196,10 @@ export class McpServer extends Dispatcher { this._capabilities.tasks = wireCapabilities; } + const tasksOpts = extractTaskManagerOptions(_options?.capabilities?.tasks); + this._taskManager = tasksOpts ? new TaskManager(tasksOpts) : new NullTaskManager(); + this._bindTaskManager(); + this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setRequestHandler('ping', () => ({})); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); @@ -248,20 +261,20 @@ export class McpServer extends Dispatcher { * Builds a {@linkcode StreamDriver} internally. */ async connect(transport: Transport): Promise { - if (this._driver) { - throw new SdkError(SdkErrorCode.AlreadyConnected, 'Server is already connected to a transport'); - } const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, - debouncedNotificationMethods: this._options?.debouncedNotificationMethods + debouncedNotificationMethods: this._options?.debouncedNotificationMethods, + taskManager: this._taskManager, + enforceStrictCapabilities: this._options?.enforceStrictCapabilities }; - this._driver = new StreamDriver(this, transport, driverOpts); - this._driver.onclose = () => { - this._driver = undefined; + const driver = new StreamDriver(this, transport, driverOpts); + this._driver = driver; + driver.onclose = () => { + if (this._driver === driver) this._driver = undefined; this.onclose?.(); }; - this._driver.onerror = error => this.onerror?.(error); - await this._driver.start(); + driver.onerror = error => this.onerror?.(error); + await driver.start(); } /** @@ -296,14 +309,33 @@ export class McpServer extends Dispatcher { */ get experimental(): { tasks: ExperimentalMcpServerTasks } { if (!this._experimental) { - // ExperimentalMcpServerTasks is currently typed against the old mcp.ts McpServer; the - // structural surface it actually uses (server.registerCapabilities, _createRegisteredTool, - // etc.) is present on this class. Cast until tasks helper is re-typed in step 3. - this._experimental = { tasks: new ExperimentalMcpServerTasks(this as never) }; + this._experimental = { tasks: new ExperimentalMcpServerTasks(this) }; } return this._experimental; } + /** Task orchestration. Always available; a {@linkcode NullTaskManager} when no task store is configured. */ + get taskManager(): TaskManager { + return this._taskManager; + } + + private _bindTaskManager(): void { + const host: TaskManagerHost = { + request: (r, schema, opts) => this._driverRequest(r, schema as never, opts), + notification: (n, opts) => this.notification(n, opts), + reportError: e => (this.onerror ?? (() => {}))(e), + removeProgressHandler: t => this._driver?.removeProgressHandler(t), + registerHandler: (m, h) => this.setRawRequestHandler(m, h as never), + sendOnResponseStream: async (msg, relatedRequestId) => { + await this._driver?.pipe.send(msg, { relatedRequestId }); + }, + enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, + assertTaskCapability: m => this._assertTaskCapability(m), + assertTaskHandlerCapability: m => this._assertTaskHandlerCapability(m) + }; + this._taskManager.bind(host); + } + // ─────────────────────────────────────────────────────────────────────── // Context building // ─────────────────────────────────────────────────────────────────────── @@ -409,6 +441,7 @@ export class McpServer extends Dispatcher { method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise ): void { + this._assertRequestHandlerCapability(method); if (method === 'tools/call') { const wrapped = async (request: RequestTypeMap[M], ctx: ServerContext): Promise => { const validated = parseSchema(CallToolRequestSchema, request); @@ -453,9 +486,23 @@ export class McpServer extends Dispatcher { } private _driverRequest(req: Request, schema: { parse(v: unknown): T }, options?: RequestOptions): Promise { + if (this._options?.enforceStrictCapabilities === true) { + this._assertCapabilityForMethod(req.method as RequestMethod); + } return this._requireDriver().request(req, schema as never, options) as Promise; } + /** + * Sends a request to the connected peer and awaits the result. Result schema is + * resolved from the method name. + */ + async request( + req: { method: M; params?: Record }, + options?: RequestOptions + ): Promise { + return this._driverRequest(req as Request, getResultSchema(req.method), options) as Promise; + } + async ping(): Promise { return this._driverRequest({ method: 'ping' }, EmptyResultSchema); } @@ -591,9 +638,122 @@ export class McpServer extends Dispatcher { * Sends a notification over the connected transport. No-op when not connected. */ async notification(notification: Notification, options?: NotificationOptions): Promise { + this._assertNotificationCapability(notification.method as NotificationMethod); await this._driver?.notification(notification, options); } + // ─────────────────────────────────────────────────────────────────────── + // Capability assertions (v1 compat). No-ops once capabilities move per-request. + // ─────────────────────────────────────────────────────────────────────── + + private _assertCapabilityForMethod(method: RequestMethod): void { + switch (method) { + case 'sampling/createMessage': + if (!this._clientCapabilities?.sampling) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support sampling (required for ${method})`); + } + break; + case 'elicitation/create': + if (!this._clientCapabilities?.elicitation) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation (required for ${method})`); + } + break; + case 'roots/list': + if (!this._clientCapabilities?.roots) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support listing roots (required for ${method})` + ); + } + break; + } + } + + private _assertNotificationCapability(method: NotificationMethod): void { + switch (method) { + case 'notifications/message': + if (!this._capabilities.logging) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); + } + break; + case 'notifications/resources/updated': + case 'notifications/resources/list_changed': + if (!this._capabilities.resources) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying about resources (required for ${method})` + ); + } + break; + case 'notifications/tools/list_changed': + if (!this._capabilities.tools) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying of tool list changes (required for ${method})` + ); + } + break; + case 'notifications/prompts/list_changed': + if (!this._capabilities.prompts) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying of prompt list changes (required for ${method})` + ); + } + break; + case 'notifications/elicitation/complete': + if (!this._clientCapabilities?.elicitation?.url) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support URL elicitation (required for ${method})` + ); + } + break; + } + } + + private _assertRequestHandlerCapability(method: string): void { + switch (method) { + case 'completion/complete': + if (!this._capabilities.completions) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); + } + break; + case 'logging/setLevel': + if (!this._capabilities.logging) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); + } + break; + case 'prompts/get': + case 'prompts/list': + if (!this._capabilities.prompts) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); + } + break; + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': + if (!this._capabilities.resources) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); + } + break; + case 'tools/call': + case 'tools/list': + if (!this._capabilities.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); + } + break; + } + } + + private _assertTaskCapability(method: string): void { + assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, 'Client'); + } + + private _assertTaskHandlerCapability(method: string): void { + assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server'); + } + async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise { if (this._capabilities.logging && !this._isMessageIgnored(params.level, sessionId)) { return this.notification({ method: 'notifications/message', params }); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4361f3e1e..6a86c507e 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -185,7 +185,7 @@ export class Server extends Protocol { get experimental(): { tasks: ExperimentalServerTasks } { if (!this._experimental) { this._experimental = { - tasks: new ExperimentalServerTasks(this) + tasks: new ExperimentalServerTasks(this as never) }; } return this._experimental; diff --git a/packages/server/test/mcpServer.test.ts b/packages/server/test/mcpServer.test.ts index f2262335b..ea06f2e83 100644 --- a/packages/server/test/mcpServer.test.ts +++ b/packages/server/test/mcpServer.test.ts @@ -240,12 +240,13 @@ describe('McpServer compat / .server / connect()', () => { expect(listResp.result.tools[0].name).toBe('t'); }); - it('connect() twice throws AlreadyConnected', async () => { + it('connect() twice replaces the active driver (v1 multi-transport pattern)', async () => { const s = new McpServer({ name: 's', version: '1' }); const [a] = InMemoryTransport.createLinkedPair(); await s.connect(a); const [c] = InMemoryTransport.createLinkedPair(); - await expect(s.connect(c)).rejects.toThrow(); + await expect(s.connect(c)).resolves.toBeUndefined(); + expect(s.transport).toBe(c); }); it('elicitInput() instance method throws NotConnected when no driver', async () => { From c68922d1ed97875292cf10f4d667b0d4a76b828d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:27:22 +0000 Subject: [PATCH 07/55] fix(server): thread closeSSE/closeStandaloneSSE through buildEnv to ctx.http All tests green: 1529 (1376 baseline + 153 new). Typecheck clean. --- packages/server/src/server/mcpServer.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 49cb64021..c37f5b6e6 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -3,6 +3,7 @@ import type { BaseContext, BaseMetadata, CallToolRequest, + MessageExtraInfo, CallToolResult, ClientCapabilities, CompleteRequestPrompt, @@ -265,7 +266,8 @@ export class McpServer extends Dispatcher { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, taskManager: this._taskManager, - enforceStrictCapabilities: this._options?.enforceStrictCapabilities + enforceStrictCapabilities: this._options?.enforceStrictCapabilities, + buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) }; const driver = new StreamDriver(this, transport, driverOpts); this._driver = driver; @@ -340,8 +342,9 @@ export class McpServer extends Dispatcher { // Context building // ─────────────────────────────────────────────────────────────────────── - protected override buildContext(base: BaseContext, env: DispatchEnv): ServerContext { - const hasHttpInfo = base.http || env.httpReq; + protected override buildContext(base: BaseContext, env: DispatchEnv & { _transportExtra?: MessageExtraInfo }): ServerContext { + const extra = env._transportExtra; + const hasHttpInfo = base.http || env.httpReq || extra?.closeSSEStream || extra?.closeStandaloneSSEStream; return { ...base, mcpReq: { @@ -351,7 +354,14 @@ export class McpServer extends Dispatcher { elicitInput: (params, options) => this._elicitInputViaCtx(base, params, options), requestSampling: (params, options) => this._createMessageViaCtx(base, params, options) }, - http: hasHttpInfo ? { ...base.http, req: env.httpReq } : undefined + http: hasHttpInfo + ? { + ...base.http, + req: env.httpReq, + closeSSE: extra?.closeSSEStream, + closeStandaloneSSE: extra?.closeStandaloneSSEStream + } + : undefined }; } From 5d7898beb29b408c17c3e23dcf5fd5448ae78779 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:35:57 +0000 Subject: [PATCH 08/55] feat(server): add deprecated .tool/.prompt/.resource overloads + flat ctx fields for v1 compat; fix prompts schema, completion handler, tools-list Conformance: 30/30 scenarios (40/40 checks) passing via v1-pattern path. All SDK tests green: 1529/1529. Typecheck clean. --- packages/server/src/server/mcpServer.ts | 215 ++++++++++++++++++++++-- 1 file changed, 204 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index c37f5b6e6..b40f1a7c1 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -81,6 +81,8 @@ import { extractTaskManagerOptions, getResultSchema, isJSONRPCRequest, + isStandardSchema, + isStandardSchemaWithJSON, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -101,6 +103,7 @@ import { validateStandardSchema } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import { z } from 'zod/v4'; import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; @@ -345,7 +348,7 @@ export class McpServer extends Dispatcher { protected override buildContext(base: BaseContext, env: DispatchEnv & { _transportExtra?: MessageExtraInfo }): ServerContext { const extra = env._transportExtra; const hasHttpInfo = base.http || env.httpReq || extra?.closeSSEStream || extra?.closeStandaloneSSEStream; - return { + const ctx: ServerContext = { ...base, mcpReq: { ...base.mcpReq, @@ -363,6 +366,16 @@ export class McpServer extends Dispatcher { } : undefined }; + // v1 RequestHandlerExtra flat compat fields. New code should use ctx.mcpReq.* / ctx.http.*. + const compat = ctx as ServerContext & Record; + compat.signal = base.mcpReq.signal; + compat.requestId = base.mcpReq.id; + compat._meta = base.mcpReq._meta; + compat.sendNotification = base.mcpReq.notify; + compat.sendRequest = base.mcpReq.send; + compat.authInfo = ctx.http?.authInfo; + compat.requestInfo = env.httpReq; + return ctx; } private async _elicitInputViaCtx( @@ -446,21 +459,37 @@ export class McpServer extends Dispatcher { /** * Override request handler registration to enforce server-side validation for `tools/call`. + * + * Also accepts the v1 form `setRequestHandler(zodRequestSchema, handler)` where the schema + * has a literal `method` shape (e.g. `z.object({method: z.literal('resources/subscribe')})`). */ public override setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise + ): void; + /** @deprecated Pass a method string instead of a Zod request schema. */ + public override setRequestHandler( + schema: { shape: { method: unknown } }, + handler: (request: JSONRPCRequest, ctx: ServerContext) => Result | Promise + ): void; + public override setRequestHandler( + methodOrSchema: RequestMethod | { shape: { method: unknown } }, + handler: (request: never, ctx: ServerContext) => Result | Promise ): void { + const method = ( + typeof methodOrSchema === 'string' ? methodOrSchema : extractMethodFromSchema(methodOrSchema) + ) as RequestMethod; this._assertRequestHandlerCapability(method); + const h = handler as (request: JSONRPCRequest, ctx: ServerContext) => Result | Promise; if (method === 'tools/call') { - const wrapped = async (request: RequestTypeMap[M], ctx: ServerContext): Promise => { + const wrapped = async (request: JSONRPCRequest, ctx: ServerContext): Promise => { const validated = parseSchema(CallToolRequestSchema, request); if (!validated.success) { const msg = validated.error instanceof Error ? validated.error.message : String(validated.error); throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${msg}`); } const { params } = validated.data; - const result = await Promise.resolve(handler(request, ctx)); + const result = await Promise.resolve(h(request, ctx)); if (params.task) { const taskValidation = parseSchema(CreateTaskResultSchema, result); if (!taskValidation.success) { @@ -476,9 +505,9 @@ export class McpServer extends Dispatcher { } return resultValidation.data; }; - return super.setRequestHandler(method, wrapped); + return super.setRequestHandler(method, wrapped as never); } - return super.setRequestHandler(method, handler); + return super.setRequestHandler(method, h as never); } // ─────────────────────────────────────────────────────────────────────── @@ -743,6 +772,8 @@ export class McpServer extends Dispatcher { case 'resources/list': case 'resources/templates/list': case 'resources/read': + case 'resources/subscribe': + case 'resources/unsubscribe': if (!this._capabilities.resources) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); } @@ -1317,8 +1348,8 @@ export class McpServer extends Dispatcher { config: { title?: string; description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; + inputSchema?: InputArgs | ZodRawShapeCompat; + outputSchema?: OutputArgs | ZodRawShapeCompat; annotations?: ToolAnnotations; _meta?: Record; }, @@ -1330,8 +1361,8 @@ export class McpServer extends Dispatcher { name, title, description, - inputSchema, - outputSchema, + coerceSchema(inputSchema), + coerceSchema(outputSchema), annotations, { taskSupport: 'forbidden' }, _meta, @@ -1344,16 +1375,121 @@ export class McpServer extends Dispatcher { */ registerPrompt( name: string, - config: { title?: string; description?: string; argsSchema?: Args; _meta?: Record }, + config: { title?: string; description?: string; argsSchema?: Args | ZodRawShapeCompat; _meta?: Record }, cb: PromptCallback ): RegisteredPrompt { if (this._registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); const { title, description, argsSchema, _meta } = config; - const r = this._createRegisteredPrompt(name, title, description, argsSchema, cb as PromptCallback, _meta); + const r = this._createRegisteredPrompt( + name, + title, + description, + coerceSchema(argsSchema), + cb as PromptCallback, + _meta + ); this.setPromptRequestHandlers(); this.sendPromptListChanged(); return r; } + + // ─────────────────────────────────────────────────────────────────────── + // Deprecated v1 overloads (positional, raw-shape) — call register* internally + // ─────────────────────────────────────────────────────────────────────── + + /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ + tool(name: string, cb: ToolCallback): RegisteredTool; + /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ + tool(name: string, description: string, cb: ToolCallback): RegisteredTool; + /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ + tool(name: string, paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: LegacyToolCallback): RegisteredTool; + /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ + tool( + name: string, + description: string, + paramsSchemaOrAnnotations: Args | ToolAnnotations, + cb: LegacyToolCallback + ): RegisteredTool; + /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ + tool(name: string, paramsSchema: Args, annotations: ToolAnnotations, cb: LegacyToolCallback): RegisteredTool; + /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ + tool( + name: string, + description: string, + paramsSchema: Args, + annotations: ToolAnnotations, + cb: LegacyToolCallback + ): RegisteredTool; + tool(name: string, ...rest: unknown[]): RegisteredTool { + if (this._registeredTools[name]) throw new Error(`Tool ${name} is already registered`); + let description: string | undefined; + let inputSchema: StandardSchemaWithJSON | undefined; + let annotations: ToolAnnotations | undefined; + if (typeof rest[0] === 'string') description = rest.shift() as string; + if (rest.length > 1) { + const first = rest[0]; + if (isZodRawShapeCompat(first) || isStandardSchema(first)) { + inputSchema = coerceSchema(rest.shift()); + if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { + annotations = rest.shift() as ToolAnnotations; + } + } else if (typeof first === 'object' && first !== null) { + if (Object.values(first).some(v => typeof v === 'object' && v !== null)) { + throw new Error(`Tool ${name} expected a Zod schema or ToolAnnotations, but received an unrecognized object`); + } + annotations = rest.shift() as ToolAnnotations; + } + } + const cb = rest[0] as ToolCallback; + return this._createRegisteredTool(name, undefined, description, inputSchema, undefined, annotations, { taskSupport: 'forbidden' }, undefined, cb); + } + + /** @deprecated Use {@linkcode McpServer.registerPrompt | registerPrompt()} instead. */ + prompt(name: string, cb: PromptCallback): RegisteredPrompt; + /** @deprecated Use {@linkcode McpServer.registerPrompt | registerPrompt()} instead. */ + prompt(name: string, description: string, cb: PromptCallback): RegisteredPrompt; + /** @deprecated Use {@linkcode McpServer.registerPrompt | registerPrompt()} instead. */ + prompt(name: string, argsSchema: Args, cb: LegacyPromptCallback): RegisteredPrompt; + /** @deprecated Use {@linkcode McpServer.registerPrompt | registerPrompt()} instead. */ + prompt(name: string, description: string, argsSchema: Args, cb: LegacyPromptCallback): RegisteredPrompt; + prompt(name: string, ...rest: unknown[]): RegisteredPrompt { + if (this._registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); + let description: string | undefined; + if (typeof rest[0] === 'string') description = rest.shift() as string; + let argsSchema: StandardSchemaWithJSON | undefined; + if (rest.length > 1) argsSchema = coerceSchema(rest.shift()); + const cb = rest[0] as PromptCallback; + const r = this._createRegisteredPrompt(name, undefined, description, argsSchema, cb, undefined); + this.setPromptRequestHandlers(); + this.sendPromptListChanged(); + return r; + } + + /** @deprecated Use {@linkcode McpServer.registerResource | registerResource()} instead. */ + resource(name: string, uri: string, readCallback: ReadResourceCallback): RegisteredResource; + /** @deprecated Use {@linkcode McpServer.registerResource | registerResource()} instead. */ + resource(name: string, uri: string, metadata: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + /** @deprecated Use {@linkcode McpServer.registerResource | registerResource()} instead. */ + resource(name: string, template: ResourceTemplate, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; + /** @deprecated Use {@linkcode McpServer.registerResource | registerResource()} instead. */ + resource(name: string, template: ResourceTemplate, metadata: ResourceMetadata, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; + resource(name: string, uriOrTemplate: string | ResourceTemplate, ...rest: unknown[]): RegisteredResource | RegisteredResourceTemplate { + let metadata: ResourceMetadata | undefined; + if (typeof rest[0] === 'object') metadata = rest.shift() as ResourceMetadata; + const readCallback = rest[0] as ReadResourceCallback | ReadResourceTemplateCallback; + if (typeof uriOrTemplate === 'string') { + if (this._registeredResources[uriOrTemplate]) throw new Error(`Resource ${uriOrTemplate} is already registered`); + const r = this._createRegisteredResource(name, undefined, uriOrTemplate, metadata, readCallback as ReadResourceCallback); + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return r; + } + if (this._registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`); + const r = this._createRegisteredResourceTemplate(name, undefined, uriOrTemplate, metadata, readCallback as ReadResourceTemplateCallback); + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return r; + } } // ─────────────────────────────────────────────────────────────────────────── @@ -1585,6 +1721,63 @@ function createPromptHandler( return async (_args, ctx) => typed(ctx); } +/** + * v1 compat: a "raw shape" is a plain object whose values are Zod schemas + * (e.g. `{ name: z.string() }`), or an empty object. v1's `tool()`/`prompt()` + * and `registerTool({inputSchema:{}})` accepted these directly. + */ +type ZodRawShapeCompat = Record; + +/** v1-style callback signature for the deprecated {@linkcode McpServer.tool | tool()} overloads. */ +type LegacyToolCallback = ( + args: z.infer>, + ctx: ServerContext +) => CallToolResult | Promise; + +/** v1-style callback signature for the deprecated {@linkcode McpServer.prompt | prompt()} overloads. */ +type LegacyPromptCallback = ( + args: z.infer>, + ctx: ServerContext +) => GetPromptResult | Promise; + +/** + * v1 compat: extract the literal method string from a `z.object({method: z.literal('x'), ...})` schema. + */ +function extractMethodFromSchema(schema: { shape: { method: unknown } }): string { + const lit = schema.shape.method as { value?: unknown; _zod?: { def?: { values?: unknown[] } } }; + const v = lit?.value ?? lit?._zod?.def?.values?.[0]; + if (typeof v !== 'string') { + throw new Error('setRequestHandler(schema, handler): schema.shape.method must be a z.literal(string)'); + } + return v; +} + +function isZodTypeLike(v: unknown): boolean { + return v != null && typeof v === 'object' && '_zod' in (v as object); +} + +function isZodRawShapeCompat(v: unknown): v is ZodRawShapeCompat { + if (v == null || typeof v !== 'object') return false; + if (isStandardSchema(v)) return false; + const values = Object.values(v as object); + if (values.length === 0) return true; + return values.some(isZodTypeLike); +} + +/** + * Coerce a v1-style raw Zod shape (or empty object) to a {@linkcode StandardSchemaWithJSON}. + * Standard Schemas pass through unchanged. + */ +function coerceSchema(schema: unknown): StandardSchemaWithJSON | undefined { + if (schema == null) return undefined; + if (isStandardSchemaWithJSON(schema)) return schema; + if (isZodRawShapeCompat(schema)) return z.object(schema) as unknown as StandardSchemaWithJSON; + if (isStandardSchema(schema)) { + throw new Error('Schema lacks JSON-Schema emission (zod >=4.2 or equivalent required).'); + } + throw new Error('inputSchema/argsSchema must be a Standard Schema or a Zod raw shape (e.g. {name: z.string()})'); +} + function getSchemaShape(schema: unknown): Record | undefined { const c = schema as { shape?: unknown }; if (c.shape && typeof c.shape === 'object') return c.shape as Record; From 85dd8b9c5b47aff8c4527ec80bf613d043437c07 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:47:09 +0000 Subject: [PATCH 09/55] chore: fix lint in core (duplicate exports, explicit-any) --- packages/core/src/index.ts | 12 ++++++++++-- packages/core/src/shared/dispatcher.ts | 13 +++++-------- packages/core/src/shared/streamDriver.ts | 11 +++++++---- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5428760d6..22ad04387 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,12 +3,20 @@ export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; export * from './shared/dispatcher.js'; -export * from './shared/streamDriver.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/responseMessage.js'; export * from './shared/stdio.js'; -export type { InboundContext, InboundResult, RequestTaskStore, TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; +export * from './shared/streamDriver.js'; +export type { + InboundContext, + InboundResult, + RequestTaskStore, + TaskContext, + TaskManagerHost, + TaskManagerOptions, + TaskRequestOptions +} from './shared/taskManager.js'; export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 224ab7b06..9dafbf612 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -15,7 +15,7 @@ import type { Result, ResultTypeMap } from '../types/index.js'; -import { getNotificationSchema, getRequestSchema, getResultSchema, ProtocolErrorCode } from '../types/index.js'; +import { getNotificationSchema, getRequestSchema, ProtocolErrorCode } from '../types/index.js'; import type { BaseContext, RequestOptions } from './protocol.js'; import type { TaskContext } from './taskManager.js'; @@ -144,11 +144,9 @@ export class Dispatcher { .then(() => handler(request, ctx)) .then( result => { - if (localAbort.signal.aborted) { - final = errorResponse(request.id, ProtocolErrorCode.InternalError, 'Request cancelled').message; - } else { - final = { jsonrpc: '2.0', id: request.id, result }; - } + final = localAbort.signal.aborted + ? errorResponse(request.id, ProtocolErrorCode.InternalError, 'Request cancelled').message + : { jsonrpc: '2.0', id: request.id, result }; }, error => { final = toErrorResponse(request.id, error); @@ -258,5 +256,4 @@ function toErrorResponse(id: RequestId, error: unknown): JSONRPCErrorResponse { } /** Re-export for convenience; the canonical definition lives in protocol.ts for now. */ -export type { BaseContext, RequestOptions } from './protocol.js'; -export { getResultSchema }; +// BaseContext / RequestOptions are exported from protocol.ts via the core barrel. diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 81174030f..bbf042aa4 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -11,6 +11,7 @@ import type { ProgressNotification, Request, RequestId, + RequestMethod, Result } from '../types/index.js'; import { @@ -24,7 +25,7 @@ import { import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; import type { DispatchEnv, Dispatcher } from './dispatcher.js'; -import { getResultSchema } from './dispatcher.js'; +import { getResultSchema } from '../types/index.js'; import type { NotificationOptions, ProgressCallback, RequestOptions } from './protocol.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './protocol.js'; import type { InboundContext, TaskManagerHost, TaskManagerOptions } from './taskManager.js'; @@ -79,6 +80,7 @@ export class StreamDriver { onerror?: (error: Error) => void; constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- driver is context-agnostic; subclass owns ContextT readonly dispatcher: Dispatcher, readonly pipe: Transport, private _options: StreamDriverOptions = {} @@ -300,7 +302,7 @@ export class StreamDriver { authInfo: extra?.authInfo, httpReq: extra?.request, task: taskResult.taskContext, - send: (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as any), opts) as Promise + send: (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise }; const env = this._options.buildEnv ? this._options.buildEnv(extra, baseEnv) : baseEnv; @@ -314,7 +316,7 @@ export class StreamDriver { jsonrpc: '2.0', id: request.id, error: { - code: Number.isSafeInteger(e?.code) ? (e.code as number) : -32603, + code: Number.isSafeInteger(e?.code) ? (e.code as number) : -32_603, message: e?.message ?? 'Internal error', ...(e?.data !== undefined && { data: e.data }) } @@ -346,7 +348,8 @@ export class StreamDriver { private _onnotification(notification: JSONRPCNotification): void { if (notification.method === 'notifications/cancelled') { const requestId = (notification.params as { requestId?: RequestId } | undefined)?.requestId; - if (requestId !== undefined) this._requestHandlerAbortControllers.get(requestId)?.abort((notification.params as any)?.reason); + if (requestId !== undefined) + this._requestHandlerAbortControllers.get(requestId)?.abort((notification.params as { reason?: unknown })?.reason); return; } if (notification.method === 'notifications/progress') { From 8a336222d47179f34ee5d8e4c382e0b39b2832c8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 14:55:32 +0000 Subject: [PATCH 10/55] feat(client): tasks support in clientV2 (SEP-2557 direction); lint cleanup - experimental.tasks getter, getTask/listTasks/cancelTask methods - callTool handles CreateTaskResult polymorphism - 5 new client tests - lint cleanup across client/server In-repo conformance: server 40/40 + client 289/289. SDK tests 1534/1534. --- packages/client/src/client/clientTransport.ts | 16 +- packages/client/src/client/clientV2.ts | 184 +++++++++++++++--- .../client/src/client/streamableHttpV2.ts | 11 +- packages/client/src/validators/cfWorker.ts | 2 +- packages/client/test/client/clientV2.test.ts | 77 +++++++- packages/core/src/shared/streamDriver.ts | 2 +- packages/server/src/index.ts | 10 +- packages/server/src/server/compat.ts | 2 +- packages/server/src/server/mcpServer.ts | 104 +++++++--- packages/server/src/server/sessionCompat.ts | 4 +- packages/server/src/server/shttpHandler.ts | 62 +++--- packages/server/src/validators/cfWorker.ts | 2 +- packages/server/test/mcpServer.test.ts | 6 +- 13 files changed, 373 insertions(+), 109 deletions(-) diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index 7084b1ef8..de5d8decc 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -1,4 +1,5 @@ import type { + Dispatcher, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -6,14 +7,10 @@ import type { Notification, Progress, Request, + RequestOptions, Transport } from '@modelcontextprotocol/core'; -import { getResultSchema, isJSONRPCErrorResponse } from '@modelcontextprotocol/core'; - -// TODO(ts-rebuild): replace with `from '@modelcontextprotocol/core'` once the core barrel exports these. -// Dispatcher/StreamDriver are written by a sibling fork to packages/core/src/shared/{dispatcher,streamDriver}.ts. -import type { Dispatcher, RequestOptions } from '@modelcontextprotocol/core'; -import { StreamDriver } from '@modelcontextprotocol/core'; +import { getResultSchema, StreamDriver } from '@modelcontextprotocol/core'; /** * Per-call options for {@linkcode ClientTransport.fetch}. @@ -86,6 +83,7 @@ export function isPipeTransport(t: Transport | ClientTransport): t is Transport * {@linkcode StreamDriver}. The supplied {@linkcode Dispatcher} services any * server-initiated requests (sampling, elicitation, roots) that arrive on the pipe. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- adapter is context-agnostic; the caller's Dispatcher subclass owns ContextT export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher): ClientTransport { const driver = new StreamDriver(dispatcher, pipe); let started = false; @@ -128,10 +126,9 @@ export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher void) | undefined; const queue: JSONRPCNotification[] = []; let wake: (() => void) | undefined; - push = n => { + const push = (n: JSONRPCNotification) => { queue.push(n); wake?.(); }; @@ -153,4 +150,5 @@ export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher = new Set(); private _requestMessageId = 0; private _pendingListChangedConfig?: ListChangedHandlers; + private _experimental?: { tasks: ExperimentalClientTasks }; onclose?: () => void; onerror?: (error: Error) => void; @@ -236,7 +261,7 @@ export class Client { /** Low-level: send one typed request. Runs the MRTR loop. */ async request(req: { method: M; params?: RequestTypeMap[M]['params'] }, options?: RequestOptions) { - const schema = (await import('@modelcontextprotocol/core')).getResultSchema(req.method); + const schema = getResultSchema(req.method); return this._request({ method: req.method, params: req.params }, schema, options) as Promise; } @@ -287,14 +312,27 @@ export class Client { this._cacheToolMetadata(result.tools); return result; } - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { - if (this._cachedRequiredTaskTools.has(params.name)) { + async callTool( + params: CallToolRequest['params'], + options?: RequestOptions & { awaitTask?: boolean } + ): Promise { + if (this._cachedRequiredTaskTools.has(params.name) && !options?.task && !options?.awaitTask) { throw new ProtocolError( ProtocolErrorCode.InvalidRequest, - `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` + `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() or pass {awaitTask: true}.` ); } - const result = await this._request({ method: 'tools/call', params }, CallToolResultSchema, options); + const raw = await this._requestRaw({ method: 'tools/call', params }, options); + // SEP-2557: server may return a task even when not requested. + if (isCreateTaskResult(raw)) { + if (options?.awaitTask) { + return this._pollTaskToCompletion(raw.task.taskId, options); + } + return raw; + } + const parsed = parseSchema(CallToolResultSchema, raw); + if (!parsed.success) throw parsed.error; + const result = parsed.data; const validator = this._cachedToolOutputValidators.get(params.name); if (validator) { if (!result.structuredContent && !result.isError) { @@ -319,9 +357,88 @@ export class Client { return this.notification({ method: 'notifications/roots/list_changed' }); } + // -- tasks (SEP-1686 / SEP-2557) ----------------------------------------- + // Kept isolated: typed RPCs + the polymorphism check in callTool above. The + // streaming/polling helpers live in {@linkcode ExperimentalClientTasks}. + + async getTask(params: GetTaskRequest['params'], options?: RequestOptions) { + return this._request({ method: 'tasks/get', params }, GetTaskResultSchema, options); + } + async listTasks(params?: ListTasksRequest['params'], options?: RequestOptions) { + return this._request({ method: 'tasks/list', params }, ListTasksResultSchema, options); + } + async cancelTask(params: CancelTaskRequest['params'], options?: RequestOptions) { + return this._request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); + } + + /** + * The connection's {@linkcode TaskManager}. Only present when connected over a + * pipe-shaped transport (the StreamDriver owns it). Request-shaped + * transports have no per-connection task buffer. + */ + get taskManager(): TaskManager { + const tm = this._ct?.driver?.taskManager; + if (!tm) { + throw new SdkError( + SdkErrorCode.NotConnected, + 'taskManager is only available when connected via a pipe-shaped Transport (stdio/SSE/InMemory).' + ); + } + return tm; + } + + /** + * Access experimental task helpers (callToolStream, getTaskResult, ...). + * + * @experimental + */ + get experimental(): { tasks: ExperimentalClientTasks } { + if (!this._experimental) { + this._experimental = { tasks: new ExperimentalClientTasks(this as never) }; + } + return this._experimental; + } + + /** @internal structural compat for {@linkcode ExperimentalClientTasks} */ + private isToolTask(toolName: string): boolean { + return this._cachedKnownTaskTools.has(toolName); + } + /** @internal structural compat for {@linkcode ExperimentalClientTasks} */ + private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { + return this._cachedToolOutputValidators.get(toolName); + } + + private async _pollTaskToCompletion(taskId: string, options?: RequestOptions): Promise { + // SEP-2557 collapses tasks/result into tasks/get; poll status, then + // fetch payload. Backoff is fixed-interval; the streaming variant lives + // in ExperimentalClientTasks. + const intervalMs = 500; + while (true) { + options?.signal?.throwIfAborted(); + const r: GetTaskResult = await this.getTask({ taskId }, options); + const status = r.status; + if (status === 'completed' || status === 'failed' || status === 'cancelled') { + try { + return await this._request({ method: 'tasks/result', params: { taskId } }, CallToolResultSchema, options); + } catch { + return { content: [], isError: status !== 'completed' }; + } + } + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + } + // -- internals ----------------------------------------------------------- private async _request(req: Request, resultSchema: T, options?: RequestOptions): Promise> { + const raw = await this._requestRaw(req, options); + const parsed = parseSchema(resultSchema, raw); + if (!parsed.success) throw parsed.error; + return parsed.data as SchemaOutput; + } + + /** Like {@linkcode _request} but returns the unparsed result. Used where the result is polymorphic (e.g. SEP-2557 task results). */ + private async _requestRaw(req: Request, options?: RequestOptions): Promise { if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); let inputResponses: Record = {}; for (let round = 0; round < this._mrtrMaxRounds; round++) { @@ -342,7 +459,7 @@ export class Client { resetTimeoutOnProgress: options?.resetTimeoutOnProgress, maxTotalTimeout: options?.maxTotalTimeout, onprogress: options?.onprogress, - onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(e => this.onerror?.(e)) + onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)) }; const resp = await this._ct.fetch(jr, opts); if (isJSONRPCErrorResponse(resp)) { @@ -353,9 +470,7 @@ export class Client { inputResponses = { ...inputResponses, ...(await this._serviceInputRequests(raw.InputRequests)) }; continue; } - const parsed = parseSchema(resultSchema, raw); - if (!parsed.success) throw parsed.error; - return parsed.data as SchemaOutput; + return raw; } throw new ProtocolError(ProtocolErrorCode.InternalError, `MRTR exceeded ${this._mrtrMaxRounds} rounds for ${req.method}`); } @@ -422,10 +537,11 @@ export class Client { throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); } } catch (error) { - if (!(error instanceof ProtocolError) || (error as ProtocolError).code !== ProtocolErrorCode.MethodNotFound) { - // Non-method-not-found error from discover: surface it. - if (error instanceof ProtocolError) throw error; - } + if ( + (!(error instanceof ProtocolError) || (error as ProtocolError).code !== ProtocolErrorCode.MethodNotFound) && // Non-method-not-found error from discover: surface it. + error instanceof ProtocolError + ) + throw error; } await this._initializeHandshake(options, () => {}); } @@ -436,7 +552,10 @@ export class Client { this._cachedRequiredTaskTools.clear(); for (const tool of tools) { if (tool.outputSchema) { - this._cachedToolOutputValidators.set(tool.name, this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType)); + this._cachedToolOutputValidators.set( + tool.name, + this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) + ); } const ts = tool.execution?.taskSupport; if (ts === 'required' || ts === 'optional') this._cachedKnownTaskTools.add(tool.name); @@ -454,16 +573,25 @@ export class Client { if (c.autoRefresh === false) return c.onChanged(null, null); try { c.onChanged(null, (await fetch()) as never); - } catch (e) { - c.onChanged(e instanceof Error ? e : new Error(String(e)), null); + } catch (error) { + c.onChanged(error instanceof Error ? error : new Error(String(error)), null); } }); }; - wire('tools', 'notifications/tools/list_changed', async () => (await this.listTools()).tools); - wire('prompts', 'notifications/prompts/list_changed', async () => (await this.listPrompts()).prompts ?? []); - wire('resources', 'notifications/resources/list_changed', async () => (await this.listResources()).resources ?? []); + wire('tools', 'notifications/tools/list_changed', async () => { + const r = await this.listTools(); + return r.tools; + }); + wire('prompts', 'notifications/prompts/list_changed', async () => { + const r = await this.listPrompts(); + return r.prompts ?? []; + }); + wire('resources', 'notifications/resources/list_changed', async () => { + const r = await this.listResources(); + return r.resources ?? []; + }); } } -export type { ClientTransport, ClientFetchOptions } from './clientTransport.js'; -export { pipeAsClientTransport, isPipeTransport } from './clientTransport.js'; +export type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; +export { isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; diff --git a/packages/client/src/client/streamableHttpV2.ts b/packages/client/src/client/streamableHttpV2.ts index cf30b6a18..7e241c844 100644 --- a/packages/client/src/client/streamableHttpV2.ts +++ b/packages/client/src/client/streamableHttpV2.ts @@ -1,4 +1,10 @@ -import type { FetchLike, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, JSONRPCResultResponse } from '@modelcontextprotocol/core'; +import type { + FetchLike, + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse +} from '@modelcontextprotocol/core'; import { isJSONRPCErrorResponse, isJSONRPCNotification, @@ -276,7 +282,8 @@ export class StreamableHttpClientTransportV2 implements ClientTransport { private _routeNotification(msg: JSONRPCNotification, opts: ClientFetchOptions): void { if (msg.method === 'notifications/progress' && opts.onprogress) { - const { progressToken: _t, ...progress } = (msg.params ?? {}) as Record; + const { progressToken: _progressToken, ...progress } = (msg.params ?? {}) as Record; + void _progressToken; opts.onprogress(progress as never); return; } diff --git a/packages/client/src/validators/cfWorker.ts b/packages/client/src/validators/cfWorker.ts index b068e69a1..7d1c843e5 100644 --- a/packages/client/src/validators/cfWorker.ts +++ b/packages/client/src/validators/cfWorker.ts @@ -6,5 +6,5 @@ * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/client/validators/cf-worker'; * ``` */ -export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core'; +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; diff --git a/packages/client/test/client/clientV2.test.ts b/packages/client/test/client/clientV2.test.ts index 527111e9e..d2053b7bd 100644 --- a/packages/client/test/client/clientV2.test.ts +++ b/packages/client/test/client/clientV2.test.ts @@ -1,4 +1,6 @@ import type { + CallToolResult, + CreateTaskResult, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -100,7 +102,7 @@ describe('Client (V2)', () => { const { c } = await connected(r => r.method === 'tools/call' ? ok(r.id, { content: [{ type: 'text', text: 'hi' }] }) : err(r.id, -32601, 'nope') ); - const result = await c.callTool({ name: 'x', arguments: {} }); + const result = (await c.callTool({ name: 'x', arguments: {} })) as CallToolResult; expect(result.content[0]).toEqual({ type: 'text', text: 'hi' }); }); @@ -175,7 +177,7 @@ describe('Client (V2)', () => { const c = new Client({ name: 'c', version: '1' }, { capabilities: { elicitation: {} } }); c.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { x: 1 } })); await c.connect(ct); - const result = await c.callTool({ name: 't', arguments: {} }); + const result = (await c.callTool({ name: 't', arguments: {} })) as CallToolResult; expect(result.content[0]).toEqual({ type: 'text', text: 'done' }); expect(round).toBe(2); const second = sent.filter(r => r.method === 'tools/call')[1]; @@ -281,4 +283,75 @@ describe('Client (V2)', () => { expect(got).toHaveLength(1); }); }); + + describe('tasks (SEP-1686 / SEP-2557)', () => { + async function connected(handler: (req: JSONRPCRequest) => FetchResp | Promise) { + const m = mockTransport(req => { + if (req.method === 'server/discover') return ok(req.id, { capabilities: { tools: {}, tasks: { tools: { call: true } } } }); + return handler(req); + }); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(m.ct); + return { c, ...m }; + } + + it('experimental.tasks getter exists and is lazily constructed once', async () => { + const { c } = await connected(r => ok(r.id, {})); + const a = c.experimental.tasks; + const b = c.experimental.tasks; + expect(a).toBe(b); + expect(typeof a.callToolStream).toBe('function'); + }); + + it('callTool returns CreateTaskResult unchanged when server returns a task (SEP-2557 unsolicited)', async () => { + const taskResult = { task: { taskId: 't-1', status: 'working', createdAt: '2026-01-01T00:00:00Z' } }; + const { c } = await connected(r => (r.method === 'tools/call' ? ok(r.id, taskResult) : err(r.id, -32601, ''))); + const result = (await c.callTool({ name: 'slow', arguments: {} })) as CreateTaskResult; + expect(result.task.taskId).toBe('t-1'); + }); + + const taskBody = (overrides: Record = {}) => ({ + taskId: 't-2', + status: 'working', + ttl: null, + createdAt: '2026-01-01T00:00:00Z', + lastUpdatedAt: '2026-01-01T00:00:00Z', + ...overrides + }); + + it('callTool with awaitTask polls tasks/get then tasks/result', async () => { + let getCalls = 0; + const { c, sent } = await connected(r => { + if (r.method === 'tools/call') return ok(r.id, { task: taskBody() }); + if (r.method === 'tasks/get') { + getCalls++; + return ok(r.id, taskBody({ status: getCalls === 1 ? 'working' : 'completed' })); + } + if (r.method === 'tasks/result') return ok(r.id, { content: [{ type: 'text', text: 'done' }] }); + return err(r.id, -32601, ''); + }); + const result = (await c.callTool({ name: 'slow', arguments: {} }, { awaitTask: true })) as CallToolResult; + expect(result.content[0]).toEqual({ type: 'text', text: 'done' }); + expect(getCalls).toBe(2); + expect(sent.some(r => r.method === 'tasks/result')).toBe(true); + }); + + it('getTask / listTasks / cancelTask call the right methods', async () => { + const { c, sent } = await connected(r => { + if (r.method === 'tasks/get') return ok(r.id, taskBody({ taskId: 'x', status: 'completed' })); + if (r.method === 'tasks/list') return ok(r.id, { tasks: [] }); + if (r.method === 'tasks/cancel') return ok(r.id, taskBody({ taskId: 'x', status: 'cancelled' })); + return err(r.id, -32601, ''); + }); + await c.getTask({ taskId: 'x' }); + await c.listTasks(); + await c.cancelTask({ taskId: 'x' }); + expect(sent.map(r => r.method).filter(m => m.startsWith('tasks/'))).toEqual(['tasks/get', 'tasks/list', 'tasks/cancel']); + }); + + it('taskManager throws NotConnected when not connected via a pipe', async () => { + const { c } = await connected(r => ok(r.id, {})); + expect(() => c.taskManager).toThrow(/pipe-shaped Transport/); + }); + }); }); diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index bbf042aa4..c75f4618f 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -15,6 +15,7 @@ import type { Result } from '../types/index.js'; import { + getResultSchema, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, @@ -25,7 +26,6 @@ import { import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; import type { DispatchEnv, Dispatcher } from './dispatcher.js'; -import { getResultSchema } from '../types/index.js'; import type { NotificationOptions, ProgressCallback, RequestOptions } from './protocol.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './protocol.js'; import type { InboundContext, TaskManagerHost, TaskManagerOptions } from './taskManager.js'; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cc650d8f2..f4c811c76 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,6 +6,7 @@ // // Any new export added here becomes public API. Use named exports, not wildcards. +export { Server } from './server/compat.js'; export type { CompletableSchema, CompleteCallback } from './server/completable.js'; export { completable, isCompletable } from './server/completable.js'; export type { @@ -25,13 +26,12 @@ export type { ToolCallback } from './server/mcpServer.js'; export { McpServer, ResourceTemplate } from './server/mcpServer.js'; -export { Server } from './server/compat.js'; -export type { ShttpHandlerOptions } from './server/shttpHandler.js'; -export { shttpHandler } from './server/shttpHandler.js'; -export type { SessionCompatOptions } from './server/sessionCompat.js'; -export { SessionCompat } from './server/sessionCompat.js'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; +export type { SessionCompatOptions } from './server/sessionCompat.js'; +export { SessionCompat } from './server/sessionCompat.js'; +export type { ShttpHandlerOptions } from './server/shttpHandler.js'; +export { shttpHandler } from './server/shttpHandler.js'; export { StdioServerTransport } from './server/stdio.js'; export type { EventId, diff --git a/packages/server/src/server/compat.ts b/packages/server/src/server/compat.ts index e04c41d83..96cf4f266 100644 --- a/packages/server/src/server/compat.ts +++ b/packages/server/src/server/compat.ts @@ -2,5 +2,5 @@ * v1 compat alias. The low-level `Server` class is now the same as `McpServer`. * @deprecated Import {@linkcode McpServer} from `./mcpServer.js` directly. */ -export { McpServer as Server } from './mcpServer.js'; export type { ServerOptions } from './mcpServer.js'; +export { McpServer as Server } from './mcpServer.js'; diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index b40f1a7c1..1cebb6f5c 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -3,7 +3,6 @@ import type { BaseContext, BaseMetadata, CallToolRequest, - MessageExtraInfo, CallToolResult, ClientCapabilities, CompleteRequestPrompt, @@ -34,6 +33,7 @@ import type { ListToolsResult, LoggingLevel, LoggingMessageNotification, + MessageExtraInfo, Notification, NotificationMethod, NotificationOptions, @@ -352,8 +352,7 @@ export class McpServer extends Dispatcher { ...base, mcpReq: { ...base.mcpReq, - log: (level, data, logger) => - base.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } }), + log: (level, data, logger) => base.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } }), elicitInput: (params, options) => this._elicitInputViaCtx(base, params, options), requestSampling: (params, options) => this._createMessageViaCtx(base, params, options) }, @@ -476,9 +475,7 @@ export class McpServer extends Dispatcher { methodOrSchema: RequestMethod | { shape: { method: unknown } }, handler: (request: never, ctx: ServerContext) => Result | Promise ): void { - const method = ( - typeof methodOrSchema === 'string' ? methodOrSchema : extractMethodFromSchema(methodOrSchema) - ) as RequestMethod; + const method = (typeof methodOrSchema === 'string' ? methodOrSchema : extractMethodFromSchema(methodOrSchema)) as RequestMethod; this._assertRequestHandlerCapability(method); const h = handler as (request: JSONRPCRequest, ctx: ServerContext) => Result | Promise; if (method === 'tools/call') { @@ -687,17 +684,19 @@ export class McpServer extends Dispatcher { private _assertCapabilityForMethod(method: RequestMethod): void { switch (method) { - case 'sampling/createMessage': + case 'sampling/createMessage': { if (!this._clientCapabilities?.sampling) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support sampling (required for ${method})`); } break; - case 'elicitation/create': + } + case 'elicitation/create': { if (!this._clientCapabilities?.elicitation) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation (required for ${method})`); } break; - case 'roots/list': + } + case 'roots/list': { if (!this._clientCapabilities?.roots) { throw new SdkError( SdkErrorCode.CapabilityNotSupported, @@ -705,18 +704,20 @@ export class McpServer extends Dispatcher { ); } break; + } } } private _assertNotificationCapability(method: NotificationMethod): void { switch (method) { - case 'notifications/message': + case 'notifications/message': { if (!this._capabilities.logging) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); } break; + } case 'notifications/resources/updated': - case 'notifications/resources/list_changed': + case 'notifications/resources/list_changed': { if (!this._capabilities.resources) { throw new SdkError( SdkErrorCode.CapabilityNotSupported, @@ -724,7 +725,8 @@ export class McpServer extends Dispatcher { ); } break; - case 'notifications/tools/list_changed': + } + case 'notifications/tools/list_changed': { if (!this._capabilities.tools) { throw new SdkError( SdkErrorCode.CapabilityNotSupported, @@ -732,7 +734,8 @@ export class McpServer extends Dispatcher { ); } break; - case 'notifications/prompts/list_changed': + } + case 'notifications/prompts/list_changed': { if (!this._capabilities.prompts) { throw new SdkError( SdkErrorCode.CapabilityNotSupported, @@ -740,7 +743,8 @@ export class McpServer extends Dispatcher { ); } break; - case 'notifications/elicitation/complete': + } + case 'notifications/elicitation/complete': { if (!this._clientCapabilities?.elicitation?.url) { throw new SdkError( SdkErrorCode.CapabilityNotSupported, @@ -748,42 +752,48 @@ export class McpServer extends Dispatcher { ); } break; + } } } private _assertRequestHandlerCapability(method: string): void { switch (method) { - case 'completion/complete': + case 'completion/complete': { if (!this._capabilities.completions) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); } break; - case 'logging/setLevel': + } + case 'logging/setLevel': { if (!this._capabilities.logging) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); } break; + } case 'prompts/get': - case 'prompts/list': + case 'prompts/list': { if (!this._capabilities.prompts) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); } break; + } case 'resources/list': case 'resources/templates/list': case 'resources/read': case 'resources/subscribe': - case 'resources/unsubscribe': + case 'resources/unsubscribe': { if (!this._capabilities.resources) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); } break; + } case 'tools/call': - case 'tools/list': + case 'tools/list': { if (!this._capabilities.tools) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); } break; + } } } @@ -996,8 +1006,9 @@ export class McpServer extends Dispatcher { assertCompleteRequestResourceTemplate(request); return this.handleResourceCompletion(request, request.params.ref); } - default: + default: { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`); + } } }); this._completionHandlerInitialized = true; @@ -1402,7 +1413,11 @@ export class McpServer extends Dispatcher { /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ tool(name: string, description: string, cb: ToolCallback): RegisteredTool; /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ - tool(name: string, paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: LegacyToolCallback): RegisteredTool; + tool( + name: string, + paramsSchemaOrAnnotations: Args | ToolAnnotations, + cb: LegacyToolCallback + ): RegisteredTool; /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ tool( name: string, @@ -1411,7 +1426,12 @@ export class McpServer extends Dispatcher { cb: LegacyToolCallback ): RegisteredTool; /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ - tool(name: string, paramsSchema: Args, annotations: ToolAnnotations, cb: LegacyToolCallback): RegisteredTool; + tool( + name: string, + paramsSchema: Args, + annotations: ToolAnnotations, + cb: LegacyToolCallback + ): RegisteredTool; /** @deprecated Use {@linkcode McpServer.registerTool | registerTool()} instead. */ tool( name: string, @@ -1441,7 +1461,17 @@ export class McpServer extends Dispatcher { } } const cb = rest[0] as ToolCallback; - return this._createRegisteredTool(name, undefined, description, inputSchema, undefined, annotations, { taskSupport: 'forbidden' }, undefined, cb); + return this._createRegisteredTool( + name, + undefined, + description, + inputSchema, + undefined, + annotations, + { taskSupport: 'forbidden' }, + undefined, + cb + ); } /** @deprecated Use {@linkcode McpServer.registerPrompt | registerPrompt()} instead. */ @@ -1451,7 +1481,12 @@ export class McpServer extends Dispatcher { /** @deprecated Use {@linkcode McpServer.registerPrompt | registerPrompt()} instead. */ prompt(name: string, argsSchema: Args, cb: LegacyPromptCallback): RegisteredPrompt; /** @deprecated Use {@linkcode McpServer.registerPrompt | registerPrompt()} instead. */ - prompt(name: string, description: string, argsSchema: Args, cb: LegacyPromptCallback): RegisteredPrompt; + prompt( + name: string, + description: string, + argsSchema: Args, + cb: LegacyPromptCallback + ): RegisteredPrompt; prompt(name: string, ...rest: unknown[]): RegisteredPrompt { if (this._registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); let description: string | undefined; @@ -1472,7 +1507,12 @@ export class McpServer extends Dispatcher { /** @deprecated Use {@linkcode McpServer.registerResource | registerResource()} instead. */ resource(name: string, template: ResourceTemplate, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; /** @deprecated Use {@linkcode McpServer.registerResource | registerResource()} instead. */ - resource(name: string, template: ResourceTemplate, metadata: ResourceMetadata, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; + resource( + name: string, + template: ResourceTemplate, + metadata: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; resource(name: string, uriOrTemplate: string | ResourceTemplate, ...rest: unknown[]): RegisteredResource | RegisteredResourceTemplate { let metadata: ResourceMetadata | undefined; if (typeof rest[0] === 'object') metadata = rest.shift() as ResourceMetadata; @@ -1485,7 +1525,13 @@ export class McpServer extends Dispatcher { return r; } if (this._registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`); - const r = this._createRegisteredResourceTemplate(name, undefined, uriOrTemplate, metadata, readCallback as ReadResourceTemplateCallback); + const r = this._createRegisteredResourceTemplate( + name, + undefined, + uriOrTemplate, + metadata, + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); return r; @@ -1672,7 +1718,7 @@ const EMPTY_OBJECT_JSON_SCHEMA = { type: 'object' as const, properties: {} }; const EMPTY_COMPLETION_RESULT: CompleteResult = { completion: { values: [], hasMore: false } }; function jsonResponse(status: number, body: unknown): Response { - return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + return Response.json(body, { status, headers: { 'content-type': 'application/json' } }); } function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { @@ -1747,7 +1793,7 @@ function extractMethodFromSchema(schema: { shape: { method: unknown } }): string const lit = schema.shape.method as { value?: unknown; _zod?: { def?: { values?: unknown[] } } }; const v = lit?.value ?? lit?._zod?.def?.values?.[0]; if (typeof v !== 'string') { - throw new Error('setRequestHandler(schema, handler): schema.shape.method must be a z.literal(string)'); + throw new TypeError('setRequestHandler(schema, handler): schema.shape.method must be a z.literal(string)'); } return v; } @@ -1761,7 +1807,7 @@ function isZodRawShapeCompat(v: unknown): v is ZodRawShapeCompat { if (isStandardSchema(v)) return false; const values = Object.values(v as object); if (values.length === 0) return true; - return values.some(isZodTypeLike); + return values.some(v => isZodTypeLike(v)); } /** diff --git a/packages/server/src/server/sessionCompat.ts b/packages/server/src/server/sessionCompat.ts index d5cb59a2e..303a96fe2 100644 --- a/packages/server/src/server/sessionCompat.ts +++ b/packages/server/src/server/sessionCompat.ts @@ -50,9 +50,7 @@ interface SessionEntry { } /** Result of {@linkcode SessionCompat.validate}. */ -export type SessionValidation = - | { ok: true; sessionId: string | undefined; isInitialize: boolean } - | { ok: false; response: Response }; +export type SessionValidation = { ok: true; sessionId: string | undefined; isInitialize: boolean } | { ok: false; response: Response }; function jsonError(status: number, code: number, message: string, headers?: Record): Response { return Response.json( diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index 4b4845aa7..149d541f3 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -1,4 +1,11 @@ -import type { AuthInfo, DispatchEnv, DispatchOutput, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { + AuthInfo, + DispatchEnv, + DispatchOutput, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest +} from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, @@ -29,7 +36,10 @@ export interface EventStore { * Replays events stored after the given event ID, calling `send` for each. * @returns The stream ID the replayed events belong to */ - replayEventsAfter(lastEventId: EventId, opts: { send: (eventId: EventId, message: JSONRPCMessage) => Promise }): Promise; + replayEventsAfter( + lastEventId: EventId, + opts: { send: (eventId: EventId, message: JSONRPCMessage) => Promise } + ): Promise; } /** @@ -163,7 +173,7 @@ export function shttpHandler( if (!eventStore) return; if (protocolVersion < '2025-11-25') return; const primingId = await eventStore.storeEvent(streamId, {} as JSONRPCMessage); - const retry = retryInterval !== undefined ? `retry: ${retryInterval}\n` : ''; + const retry = retryInterval === undefined ? '' : `retry: ${retryInterval}\n`; controller.enqueue(encoder.encode(`id: ${primingId}\n${retry}data: \n\n`)); } @@ -193,22 +203,22 @@ export function shttpHandler( } let raw: unknown; - if (extra?.parsedBody !== undefined) { - raw = extra.parsedBody; - } else { + if (extra?.parsedBody === undefined) { try { raw = await req.json(); - } catch (e) { - onerror?.(e as Error); + } catch (error) { + onerror?.(error as Error); return jsonError(400, -32_700, 'Parse error: Invalid JSON'); } + } else { + raw = extra.parsedBody; } let messages: JSONRPCMessage[]; try { messages = Array.isArray(raw) ? raw.map(m => JSONRPCMessageSchema.parse(m)) : [JSONRPCMessageSchema.parse(raw)]; - } catch (e) { - onerror?.(e as Error); + } catch (error) { + onerror?.(error as Error); return jsonError(400, -32_700, 'Parse error: Invalid JSON-RPC message'); } @@ -225,11 +235,11 @@ export function shttpHandler( if (protoErr) return protoErr; } - const requests = messages.filter(isJSONRPCRequest); - const notifications = messages.filter(isJSONRPCNotification); + const requests = messages.filter(m => isJSONRPCRequest(m)); + const notifications = messages.filter(m => isJSONRPCNotification(m)); for (const n of notifications) { - void server.dispatchNotification(n).catch(e => onerror?.(e as Error)); + void server.dispatchNotification(n).catch(error => onerror?.(error as Error)); } if (requests.length === 0) { @@ -272,8 +282,8 @@ export function shttpHandler( await emit(controller, encoder, streamId, out.message); } } - } catch (e) { - onerror?.(e as Error); + } catch (error) { + onerror?.(error as Error); } finally { try { controller.close(); @@ -347,8 +357,8 @@ export function shttpHandler( } }); if (session) session.setStandaloneStream(sessionId, controller); - } catch (e) { - onerror?.(e as Error); + } catch (error) { + onerror?.(error as Error); try { controller.close(); } catch { @@ -381,18 +391,22 @@ export function shttpHandler( return async (req: Request, extra?: ShttpRequestExtra): Promise => { try { switch (req.method) { - case 'POST': + case 'POST': { return await handlePost(req, extra); - case 'GET': + } + case 'GET': { return await handleGet(req); - case 'DELETE': + } + case 'DELETE': { return await handleDelete(req); - default: + } + default: { return jsonError(405, -32_000, 'Method not allowed.', { headers: { Allow: 'GET, POST, DELETE' } }); + } } - } catch (e) { - onerror?.(e as Error); - return jsonError(400, -32_700, 'Parse error', { data: String(e) }); + } catch (error) { + onerror?.(error as Error); + return jsonError(400, -32_700, 'Parse error', { data: String(error) }); } }; } diff --git a/packages/server/src/validators/cfWorker.ts b/packages/server/src/validators/cfWorker.ts index 9a3a88405..e04436dbd 100644 --- a/packages/server/src/validators/cfWorker.ts +++ b/packages/server/src/validators/cfWorker.ts @@ -6,5 +6,5 @@ * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; * ``` */ -export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core'; +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core'; diff --git a/packages/server/test/mcpServer.test.ts b/packages/server/test/mcpServer.test.ts index ea06f2e83..03dd334cc 100644 --- a/packages/server/test/mcpServer.test.ts +++ b/packages/server/test/mcpServer.test.ts @@ -251,9 +251,9 @@ describe('McpServer compat / .server / connect()', () => { it('elicitInput() instance method throws NotConnected when no driver', async () => { const s = new McpServer({ name: 's', version: '1' }); - await expect( - s.elicitInput({ message: 'q', requestedSchema: { type: 'object', properties: {} } }) - ).rejects.toThrow(/not connected/i); + await expect(s.elicitInput({ message: 'q', requestedSchema: { type: 'object', properties: {} } })).rejects.toThrow( + /not connected/i + ); }); it('registerCapabilities throws after connect', async () => { From baea41a2d6306da8b919f4c6c507a286677c998d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 15:07:29 +0000 Subject: [PATCH 11/55] feat(core): add Dispatcher.dispatchRaw for envelope-agnostic drivers (gRPC/protobuf) Yields {kind: notification|result|error} without JSON-RPC wrapping. Handlers unchanged. docs/grpc-integration.md shows the adapter shape. --- docs/grpc-integration.md | 75 ++++++++++++++++++++ packages/core/src/shared/dispatcher.ts | 33 +++++++++ packages/core/test/shared/dispatcher.test.ts | 32 +++++++++ 3 files changed, 140 insertions(+) create mode 100644 docs/grpc-integration.md diff --git a/docs/grpc-integration.md b/docs/grpc-integration.md new file mode 100644 index 000000000..6d669c53d --- /dev/null +++ b/docs/grpc-integration.md @@ -0,0 +1,75 @@ +# gRPC Integration via `Dispatcher.dispatchRaw` + +Status: experimental. The `dispatchRaw` entry point lets a non-JSON-RPC driver (gRPC, REST, protobuf) call MCP handlers without constructing JSON-RPC envelopes. + +## The seam + +`McpServer extends Dispatcher`, so any server has: + +```ts +mcpServer.dispatchRaw(method: string, params: unknown, env?: DispatchEnv): AsyncIterable + +type RawDispatchOutput = + | { kind: 'notification'; method: string; params?: unknown } + | { kind: 'result'; result: Result } + | { kind: 'error'; code: number; message: string; data?: unknown }; +``` + +No `{jsonrpc: '2.0', id}` wrapping in or out. Handlers registered via `registerTool`/`setRequestHandler` work unchanged — `dispatchRaw` synthesizes the envelope internally. + +## gRPC service binding (sketch) + +Given a `.proto` with per-method RPCs (per SEP-1319's named param/result types): + +```proto +service Mcp { + rpc CallTool(CallToolRequestParams) returns (stream CallToolStreamItem); + rpc ListTools(ListToolsRequestParams) returns (ListToolsResult); + // ... +} +``` + +The adapter is one function per method: + +```ts +import * as grpc from '@grpc/grpc-js'; +import { McpServer } from '@modelcontextprotocol/server'; + +export function bindMcpToGrpc(mcpServer: McpServer): grpc.UntypedServiceImplementation { + return { + async CallTool(call: grpc.ServerWritableStream) { + const env = { authInfo: extractAuth(call.metadata) }; + for await (const out of mcpServer.dispatchRaw('tools/call', protoToObj(call.request), env)) { + if (out.kind === 'notification') call.write({ notification: objToProto(out) }); + else if (out.kind === 'result') call.write({ result: objToProto(out.result) }); + else call.destroy(new grpc.StatusBuilder().withCode(grpcCodeFor(out.code)).withDetails(out.message).build()); + } + call.end(); + }, + async ListTools(call, callback) { + for await (const out of mcpServer.dispatchRaw('tools/list', protoToObj(call.request))) { + if (out.kind === 'result') return callback(null, objToProto(out.result)); + if (out.kind === 'error') return callback({ code: grpcCodeFor(out.code), details: out.message }); + } + }, + // ... one binding per method + }; +} +``` + +`protoToObj`/`objToProto` are mechanical (protobuf message ↔ plain object). The `.proto` itself can be generated from `spec.types.ts` since SEP-1319 gives every params/result a named top-level type. + +## Server→client (elicitation/sampling) + +gRPC unary has no back-channel. Two options: + +1. **MRTR (recommended):** handler returns `IncompleteResult{InputRequests}`; `dispatchRaw` yields it as the result; the gRPC client re-calls with `inputResponses`. This is the SEP-2322 model and works without bidi streaming. +2. **Bidi stream:** make `tools/call` a bidi RPC; the server writes elicitation requests to the stream, client writes responses. Pass `env.send` that writes to the stream and awaits a matching reply. + +`dispatchRaw` supports both: with no `env.send`, `ctx.mcpReq.elicitInput()` throws (handler must use MRTR-native form); with `env.send` provided, it works inline. + +## What's not in the SDK + +- The `.proto` file (separate artifact, ideally generated) +- The `@modelcontextprotocol/grpc` adapter package (the binding above) +- protobuf↔object conversion helpers diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 9dafbf612..1a452f192 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -55,6 +55,14 @@ export type DispatchOutput = | { kind: 'notification'; message: JSONRPCNotification } | { kind: 'response'; message: JSONRPCResponse | JSONRPCErrorResponse }; +/** + * Envelope-agnostic output from {@linkcode Dispatcher.dispatchRaw}. No JSON-RPC `{jsonrpc, id}` wrapping. + */ +export type RawDispatchOutput = + | { kind: 'notification'; method: string; params?: Record } + | { kind: 'result'; result: Result } + | { kind: 'error'; code: number; message: string; data?: unknown }; + type RawHandler = (request: JSONRPCRequest, ctx: ContextT) => Promise; /** @@ -174,6 +182,31 @@ export class Dispatcher { yield { kind: 'response', message: final! }; } + /** + * Envelope-agnostic dispatch for non-JSON-RPC drivers (gRPC, protobuf, REST). + * Takes `{method, params}` directly and yields unwrapped notifications and a terminal + * result/error. The JSON-RPC `{jsonrpc, id}` envelope is synthesized internally so + * registered handlers (which receive `JSONRPCRequest`) work unchanged. + * + * @experimental Shape may change to align with SEP-1319 named param/result types. + */ + async *dispatchRaw( + method: string, + params: Record | undefined, + env: DispatchEnv = {} + ): AsyncGenerator { + const synthetic: JSONRPCRequest = { jsonrpc: '2.0', id: 0, method, params }; + for await (const out of this.dispatch(synthetic, env)) { + if (out.kind === 'notification') { + yield { kind: 'notification', method: out.message.method, params: out.message.params }; + } else if ('result' in out.message) { + yield { kind: 'result', result: out.message.result }; + } else { + yield { kind: 'error', ...out.message.error }; + } + } + } + /** * Dispatch one inbound notification to its handler. Errors are reported via the * returned promise; unknown methods are silently ignored. diff --git a/packages/core/test/shared/dispatcher.test.ts b/packages/core/test/shared/dispatcher.test.ts index bd240e13d..1cad76ad7 100644 --- a/packages/core/test/shared/dispatcher.test.ts +++ b/packages/core/test/shared/dispatcher.test.ts @@ -191,3 +191,35 @@ describe('Dispatcher', () => { expect(r.result).toEqual({ v: 1 }); }); }); + +describe('Dispatcher.dispatchRaw (envelope-agnostic)', () => { + test('yields result without JSON-RPC envelope', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('greet', async r => ({ hello: (r.params as { name: string }).name }) as Result); + const out = []; + for await (const o of d.dispatchRaw('greet', { name: 'proto' })) out.push(o); + expect(out).toEqual([{ kind: 'result', result: { hello: 'proto' } }]); + }); + + test('yields error without envelope', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('boom', async () => { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad'); + }); + const out = []; + for await (const o of d.dispatchRaw('boom', {})) out.push(o); + expect(out).toEqual([{ kind: 'error', code: ProtocolErrorCode.InvalidParams, message: 'bad' }]); + }); + + test('yields notifications then result', async () => { + const d = new Dispatcher(); + d.setRawRequestHandler('work', async (_r, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 't', progress: 1 } }); + return { done: true } as Result; + }); + const out = []; + for await (const o of d.dispatchRaw('work', {})) out.push(o); + expect(out[0]).toMatchObject({ kind: 'notification', method: 'notifications/progress' }); + expect(out[1]).toEqual({ kind: 'result', result: { done: true } }); + }); +}); From e16ee66ab07c5f0a3e5570152982df724bd6463a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 15:08:50 +0000 Subject: [PATCH 12/55] refactor(server): split mcpServer.ts into core + registries + legacy + capabilities + resourceTemplate - mcpServer.ts: 1795 -> 933 LOC (core class, handle/connect/buildContext, server->client methods) - serverRegistries.ts: 873 (registerTool/Resource/Prompt + lazy installers, composed via _registries) - serverCapabilities.ts: 130 (assert* functions, method->capability table) - serverLegacy.ts: 105 (v1 overload parsing helpers) - resourceTemplate.ts: 45 All tests/lint/typecheck green. --- packages/server/src/server/mcpServer.ts | 1138 ++--------------- .../server/src/server/resourceTemplate.ts | 45 + .../server/src/server/serverCapabilities.ts | 127 ++ packages/server/src/server/serverLegacy.ts | 99 ++ .../server/src/server/serverRegistries.ts | 873 +++++++++++++ 5 files changed, 1262 insertions(+), 1020 deletions(-) create mode 100644 packages/server/src/server/resourceTemplate.ts create mode 100644 packages/server/src/server/serverCapabilities.ts create mode 100644 packages/server/src/server/serverLegacy.ts create mode 100644 packages/server/src/server/serverRegistries.ts diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 1cebb6f5c..cf0b99152 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -1,25 +1,16 @@ import type { AuthInfo, BaseContext, - BaseMetadata, - CallToolRequest, - CallToolResult, ClientCapabilities, - CompleteRequestPrompt, - CompleteRequestResourceTemplate, - CompleteResult, CreateMessageRequest, CreateMessageRequestParamsBase, CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, - CreateTaskResult, - CreateTaskServerContext, DispatchEnv, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, - GetPromptResult, Implementation, InitializeRequest, InitializeResult, @@ -27,26 +18,18 @@ import type { JSONRPCRequest, JsonSchemaType, jsonSchemaValidator, - ListPromptsResult, - ListResourcesResult, ListRootsRequest, - ListToolsResult, LoggingLevel, LoggingMessageNotification, MessageExtraInfo, Notification, NotificationMethod, NotificationOptions, - Prompt, - PromptReference, ProtocolOptions, - ReadResourceResult, Request, RequestMethod, RequestOptions, RequestTypeMap, - Resource, - ResourceTemplateReference, ResourceUpdatedNotification, Result, ResultTypeMap, @@ -57,18 +40,14 @@ import type { StreamDriverOptions, TaskManagerHost, TaskManagerOptions, - Tool, ToolAnnotations, ToolExecution, ToolResultContent, ToolUseContent, - Transport, - Variables + Transport } from '@modelcontextprotocol/core'; import { assertClientRequestTaskCapability, - assertCompleteRequestPrompt, - assertCompleteRequestResourceTemplate, assertToolsCallTaskCapability, CallToolRequestSchema, CallToolResultSchema, @@ -81,33 +60,41 @@ import { extractTaskManagerOptions, getResultSchema, isJSONRPCRequest, - isStandardSchema, - isStandardSchemaWithJSON, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, NullTaskManager, parseSchema, - promptArgumentsFromStandardSchema, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, - standardSchemaToJsonSchema, StreamDriver, SUPPORTED_PROTOCOL_VERSIONS, - TaskManager, - UriTemplate, - validateAndWarnToolName, - validateStandardSchema + TaskManager } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import { z } from 'zod/v4'; -import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; -import { getCompleter, isCompletable } from './completable.js'; +import type { ResourceTemplate } from './resourceTemplate.js'; +import { assertCapabilityForMethod, assertNotificationCapability, assertRequestHandlerCapability } from './serverCapabilities.js'; +import type { LegacyPromptCallback, LegacyToolCallback, ZodRawShapeCompat } from './serverLegacy.js'; +import { extractMethodFromSchema, parseLegacyPromptArgs, parseLegacyToolArgs } from './serverLegacy.js'; +import type { + AnyToolHandler, + PromptCallback, + ReadResourceCallback, + ReadResourceTemplateCallback, + RegisteredPrompt, + RegisteredResource, + RegisteredResourceTemplate, + RegisteredTool, + RegistriesHost, + ResourceMetadata, + ToolCallback +} from './serverRegistries.js'; +import { ServerRegistries } from './serverRegistries.js'; /** * Extended tasks capability that includes runtime configuration (store, messageQueue). @@ -143,8 +130,9 @@ export type ServerOptions = Omit & { * * One instance can serve any number of concurrent requests. */ -export class McpServer extends Dispatcher { +export class McpServer extends Dispatcher implements RegistriesHost { private _driver?: StreamDriver; + private readonly _registries = new ServerRegistries(this); private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; @@ -157,16 +145,6 @@ export class McpServer extends Dispatcher { private _loggingLevels = new Map(); private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); - private _registeredResources: { [uri: string]: RegisteredResource } = {}; - private _registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate } = {}; - private _registeredTools: { [name: string]: RegisteredTool } = {}; - private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; - - private _toolHandlersInitialized = false; - private _completionHandlerInitialized = false; - private _resourceHandlersInitialized = false; - private _promptHandlersInitialized = false; - /** * Callback for when initialization has fully completed. */ @@ -214,7 +192,7 @@ export class McpServer extends Dispatcher { } // ─────────────────────────────────────────────────────────────────────── - // Direct dispatch (Proposal 1) + // Direct dispatch // ─────────────────────────────────────────────────────────────────────── /** @@ -335,8 +313,8 @@ export class McpServer extends Dispatcher { await this._driver?.pipe.send(msg, { relatedRequestId }); }, enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, - assertTaskCapability: m => this._assertTaskCapability(m), - assertTaskHandlerCapability: m => this._assertTaskHandlerCapability(m) + assertTaskCapability: m => assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, m, 'Client'), + assertTaskHandlerCapability: m => assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, m, 'Server') }; this._taskManager.bind(host); } @@ -476,7 +454,7 @@ export class McpServer extends Dispatcher { handler: (request: never, ctx: ServerContext) => Result | Promise ): void { const method = (typeof methodOrSchema === 'string' ? methodOrSchema : extractMethodFromSchema(methodOrSchema)) as RequestMethod; - this._assertRequestHandlerCapability(method); + assertRequestHandlerCapability(method, this._capabilities); const h = handler as (request: JSONRPCRequest, ctx: ServerContext) => Result | Promise; if (method === 'tools/call') { const wrapped = async (request: JSONRPCRequest, ctx: ServerContext): Promise => { @@ -523,7 +501,7 @@ export class McpServer extends Dispatcher { private _driverRequest(req: Request, schema: { parse(v: unknown): T }, options?: RequestOptions): Promise { if (this._options?.enforceStrictCapabilities === true) { - this._assertCapabilityForMethod(req.method as RequestMethod); + assertCapabilityForMethod(req.method as RequestMethod, this._clientCapabilities); } return this._requireDriver().request(req, schema as never, options) as Promise; } @@ -674,137 +652,10 @@ export class McpServer extends Dispatcher { * Sends a notification over the connected transport. No-op when not connected. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - this._assertNotificationCapability(notification.method as NotificationMethod); + assertNotificationCapability(notification.method as NotificationMethod, this._capabilities, this._clientCapabilities); await this._driver?.notification(notification, options); } - // ─────────────────────────────────────────────────────────────────────── - // Capability assertions (v1 compat). No-ops once capabilities move per-request. - // ─────────────────────────────────────────────────────────────────────── - - private _assertCapabilityForMethod(method: RequestMethod): void { - switch (method) { - case 'sampling/createMessage': { - if (!this._clientCapabilities?.sampling) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support sampling (required for ${method})`); - } - break; - } - case 'elicitation/create': { - if (!this._clientCapabilities?.elicitation) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation (required for ${method})`); - } - break; - } - case 'roots/list': { - if (!this._clientCapabilities?.roots) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Client does not support listing roots (required for ${method})` - ); - } - break; - } - } - } - - private _assertNotificationCapability(method: NotificationMethod): void { - switch (method) { - case 'notifications/message': { - if (!this._capabilities.logging) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); - } - break; - } - case 'notifications/resources/updated': - case 'notifications/resources/list_changed': { - if (!this._capabilities.resources) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Server does not support notifying about resources (required for ${method})` - ); - } - break; - } - case 'notifications/tools/list_changed': { - if (!this._capabilities.tools) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Server does not support notifying of tool list changes (required for ${method})` - ); - } - break; - } - case 'notifications/prompts/list_changed': { - if (!this._capabilities.prompts) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Server does not support notifying of prompt list changes (required for ${method})` - ); - } - break; - } - case 'notifications/elicitation/complete': { - if (!this._clientCapabilities?.elicitation?.url) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Client does not support URL elicitation (required for ${method})` - ); - } - break; - } - } - } - - private _assertRequestHandlerCapability(method: string): void { - switch (method) { - case 'completion/complete': { - if (!this._capabilities.completions) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); - } - break; - } - case 'logging/setLevel': { - if (!this._capabilities.logging) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); - } - break; - } - case 'prompts/get': - case 'prompts/list': { - if (!this._capabilities.prompts) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); - } - break; - } - case 'resources/list': - case 'resources/templates/list': - case 'resources/read': - case 'resources/subscribe': - case 'resources/unsubscribe': { - if (!this._capabilities.resources) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); - } - break; - } - case 'tools/call': - case 'tools/list': { - if (!this._capabilities.tools) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); - } - break; - } - } - } - - private _assertTaskCapability(method: string): void { - assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, 'Client'); - } - - private _assertTaskHandlerCapability(method: string): void { - assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server'); - } - async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise { if (this._capabilities.logging && !this._isMessageIgnored(params.level, sessionId)) { return this.notification({ method: 'notifications/message', params }); @@ -845,285 +696,36 @@ export class McpServer extends Dispatcher { } // ─────────────────────────────────────────────────────────────────────── - // Tool/Resource/Prompt registries + // Registries (delegated to ServerRegistries) // ─────────────────────────────────────────────────────────────────────── - private setToolRequestHandlers(): void { - if (this._toolHandlersInitialized) return; - this.assertCanSetRequestHandler('tools/list'); - this.assertCanSetRequestHandler('tools/call'); - this.registerCapabilities({ tools: { listChanged: this.getCapabilities().tools?.listChanged ?? true } }); - - this.setRequestHandler( - 'tools/list', - (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools) - .filter(([, tool]) => tool.enabled) - .map(([name, tool]): Tool => { - const def: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema - ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA, - annotations: tool.annotations, - execution: tool.execution, - _meta: tool._meta - }; - if (tool.outputSchema) { - def.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; - } - return def; - }) - }) - ); - - this.setRequestHandler('tools/call', async (request, ctx): Promise => { - const tool = this._registeredTools[request.params.name]; - if (!tool) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); - } - if (!tool.enabled) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); - } - try { - const isTaskRequest = !!request.params.task; - const taskSupport = tool.execution?.taskSupport; - const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); - if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) { - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask` - ); - } - if (taskSupport === 'required' && !isTaskRequest) { - throw new ProtocolError( - ProtocolErrorCode.MethodNotFound, - `Tool ${request.params.name} requires task augmentation (taskSupport: 'required')` - ); - } - if (taskSupport === 'optional' && !isTaskRequest && isTaskHandler) { - return await this.handleAutomaticTaskPolling(tool, request, ctx); - } - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const result = await this.executeToolHandler(tool, args, ctx); - if (isTaskRequest) return result; - await this.validateToolOutput(tool, result, request.params.name); - return result; - } catch (error) { - if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { - throw error; - } - return this.createToolError(error instanceof Error ? error.message : String(error)); - } - }); - - this._toolHandlersInitialized = true; - } - - private createToolError(errorMessage: string): CallToolResult { - return { content: [{ type: 'text', text: errorMessage }], isError: true }; - } - - private async validateToolInput< - ToolType extends RegisteredTool, - Args extends ToolType['inputSchema'] extends infer InputSchema - ? InputSchema extends StandardSchemaWithJSON - ? StandardSchemaWithJSON.InferOutput - : undefined - : undefined - >(tool: ToolType, args: Args, toolName: string): Promise { - if (!tool.inputSchema) return undefined as Args; - const parsed = await validateStandardSchema(tool.inputSchema, args ?? {}); - if (!parsed.success) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Input validation error: Invalid arguments for tool ${toolName}: ${parsed.error}` - ); - } - return parsed.data as unknown as Args; - } - - private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { - if (!tool.outputSchema) return; - if (!('content' in result)) return; - if (result.isError) return; - if (!result.structuredContent) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` - ); - } - const parsed = await validateStandardSchema(tool.outputSchema, result.structuredContent); - if (!parsed.success) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Output validation error: Invalid structured content for tool ${toolName}: ${parsed.error}` - ); - } - } - - private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { - return tool.executor(args, ctx); - } - - private async handleAutomaticTaskPolling( - tool: RegisteredTool, - request: RequestT, - ctx: ServerContext - ): Promise { - if (!ctx.task?.store) { - throw new Error('No task store provided for task-capable tool.'); - } - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const createTaskResult = (await tool.executor(args, ctx)) as CreateTaskResult; - const taskId = createTaskResult.task.taskId; - let task = createTaskResult.task; - const pollInterval = task.pollInterval ?? 5000; - while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') { - await new Promise(resolve => setTimeout(resolve, pollInterval)); - const updated = await ctx.task.store.getTask(taskId); - if (!updated) { - throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`); - } - task = updated; - } - return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult; - } - - private setCompletionRequestHandler(): void { - if (this._completionHandlerInitialized) return; - this.assertCanSetRequestHandler('completion/complete'); - this.registerCapabilities({ completions: {} }); - this.setRequestHandler('completion/complete', async (request): Promise => { - switch (request.params.ref.type) { - case 'ref/prompt': { - assertCompleteRequestPrompt(request); - return this.handlePromptCompletion(request, request.params.ref); - } - case 'ref/resource': { - assertCompleteRequestResourceTemplate(request); - return this.handleResourceCompletion(request, request.params.ref); - } - default: { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`); - } - } - }); - this._completionHandlerInitialized = true; - } - - private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise { - const prompt = this._registeredPrompts[ref.name]; - if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`); - if (!prompt.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); - if (!prompt.argsSchema) return EMPTY_COMPLETION_RESULT; - const promptShape = getSchemaShape(prompt.argsSchema); - const field = unwrapOptionalSchema(promptShape?.[request.params.argument.name]); - if (!isCompletable(field)) return EMPTY_COMPLETION_RESULT; - const completer = getCompleter(field); - if (!completer) return EMPTY_COMPLETION_RESULT; - const suggestions = await completer(request.params.argument.value, request.params.context); - return createCompletionResult(suggestions); - } - - private async handleResourceCompletion( - request: CompleteRequestResourceTemplate, - ref: ResourceTemplateReference - ): Promise { - const template = Object.values(this._registeredResourceTemplates).find(t => t.resourceTemplate.uriTemplate.toString() === ref.uri); - if (!template) { - if (this._registeredResources[ref.uri]) return EMPTY_COMPLETION_RESULT; - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`); - } - const completer = template.resourceTemplate.completeCallback(request.params.argument.name); - if (!completer) return EMPTY_COMPLETION_RESULT; - const suggestions = await completer(request.params.argument.value, request.params.context); - return createCompletionResult(suggestions); - } - - private setResourceRequestHandlers(): void { - if (this._resourceHandlersInitialized) return; - this.assertCanSetRequestHandler('resources/list'); - this.assertCanSetRequestHandler('resources/templates/list'); - this.assertCanSetRequestHandler('resources/read'); - this.registerCapabilities({ resources: { listChanged: this.getCapabilities().resources?.listChanged ?? true } }); - - this.setRequestHandler('resources/list', async (_request, ctx) => { - const resources = Object.entries(this._registeredResources) - .filter(([_, r]) => r.enabled) - .map(([uri, r]) => ({ uri, name: r.name, ...r.metadata })); - const templateResources: Resource[] = []; - for (const template of Object.values(this._registeredResourceTemplates)) { - if (!template.resourceTemplate.listCallback) continue; - const result = await template.resourceTemplate.listCallback(ctx); - for (const resource of result.resources) { - templateResources.push({ ...template.metadata, ...resource }); - } - } - return { resources: [...resources, ...templateResources] }; - }); - - this.setRequestHandler('resources/templates/list', async () => { - const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, t]) => ({ - name, - uriTemplate: t.resourceTemplate.uriTemplate.toString(), - ...t.metadata - })); - return { resourceTemplates }; - }); - - this.setRequestHandler('resources/read', async (request, ctx) => { - const uri = new URL(request.params.uri); - const resource = this._registeredResources[uri.toString()]; - if (resource) { - if (!resource.enabled) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); - } - return resource.readCallback(uri, ctx); - } - for (const template of Object.values(this._registeredResourceTemplates)) { - const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); - if (variables) return template.readCallback(uri, variables, ctx); - } - throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); - }); - - this._resourceHandlersInitialized = true; + /** + * Registers a tool with a config object and callback. + */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs | ZodRawShapeCompat; + outputSchema?: OutputArgs | ZodRawShapeCompat; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback + ): RegisteredTool { + return this._registries.registerTool(name, config, cb); } - private setPromptRequestHandlers(): void { - if (this._promptHandlersInitialized) return; - this.assertCanSetRequestHandler('prompts/list'); - this.assertCanSetRequestHandler('prompts/get'); - this.registerCapabilities({ prompts: { listChanged: this.getCapabilities().prompts?.listChanged ?? true } }); - - this.setRequestHandler( - 'prompts/list', - (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts) - .filter(([, p]) => p.enabled) - .map( - ([name, p]): Prompt => ({ - name, - title: p.title, - description: p.description, - arguments: p.argsSchema ? promptArgumentsFromStandardSchema(p.argsSchema) : undefined, - _meta: p._meta - }) - ) - }) - ); - - this.setRequestHandler('prompts/get', async (request, ctx): Promise => { - const prompt = this._registeredPrompts[request.params.name]; - if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); - if (!prompt.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); - return prompt.handler(request.params.arguments, ctx); - }); - - this._promptHandlersInitialized = true; + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { title?: string; description?: string; argsSchema?: Args | ZodRawShapeCompat; _meta?: Record }, + cb: PromptCallback + ): RegisteredPrompt { + return this._registries.registerPrompt(name, config, cb); } /** @@ -1142,157 +744,28 @@ export class McpServer extends Dispatcher { config: ResourceMetadata, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { - if (typeof uriOrTemplate === 'string') { - if (this._registeredResources[uriOrTemplate]) throw new Error(`Resource ${uriOrTemplate} is already registered`); - const r = this._createRegisteredResource( - name, - (config as BaseMetadata).title, - uriOrTemplate, - config, - readCallback as ReadResourceCallback - ); - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return r; - } else { - if (this._registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`); - const r = this._createRegisteredResourceTemplate( - name, - (config as BaseMetadata).title, - uriOrTemplate, - config, - readCallback as ReadResourceTemplateCallback - ); - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return r; - } + return this._registries.registerResource(name, uriOrTemplate as never, config, readCallback as never); } - private _createRegisteredResource( - name: string, - title: string | undefined, - uri: string, - metadata: ResourceMetadata | undefined, - readCallback: ReadResourceCallback - ): RegisteredResource { - const r: RegisteredResource = { - name, - title, - metadata, - readCallback, - enabled: true, - disable: () => r.update({ enabled: false }), - enable: () => r.update({ enabled: true }), - remove: () => r.update({ uri: null }), - update: updates => { - if (updates.uri !== undefined && updates.uri !== uri) { - delete this._registeredResources[uri]; - if (updates.uri) this._registeredResources[updates.uri] = r; - } - if (updates.name !== undefined) r.name = updates.name; - if (updates.title !== undefined) r.title = updates.title; - if (updates.metadata !== undefined) r.metadata = updates.metadata; - if (updates.callback !== undefined) r.readCallback = updates.callback; - if (updates.enabled !== undefined) r.enabled = updates.enabled; - this.sendResourceListChanged(); - } - }; - this._registeredResources[uri] = r; - return r; + /** @hidden v1 compat for `(mcpServer as any)._registeredTools` and `experimental.tasks`. */ + get _registeredTools(): { [name: string]: RegisteredTool } { + return this._registries.registeredTools; } - - private _createRegisteredResourceTemplate( - name: string, - title: string | undefined, - template: ResourceTemplate, - metadata: ResourceMetadata | undefined, - readCallback: ReadResourceTemplateCallback - ): RegisteredResourceTemplate { - const r: RegisteredResourceTemplate = { - resourceTemplate: template, - title, - metadata, - readCallback, - enabled: true, - disable: () => r.update({ enabled: false }), - enable: () => r.update({ enabled: true }), - remove: () => r.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - delete this._registeredResourceTemplates[name]; - if (updates.name) this._registeredResourceTemplates[updates.name] = r; - } - if (updates.title !== undefined) r.title = updates.title; - if (updates.template !== undefined) r.resourceTemplate = updates.template; - if (updates.metadata !== undefined) r.metadata = updates.metadata; - if (updates.callback !== undefined) r.readCallback = updates.callback; - if (updates.enabled !== undefined) r.enabled = updates.enabled; - this.sendResourceListChanged(); - } - }; - this._registeredResourceTemplates[name] = r; - const variableNames = template.uriTemplate.variableNames; - const hasCompleter = Array.isArray(variableNames) && variableNames.some(v => !!template.completeCallback(v)); - if (hasCompleter) this.setCompletionRequestHandler(); - return r; + /** @hidden v1 compat. */ + get _registeredResources(): { [uri: string]: RegisteredResource } { + return this._registries.registeredResources; } - - private _createRegisteredPrompt( - name: string, - title: string | undefined, - description: string | undefined, - argsSchema: StandardSchemaWithJSON | undefined, - callback: PromptCallback, - _meta: Record | undefined - ): RegisteredPrompt { - let currentArgsSchema = argsSchema; - let currentCallback = callback; - const r: RegisteredPrompt = { - title, - description, - argsSchema, - _meta, - handler: createPromptHandler(name, argsSchema, callback), - enabled: true, - disable: () => r.update({ enabled: false }), - enable: () => r.update({ enabled: true }), - remove: () => r.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - delete this._registeredPrompts[name]; - if (updates.name) this._registeredPrompts[updates.name] = r; - } - if (updates.title !== undefined) r.title = updates.title; - if (updates.description !== undefined) r.description = updates.description; - if (updates._meta !== undefined) r._meta = updates._meta; - let needsRegen = false; - if (updates.argsSchema !== undefined) { - r.argsSchema = updates.argsSchema; - currentArgsSchema = updates.argsSchema; - needsRegen = true; - } - if (updates.callback !== undefined) { - currentCallback = updates.callback as PromptCallback; - needsRegen = true; - } - if (needsRegen) r.handler = createPromptHandler(name, currentArgsSchema, currentCallback); - if (updates.enabled !== undefined) r.enabled = updates.enabled; - this.sendPromptListChanged(); - } - }; - this._registeredPrompts[name] = r; - if (argsSchema) { - const shape = getSchemaShape(argsSchema); - if (shape) { - const hasCompletable = Object.values(shape).some(f => isCompletable(unwrapOptionalSchema(f))); - if (hasCompletable) this.setCompletionRequestHandler(); - } - } - return r; + /** @hidden v1 compat. */ + get _registeredResourceTemplates(): { [name: string]: RegisteredResourceTemplate } { + return this._registries.registeredResourceTemplates; + } + /** @hidden v1 compat. */ + get _registeredPrompts(): { [name: string]: RegisteredPrompt } { + return this._registries.registeredPrompts; } - private _createRegisteredTool( + /** @hidden v1 compat for `experimental.tasks.registerToolTask` which calls this directly. */ + _createRegisteredTool( name: string, title: string | undefined, description: string | undefined, @@ -1303,9 +776,8 @@ export class McpServer extends Dispatcher { _meta: Record | undefined, handler: AnyToolHandler ): RegisteredTool { - validateAndWarnToolName(name); - let currentHandler = handler; - const r: RegisteredTool = { + return this._registries.createRegisteredTool( + name, title, description, inputSchema, @@ -1313,97 +785,10 @@ export class McpServer extends Dispatcher { annotations, execution, _meta, - handler, - executor: createToolExecutor(inputSchema, handler), - enabled: true, - disable: () => r.update({ enabled: false }), - enable: () => r.update({ enabled: true }), - remove: () => r.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - if (typeof updates.name === 'string') validateAndWarnToolName(updates.name); - delete this._registeredTools[name]; - if (updates.name) this._registeredTools[updates.name] = r; - } - if (updates.title !== undefined) r.title = updates.title; - if (updates.description !== undefined) r.description = updates.description; - let needsRegen = false; - if (updates.paramsSchema !== undefined) { - r.inputSchema = updates.paramsSchema; - needsRegen = true; - } - if (updates.callback !== undefined) { - r.handler = updates.callback; - currentHandler = updates.callback as AnyToolHandler; - needsRegen = true; - } - if (needsRegen) r.executor = createToolExecutor(r.inputSchema, currentHandler); - if (updates.outputSchema !== undefined) r.outputSchema = updates.outputSchema; - if (updates.annotations !== undefined) r.annotations = updates.annotations; - if (updates._meta !== undefined) r._meta = updates._meta; - if (updates.enabled !== undefined) r.enabled = updates.enabled; - this.sendToolListChanged(); - } - }; - this._registeredTools[name] = r; - this.setToolRequestHandlers(); - this.sendToolListChanged(); - return r; - } - - /** - * Registers a tool with a config object and callback. - */ - registerTool( - name: string, - config: { - title?: string; - description?: string; - inputSchema?: InputArgs | ZodRawShapeCompat; - outputSchema?: OutputArgs | ZodRawShapeCompat; - annotations?: ToolAnnotations; - _meta?: Record; - }, - cb: ToolCallback - ): RegisteredTool { - if (this._registeredTools[name]) throw new Error(`Tool ${name} is already registered`); - const { title, description, inputSchema, outputSchema, annotations, _meta } = config; - return this._createRegisteredTool( - name, - title, - description, - coerceSchema(inputSchema), - coerceSchema(outputSchema), - annotations, - { taskSupport: 'forbidden' }, - _meta, - cb as ToolCallback + handler ); } - /** - * Registers a prompt with a config object and callback. - */ - registerPrompt( - name: string, - config: { title?: string; description?: string; argsSchema?: Args | ZodRawShapeCompat; _meta?: Record }, - cb: PromptCallback - ): RegisteredPrompt { - if (this._registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); - const { title, description, argsSchema, _meta } = config; - const r = this._createRegisteredPrompt( - name, - title, - description, - coerceSchema(argsSchema), - cb as PromptCallback, - _meta - ); - this.setPromptRequestHandlers(); - this.sendPromptListChanged(); - return r; - } - // ─────────────────────────────────────────────────────────────────────── // Deprecated v1 overloads (positional, raw-shape) — call register* internally // ─────────────────────────────────────────────────────────────────────── @@ -1441,27 +826,9 @@ export class McpServer extends Dispatcher { cb: LegacyToolCallback ): RegisteredTool; tool(name: string, ...rest: unknown[]): RegisteredTool { - if (this._registeredTools[name]) throw new Error(`Tool ${name} is already registered`); - let description: string | undefined; - let inputSchema: StandardSchemaWithJSON | undefined; - let annotations: ToolAnnotations | undefined; - if (typeof rest[0] === 'string') description = rest.shift() as string; - if (rest.length > 1) { - const first = rest[0]; - if (isZodRawShapeCompat(first) || isStandardSchema(first)) { - inputSchema = coerceSchema(rest.shift()); - if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { - annotations = rest.shift() as ToolAnnotations; - } - } else if (typeof first === 'object' && first !== null) { - if (Object.values(first).some(v => typeof v === 'object' && v !== null)) { - throw new Error(`Tool ${name} expected a Zod schema or ToolAnnotations, but received an unrecognized object`); - } - annotations = rest.shift() as ToolAnnotations; - } - } - const cb = rest[0] as ToolCallback; - return this._createRegisteredTool( + if (this._registries.registeredTools[name]) throw new Error(`Tool ${name} is already registered`); + const { description, inputSchema, annotations, cb } = parseLegacyToolArgs(name, rest); + return this._registries.createRegisteredTool( name, undefined, description, @@ -1470,7 +837,7 @@ export class McpServer extends Dispatcher { annotations, { taskSupport: 'forbidden' }, undefined, - cb + cb as ToolCallback ); } @@ -1488,14 +855,17 @@ export class McpServer extends Dispatcher { cb: LegacyPromptCallback ): RegisteredPrompt; prompt(name: string, ...rest: unknown[]): RegisteredPrompt { - if (this._registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); - let description: string | undefined; - if (typeof rest[0] === 'string') description = rest.shift() as string; - let argsSchema: StandardSchemaWithJSON | undefined; - if (rest.length > 1) argsSchema = coerceSchema(rest.shift()); - const cb = rest[0] as PromptCallback; - const r = this._createRegisteredPrompt(name, undefined, description, argsSchema, cb, undefined); - this.setPromptRequestHandlers(); + if (this._registries.registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); + const { description, argsSchema, cb } = parseLegacyPromptArgs(rest); + const r = this._registries.createRegisteredPrompt( + name, + undefined, + description, + argsSchema, + cb as PromptCallback, + undefined + ); + this._registries.installPromptHandlers(); this.sendPromptListChanged(); return r; } @@ -1518,324 +888,52 @@ export class McpServer extends Dispatcher { if (typeof rest[0] === 'object') metadata = rest.shift() as ResourceMetadata; const readCallback = rest[0] as ReadResourceCallback | ReadResourceTemplateCallback; if (typeof uriOrTemplate === 'string') { - if (this._registeredResources[uriOrTemplate]) throw new Error(`Resource ${uriOrTemplate} is already registered`); - const r = this._createRegisteredResource(name, undefined, uriOrTemplate, metadata, readCallback as ReadResourceCallback); - this.setResourceRequestHandlers(); + if (this._registries.registeredResources[uriOrTemplate]) throw new Error(`Resource ${uriOrTemplate} is already registered`); + const r = this._registries.createRegisteredResource( + name, + undefined, + uriOrTemplate, + metadata, + readCallback as ReadResourceCallback + ); + this._registries.installResourceHandlers(); this.sendResourceListChanged(); return r; } - if (this._registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`); - const r = this._createRegisteredResourceTemplate( + if (this._registries.registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`); + const r = this._registries.createRegisteredResourceTemplate( name, undefined, uriOrTemplate, metadata, readCallback as ReadResourceTemplateCallback ); - this.setResourceRequestHandlers(); + this._registries.installResourceHandlers(); this.sendResourceListChanged(); return r; } } -// ─────────────────────────────────────────────────────────────────────────── -// ResourceTemplate -// ─────────────────────────────────────────────────────────────────────────── - -/** - * A callback to complete one variable within a resource template's URI template. - */ -export type CompleteResourceTemplateCallback = ( - value: string, - context?: { arguments?: Record } -) => string[] | Promise; - -/** - * A resource template combines a URI pattern with optional functionality to enumerate - * all resources matching that pattern. - */ -export class ResourceTemplate { - private _uriTemplate: UriTemplate; - - constructor( - uriTemplate: string | UriTemplate, - private _callbacks: { - list: ListResourcesCallback | undefined; - complete?: { [variable: string]: CompleteResourceTemplateCallback }; - } - ) { - this._uriTemplate = typeof uriTemplate === 'string' ? new UriTemplate(uriTemplate) : uriTemplate; - } - - get uriTemplate(): UriTemplate { - return this._uriTemplate; - } - - get listCallback(): ListResourcesCallback | undefined { - return this._callbacks.list; - } - - completeCallback(variable: string): CompleteResourceTemplateCallback | undefined { - return this._callbacks.complete?.[variable]; - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// Public types -// ─────────────────────────────────────────────────────────────────────────── - -export type BaseToolCallback< - SendResultT extends Result, - Ctx extends ServerContext, - Args extends StandardSchemaWithJSON | undefined -> = Args extends StandardSchemaWithJSON - ? (args: StandardSchemaWithJSON.InferOutput, ctx: Ctx) => SendResultT | Promise - : (ctx: Ctx) => SendResultT | Promise; - -export type ToolCallback = BaseToolCallback< - CallToolResult, - ServerContext, - Args ->; - -export type AnyToolHandler = ToolCallback | ToolTaskHandler; - -type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; - -export type RegisteredTool = { - title?: string; - description?: string; - inputSchema?: StandardSchemaWithJSON; - outputSchema?: StandardSchemaWithJSON; - annotations?: ToolAnnotations; - execution?: ToolExecution; - _meta?: Record; - handler: AnyToolHandler; - /** @hidden */ - executor: ToolExecutor; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - paramsSchema?: StandardSchemaWithJSON; - outputSchema?: StandardSchemaWithJSON; - annotations?: ToolAnnotations; - _meta?: Record; - callback?: ToolCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -export type ResourceMetadata = Omit; -export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; -export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; - -export type RegisteredResource = { - name: string; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string; - title?: string; - uri?: string | null; - metadata?: ResourceMetadata; - callback?: ReadResourceCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -export type ReadResourceTemplateCallback = ( - uri: URL, - variables: Variables, - ctx: ServerContext -) => ReadResourceResult | Promise; - -export type RegisteredResourceTemplate = { - resourceTemplate: ResourceTemplate; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceTemplateCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - template?: ResourceTemplate; - metadata?: ResourceMetadata; - callback?: ReadResourceTemplateCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -export type PromptCallback = Args extends StandardSchemaWithJSON - ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; - -type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; -type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; -type TaskHandlerInternal = { - createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; -}; - -export type RegisteredPrompt = { - title?: string; - description?: string; - argsSchema?: StandardSchemaWithJSON; - _meta?: Record; - /** @hidden */ - handler: PromptHandler; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - argsSchema?: Args; - _meta?: Record; - callback?: PromptCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -// ─────────────────────────────────────────────────────────────────────────── -// Helpers -// ─────────────────────────────────────────────────────────────────────────── - -const EMPTY_OBJECT_JSON_SCHEMA = { type: 'object' as const, properties: {} }; -const EMPTY_COMPLETION_RESULT: CompleteResult = { completion: { values: [], hasMore: false } }; - function jsonResponse(status: number, body: unknown): Response { return Response.json(body, { status, headers: { 'content-type': 'application/json' } }); } -function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { - const values = suggestions.map(String).slice(0, 100); - return { completion: { values, total: suggestions.length, hasMore: suggestions.length > 100 } }; -} - -function createToolExecutor( - inputSchema: StandardSchemaWithJSON | undefined, - handler: AnyToolHandler -): ToolExecutor { - const isTaskHandler = 'createTask' in handler; - if (isTaskHandler) { - const th = handler as TaskHandlerInternal; - return async (args, ctx) => { - if (!ctx.task?.store) throw new Error('No task store provided.'); - const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; - if (inputSchema) return th.createTask(args, taskCtx); - return (th.createTask as (ctx: CreateTaskServerContext) => CreateTaskResult | Promise)(taskCtx); - }; - } - if (inputSchema) { - const cb = handler as ToolCallbackInternal; - return async (args, ctx) => cb(args, ctx); - } - const cb = handler as (ctx: ServerContext) => CallToolResult | Promise; - return async (_args, ctx) => cb(ctx); -} - -function createPromptHandler( - name: string, - argsSchema: StandardSchemaWithJSON | undefined, - callback: PromptCallback -): PromptHandler { - if (argsSchema) { - const typed = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; - return async (args, ctx) => { - const parsed = await validateStandardSchema(argsSchema, args); - if (!parsed.success) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parsed.error}`); - } - return typed(parsed.data, ctx); - }; - } - const typed = callback as (ctx: ServerContext) => GetPromptResult | Promise; - return async (_args, ctx) => typed(ctx); -} - -/** - * v1 compat: a "raw shape" is a plain object whose values are Zod schemas - * (e.g. `{ name: z.string() }`), or an empty object. v1's `tool()`/`prompt()` - * and `registerTool({inputSchema:{}})` accepted these directly. - */ -type ZodRawShapeCompat = Record; - -/** v1-style callback signature for the deprecated {@linkcode McpServer.tool | tool()} overloads. */ -type LegacyToolCallback = ( - args: z.infer>, - ctx: ServerContext -) => CallToolResult | Promise; - -/** v1-style callback signature for the deprecated {@linkcode McpServer.prompt | prompt()} overloads. */ -type LegacyPromptCallback = ( - args: z.infer>, - ctx: ServerContext -) => GetPromptResult | Promise; - -/** - * v1 compat: extract the literal method string from a `z.object({method: z.literal('x'), ...})` schema. - */ -function extractMethodFromSchema(schema: { shape: { method: unknown } }): string { - const lit = schema.shape.method as { value?: unknown; _zod?: { def?: { values?: unknown[] } } }; - const v = lit?.value ?? lit?._zod?.def?.values?.[0]; - if (typeof v !== 'string') { - throw new TypeError('setRequestHandler(schema, handler): schema.shape.method must be a z.literal(string)'); - } - return v; -} - -function isZodTypeLike(v: unknown): boolean { - return v != null && typeof v === 'object' && '_zod' in (v as object); -} - -function isZodRawShapeCompat(v: unknown): v is ZodRawShapeCompat { - if (v == null || typeof v !== 'object') return false; - if (isStandardSchema(v)) return false; - const values = Object.values(v as object); - if (values.length === 0) return true; - return values.some(v => isZodTypeLike(v)); -} - -/** - * Coerce a v1-style raw Zod shape (or empty object) to a {@linkcode StandardSchemaWithJSON}. - * Standard Schemas pass through unchanged. - */ -function coerceSchema(schema: unknown): StandardSchemaWithJSON | undefined { - if (schema == null) return undefined; - if (isStandardSchemaWithJSON(schema)) return schema; - if (isZodRawShapeCompat(schema)) return z.object(schema) as unknown as StandardSchemaWithJSON; - if (isStandardSchema(schema)) { - throw new Error('Schema lacks JSON-Schema emission (zod >=4.2 or equivalent required).'); - } - throw new Error('inputSchema/argsSchema must be a Standard Schema or a Zod raw shape (e.g. {name: z.string()})'); -} - -function getSchemaShape(schema: unknown): Record | undefined { - const c = schema as { shape?: unknown }; - if (c.shape && typeof c.shape === 'object') return c.shape as Record; - return undefined; -} - -function isOptionalSchema(schema: unknown): boolean { - return (schema as { type?: string } | null | undefined)?.type === 'optional'; -} +// ─────────────────────────────────────────────────────────────────────────── +// Re-exports for path compat. External code imports these from './mcpServer.js'. +// ─────────────────────────────────────────────────────────────────────────── -function unwrapOptionalSchema(schema: unknown): unknown { - if (!isOptionalSchema(schema)) return schema; - const c = schema as { def?: { innerType?: unknown } }; - return c.def?.innerType ?? schema; -} +export type { CompleteResourceTemplateCallback, ListResourcesCallback } from './resourceTemplate.js'; +export { ResourceTemplate } from './resourceTemplate.js'; +export type { + AnyToolHandler, + BaseToolCallback, + PromptCallback, + ReadResourceCallback, + ReadResourceTemplateCallback, + RegisteredPrompt, + RegisteredResource, + RegisteredResourceTemplate, + RegisteredTool, + ResourceMetadata, + ToolCallback +} from './serverRegistries.js'; diff --git a/packages/server/src/server/resourceTemplate.ts b/packages/server/src/server/resourceTemplate.ts new file mode 100644 index 000000000..ac994dfdc --- /dev/null +++ b/packages/server/src/server/resourceTemplate.ts @@ -0,0 +1,45 @@ +import type { ListResourcesResult, ServerContext } from '@modelcontextprotocol/core'; +import { UriTemplate } from '@modelcontextprotocol/core'; + +/** + * A callback to list all resources matching a template. + */ +export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; + +/** + * A callback to complete one variable within a resource template's URI template. + */ +export type CompleteResourceTemplateCallback = ( + value: string, + context?: { arguments?: Record } +) => string[] | Promise; + +/** + * A resource template combines a URI pattern with optional functionality to enumerate + * all resources matching that pattern. + */ +export class ResourceTemplate { + private _uriTemplate: UriTemplate; + + constructor( + uriTemplate: string | UriTemplate, + private _callbacks: { + list: ListResourcesCallback | undefined; + complete?: { [variable: string]: CompleteResourceTemplateCallback }; + } + ) { + this._uriTemplate = typeof uriTemplate === 'string' ? new UriTemplate(uriTemplate) : uriTemplate; + } + + get uriTemplate(): UriTemplate { + return this._uriTemplate; + } + + get listCallback(): ListResourcesCallback | undefined { + return this._callbacks.list; + } + + completeCallback(variable: string): CompleteResourceTemplateCallback | undefined { + return this._callbacks.complete?.[variable]; + } +} diff --git a/packages/server/src/server/serverCapabilities.ts b/packages/server/src/server/serverCapabilities.ts new file mode 100644 index 000000000..a82b10848 --- /dev/null +++ b/packages/server/src/server/serverCapabilities.ts @@ -0,0 +1,127 @@ +import type { ClientCapabilities, NotificationMethod, RequestMethod, ServerCapabilities } from '@modelcontextprotocol/core'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; + +/** + * Throws if the connected client does not advertise the capability required + * for the server to send the given outbound request. + */ +export function assertCapabilityForMethod(method: RequestMethod, clientCapabilities: ClientCapabilities | undefined): void { + switch (method) { + case 'sampling/createMessage': { + if (!clientCapabilities?.sampling) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support sampling (required for ${method})`); + } + break; + } + case 'elicitation/create': { + if (!clientCapabilities?.elicitation) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation (required for ${method})`); + } + break; + } + case 'roots/list': { + if (!clientCapabilities?.roots) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support listing roots (required for ${method})`); + } + break; + } + } +} + +/** + * Throws if either side lacks the capability required for the server to emit + * the given notification. + */ +export function assertNotificationCapability( + method: NotificationMethod, + serverCapabilities: ServerCapabilities, + clientCapabilities: ClientCapabilities | undefined +): void { + switch (method) { + case 'notifications/message': { + if (!serverCapabilities.logging) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); + } + break; + } + case 'notifications/resources/updated': + case 'notifications/resources/list_changed': { + if (!serverCapabilities.resources) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying about resources (required for ${method})` + ); + } + break; + } + case 'notifications/tools/list_changed': { + if (!serverCapabilities.tools) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying of tool list changes (required for ${method})` + ); + } + break; + } + case 'notifications/prompts/list_changed': { + if (!serverCapabilities.prompts) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying of prompt list changes (required for ${method})` + ); + } + break; + } + case 'notifications/elicitation/complete': { + if (!clientCapabilities?.elicitation?.url) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support URL elicitation (required for ${method})`); + } + break; + } + } +} + +/** + * Throws if the server does not advertise the capability required to register + * a handler for the given inbound request method. + */ +export function assertRequestHandlerCapability(method: string, serverCapabilities: ServerCapabilities): void { + switch (method) { + case 'completion/complete': { + if (!serverCapabilities.completions) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); + } + break; + } + case 'logging/setLevel': { + if (!serverCapabilities.logging) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); + } + break; + } + case 'prompts/get': + case 'prompts/list': { + if (!serverCapabilities.prompts) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); + } + break; + } + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': + case 'resources/subscribe': + case 'resources/unsubscribe': { + if (!serverCapabilities.resources) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); + } + break; + } + case 'tools/call': + case 'tools/list': { + if (!serverCapabilities.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); + } + break; + } + } +} diff --git a/packages/server/src/server/serverLegacy.ts b/packages/server/src/server/serverLegacy.ts new file mode 100644 index 000000000..7f1e71f57 --- /dev/null +++ b/packages/server/src/server/serverLegacy.ts @@ -0,0 +1,99 @@ +import type { CallToolResult, GetPromptResult, ServerContext, StandardSchemaWithJSON, ToolAnnotations } from '@modelcontextprotocol/core'; +import { isStandardSchema, isStandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { z } from 'zod/v4'; + +/** + * v1 compat: a "raw shape" is a plain object whose values are Zod schemas + * (e.g. `{ name: z.string() }`), or an empty object. v1's `tool()`/`prompt()` + * and `registerTool({inputSchema:{}})` accepted these directly. + */ +export type ZodRawShapeCompat = Record; + +/** v1-style callback signature for the deprecated {@linkcode McpServer.tool | tool()} overloads. */ +export type LegacyToolCallback = ( + args: z.infer>, + ctx: ServerContext +) => CallToolResult | Promise; + +/** v1-style callback signature for the deprecated {@linkcode McpServer.prompt | prompt()} overloads. */ +export type LegacyPromptCallback = ( + args: z.infer>, + ctx: ServerContext +) => GetPromptResult | Promise; + +/** + * v1 compat: extract the literal method string from a `z.object({method: z.literal('x'), ...})` schema. + */ +export function extractMethodFromSchema(schema: { shape: { method: unknown } }): string { + const lit = schema.shape.method as { value?: unknown; _zod?: { def?: { values?: unknown[] } } }; + const v = lit?.value ?? lit?._zod?.def?.values?.[0]; + if (typeof v !== 'string') { + throw new TypeError('setRequestHandler(schema, handler): schema.shape.method must be a z.literal(string)'); + } + return v; +} + +function isZodTypeLike(v: unknown): boolean { + return v != null && typeof v === 'object' && '_zod' in (v as object); +} + +export function isZodRawShapeCompat(v: unknown): v is ZodRawShapeCompat { + if (v == null || typeof v !== 'object') return false; + if (isStandardSchema(v)) return false; + const values = Object.values(v as object); + if (values.length === 0) return true; + return values.some(v => isZodTypeLike(v)); +} + +/** + * Coerce a v1-style raw Zod shape (or empty object) to a {@linkcode StandardSchemaWithJSON}. + * Standard Schemas pass through unchanged. + */ +export function coerceSchema(schema: unknown): StandardSchemaWithJSON | undefined { + if (schema == null) return undefined; + if (isStandardSchemaWithJSON(schema)) return schema; + if (isZodRawShapeCompat(schema)) return z.object(schema) as unknown as StandardSchemaWithJSON; + if (isStandardSchema(schema)) { + throw new Error('Schema lacks JSON-Schema emission (zod >=4.2 or equivalent required).'); + } + throw new Error('inputSchema/argsSchema must be a Standard Schema or a Zod raw shape (e.g. {name: z.string()})'); +} + +/** + * Parse the variadic argument list of the deprecated {@linkcode McpServer.tool | tool()} overloads. + */ +export function parseLegacyToolArgs( + name: string, + rest: unknown[] +): { description?: string; inputSchema?: StandardSchemaWithJSON; annotations?: ToolAnnotations; cb: unknown } { + let description: string | undefined; + let inputSchema: StandardSchemaWithJSON | undefined; + let annotations: ToolAnnotations | undefined; + if (typeof rest[0] === 'string') description = rest.shift() as string; + if (rest.length > 1) { + const first = rest[0]; + if (isZodRawShapeCompat(first) || isStandardSchema(first)) { + inputSchema = coerceSchema(rest.shift()); + if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { + annotations = rest.shift() as ToolAnnotations; + } + } else if (typeof first === 'object' && first !== null) { + if (Object.values(first).some(v => typeof v === 'object' && v !== null)) { + throw new Error(`Tool ${name} expected a Zod schema or ToolAnnotations, but received an unrecognized object`); + } + annotations = rest.shift() as ToolAnnotations; + } + } + return { description, inputSchema, annotations, cb: rest[0] }; +} + +/** + * Parse the variadic argument list of the deprecated {@linkcode McpServer.prompt | prompt()} overloads. + */ +export function parseLegacyPromptArgs(rest: unknown[]): { description?: string; argsSchema?: StandardSchemaWithJSON; cb: unknown } { + let description: string | undefined; + if (typeof rest[0] === 'string') description = rest.shift() as string; + let argsSchema: StandardSchemaWithJSON | undefined; + if (rest.length > 1) argsSchema = coerceSchema(rest.shift()); + return { description, argsSchema, cb: rest[0] }; +} diff --git a/packages/server/src/server/serverRegistries.ts b/packages/server/src/server/serverRegistries.ts new file mode 100644 index 000000000..e5c65e008 --- /dev/null +++ b/packages/server/src/server/serverRegistries.ts @@ -0,0 +1,873 @@ +import type { + BaseMetadata, + CallToolRequest, + CallToolResult, + CompleteRequestPrompt, + CompleteRequestResourceTemplate, + CompleteResult, + CreateTaskResult, + CreateTaskServerContext, + GetPromptResult, + ListPromptsResult, + ListToolsResult, + Prompt, + PromptReference, + ReadResourceResult, + RequestMethod, + RequestTypeMap, + Resource, + ResourceTemplateReference, + Result, + ResultTypeMap, + ServerCapabilities, + ServerContext, + StandardSchemaWithJSON, + Tool, + ToolAnnotations, + ToolExecution, + Variables +} from '@modelcontextprotocol/core'; +import { + assertCompleteRequestPrompt, + assertCompleteRequestResourceTemplate, + promptArgumentsFromStandardSchema, + ProtocolError, + ProtocolErrorCode, + standardSchemaToJsonSchema, + validateAndWarnToolName, + validateStandardSchema +} from '@modelcontextprotocol/core'; + +import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; +import { getCompleter, isCompletable } from './completable.js'; +import type { ResourceTemplate } from './resourceTemplate.js'; +import type { ZodRawShapeCompat } from './serverLegacy.js'; +import { coerceSchema } from './serverLegacy.js'; + +/** + * Minimal surface a {@linkcode ServerRegistries} instance needs from its owning server. + * {@linkcode McpServer} satisfies this directly. + */ +export interface RegistriesHost { + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise + ): void; + assertCanSetRequestHandler(method: string): void; + registerCapabilities(capabilities: ServerCapabilities): void; + getCapabilities(): ServerCapabilities; + sendToolListChanged(): Promise; + sendResourceListChanged(): Promise; + sendPromptListChanged(): Promise; +} + +/** + * In-memory tool/resource/prompt registries plus the lazy `tools/*`, `resources/*`, + * `prompts/*`, and `completion/*` request-handler installers. + * + * Composed by {@linkcode McpServer}. One instance per server. + */ +export class ServerRegistries { + readonly registeredResources: { [uri: string]: RegisteredResource } = {}; + readonly registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate } = {}; + readonly registeredTools: { [name: string]: RegisteredTool } = {}; + readonly registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + + private _toolHandlersInitialized = false; + private _completionHandlerInitialized = false; + private _resourceHandlersInitialized = false; + private _promptHandlersInitialized = false; + + constructor(private readonly host: RegistriesHost) {} + + // ─────────────────────────────────────────────────────────────────────── + // Tools + // ─────────────────────────────────────────────────────────────────────── + + private setToolRequestHandlers(): void { + if (this._toolHandlersInitialized) return; + const h = this.host; + h.assertCanSetRequestHandler('tools/list'); + h.assertCanSetRequestHandler('tools/call'); + h.registerCapabilities({ tools: { listChanged: h.getCapabilities().tools?.listChanged ?? true } }); + + h.setRequestHandler( + 'tools/list', + (): ListToolsResult => ({ + tools: Object.entries(this.registeredTools) + .filter(([, tool]) => tool.enabled) + .map(([name, tool]): Tool => { + const def: Tool = { + name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema + ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, + execution: tool.execution, + _meta: tool._meta + }; + if (tool.outputSchema) { + def.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + } + return def; + }) + }) + ); + + h.setRequestHandler('tools/call', async (request, ctx): Promise => { + const tool = this.registeredTools[request.params.name]; + if (!tool) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); + } + if (!tool.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); + } + try { + const isTaskRequest = !!request.params.task; + const taskSupport = tool.execution?.taskSupport; + const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); + if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask` + ); + } + if (taskSupport === 'required' && !isTaskRequest) { + throw new ProtocolError( + ProtocolErrorCode.MethodNotFound, + `Tool ${request.params.name} requires task augmentation (taskSupport: 'required')` + ); + } + if (taskSupport === 'optional' && !isTaskRequest && isTaskHandler) { + return await this.handleAutomaticTaskPolling(tool, request, ctx); + } + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const result = await tool.executor(args, ctx); + if (isTaskRequest) return result; + await this.validateToolOutput(tool, result, request.params.name); + return result; + } catch (error) { + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { + throw error; + } + return createToolError(error instanceof Error ? error.message : String(error)); + } + }); + + this._toolHandlersInitialized = true; + } + + private async validateToolInput< + ToolType extends RegisteredTool, + Args extends ToolType['inputSchema'] extends infer InputSchema + ? InputSchema extends StandardSchemaWithJSON + ? StandardSchemaWithJSON.InferOutput + : undefined + : undefined + >(tool: ToolType, args: Args, toolName: string): Promise { + if (!tool.inputSchema) return undefined as Args; + const parsed = await validateStandardSchema(tool.inputSchema, args ?? {}); + if (!parsed.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Input validation error: Invalid arguments for tool ${toolName}: ${parsed.error}` + ); + } + return parsed.data as unknown as Args; + } + + private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { + if (!tool.outputSchema) return; + if (!('content' in result)) return; + if (result.isError) return; + if (!result.structuredContent) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` + ); + } + const parsed = await validateStandardSchema(tool.outputSchema, result.structuredContent); + if (!parsed.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Output validation error: Invalid structured content for tool ${toolName}: ${parsed.error}` + ); + } + } + + private async handleAutomaticTaskPolling( + tool: RegisteredTool, + request: RequestT, + ctx: ServerContext + ): Promise { + if (!ctx.task?.store) { + throw new Error('No task store provided for task-capable tool.'); + } + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const createTaskResult = (await tool.executor(args, ctx)) as CreateTaskResult; + const taskId = createTaskResult.task.taskId; + let task = createTaskResult.task; + const pollInterval = task.pollInterval ?? 5000; + while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + const updated = await ctx.task.store.getTask(taskId); + if (!updated) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`); + } + task = updated; + } + return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult; + } + + // ─────────────────────────────────────────────────────────────────────── + // Completion + // ─────────────────────────────────────────────────────────────────────── + + private setCompletionRequestHandler(): void { + if (this._completionHandlerInitialized) return; + const h = this.host; + h.assertCanSetRequestHandler('completion/complete'); + h.registerCapabilities({ completions: {} }); + h.setRequestHandler('completion/complete', async (request): Promise => { + switch (request.params.ref.type) { + case 'ref/prompt': { + assertCompleteRequestPrompt(request); + return this.handlePromptCompletion(request, request.params.ref); + } + case 'ref/resource': { + assertCompleteRequestResourceTemplate(request); + return this.handleResourceCompletion(request, request.params.ref); + } + default: { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`); + } + } + }); + this._completionHandlerInitialized = true; + } + + private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise { + const prompt = this.registeredPrompts[ref.name]; + if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`); + if (!prompt.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); + if (!prompt.argsSchema) return EMPTY_COMPLETION_RESULT; + const promptShape = getSchemaShape(prompt.argsSchema); + const field = unwrapOptionalSchema(promptShape?.[request.params.argument.name]); + if (!isCompletable(field)) return EMPTY_COMPLETION_RESULT; + const completer = getCompleter(field); + if (!completer) return EMPTY_COMPLETION_RESULT; + const suggestions = await completer(request.params.argument.value, request.params.context); + return createCompletionResult(suggestions); + } + + private async handleResourceCompletion( + request: CompleteRequestResourceTemplate, + ref: ResourceTemplateReference + ): Promise { + const template = Object.values(this.registeredResourceTemplates).find(t => t.resourceTemplate.uriTemplate.toString() === ref.uri); + if (!template) { + if (this.registeredResources[ref.uri]) return EMPTY_COMPLETION_RESULT; + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`); + } + const completer = template.resourceTemplate.completeCallback(request.params.argument.name); + if (!completer) return EMPTY_COMPLETION_RESULT; + const suggestions = await completer(request.params.argument.value, request.params.context); + return createCompletionResult(suggestions); + } + + // ─────────────────────────────────────────────────────────────────────── + // Resources + // ─────────────────────────────────────────────────────────────────────── + + private setResourceRequestHandlers(): void { + if (this._resourceHandlersInitialized) return; + const h = this.host; + h.assertCanSetRequestHandler('resources/list'); + h.assertCanSetRequestHandler('resources/templates/list'); + h.assertCanSetRequestHandler('resources/read'); + h.registerCapabilities({ resources: { listChanged: h.getCapabilities().resources?.listChanged ?? true } }); + + h.setRequestHandler('resources/list', async (_request, ctx) => { + const resources = Object.entries(this.registeredResources) + .filter(([_, r]) => r.enabled) + .map(([uri, r]) => ({ uri, name: r.name, ...r.metadata })); + const templateResources: Resource[] = []; + for (const template of Object.values(this.registeredResourceTemplates)) { + if (!template.resourceTemplate.listCallback) continue; + const result = await template.resourceTemplate.listCallback(ctx); + for (const resource of result.resources) { + templateResources.push({ ...template.metadata, ...resource }); + } + } + return { resources: [...resources, ...templateResources] }; + }); + + h.setRequestHandler('resources/templates/list', async () => { + const resourceTemplates = Object.entries(this.registeredResourceTemplates).map(([name, t]) => ({ + name, + uriTemplate: t.resourceTemplate.uriTemplate.toString(), + ...t.metadata + })); + return { resourceTemplates }; + }); + + h.setRequestHandler('resources/read', async (request, ctx) => { + const uri = new URL(request.params.uri); + const resource = this.registeredResources[uri.toString()]; + if (resource) { + if (!resource.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); + } + return resource.readCallback(uri, ctx); + } + for (const template of Object.values(this.registeredResourceTemplates)) { + const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); + if (variables) return template.readCallback(uri, variables, ctx); + } + throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); + }); + + this._resourceHandlersInitialized = true; + } + + // ─────────────────────────────────────────────────────────────────────── + // Prompts + // ─────────────────────────────────────────────────────────────────────── + + private setPromptRequestHandlers(): void { + if (this._promptHandlersInitialized) return; + const h = this.host; + h.assertCanSetRequestHandler('prompts/list'); + h.assertCanSetRequestHandler('prompts/get'); + h.registerCapabilities({ prompts: { listChanged: h.getCapabilities().prompts?.listChanged ?? true } }); + + h.setRequestHandler( + 'prompts/list', + (): ListPromptsResult => ({ + prompts: Object.entries(this.registeredPrompts) + .filter(([, p]) => p.enabled) + .map( + ([name, p]): Prompt => ({ + name, + title: p.title, + description: p.description, + arguments: p.argsSchema ? promptArgumentsFromStandardSchema(p.argsSchema) : undefined, + _meta: p._meta + }) + ) + }) + ); + + h.setRequestHandler('prompts/get', async (request, ctx): Promise => { + const prompt = this.registeredPrompts[request.params.name]; + if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); + if (!prompt.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); + return prompt.handler(request.params.arguments, ctx); + }); + + this._promptHandlersInitialized = true; + } + + // ─────────────────────────────────────────────────────────────────────── + // Public registration entry points + // ─────────────────────────────────────────────────────────────────────── + + /** + * Registers a resource with a config object and callback. + */ + registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; + registerResource( + name: string, + uriOrTemplate: string | ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceCallback | ReadResourceTemplateCallback + ): RegisteredResource | RegisteredResourceTemplate { + if (typeof uriOrTemplate === 'string') { + if (this.registeredResources[uriOrTemplate]) throw new Error(`Resource ${uriOrTemplate} is already registered`); + const r = this.createRegisteredResource( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); + this.setResourceRequestHandlers(); + this.host.sendResourceListChanged(); + return r; + } else { + if (this.registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`); + const r = this.createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); + this.setResourceRequestHandlers(); + this.host.sendResourceListChanged(); + return r; + } + } + + /** + * Registers a tool with a config object and callback. + */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs | ZodRawShapeCompat; + outputSchema?: OutputArgs | ZodRawShapeCompat; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback + ): RegisteredTool { + if (this.registeredTools[name]) throw new Error(`Tool ${name} is already registered`); + const { title, description, inputSchema, outputSchema, annotations, _meta } = config; + return this.createRegisteredTool( + name, + title, + description, + coerceSchema(inputSchema), + coerceSchema(outputSchema), + annotations, + { taskSupport: 'forbidden' }, + _meta, + cb as ToolCallback + ); + } + + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { title?: string; description?: string; argsSchema?: Args | ZodRawShapeCompat; _meta?: Record }, + cb: PromptCallback + ): RegisteredPrompt { + if (this.registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`); + const { title, description, argsSchema, _meta } = config; + const r = this.createRegisteredPrompt( + name, + title, + description, + coerceSchema(argsSchema), + cb as PromptCallback, + _meta + ); + this.setPromptRequestHandlers(); + this.host.sendPromptListChanged(); + return r; + } + + // ─────────────────────────────────────────────────────────────────────── + // Registered* factories. Exposed so legacy `.tool()`/`.prompt()`/`.resource()` + // and `experimental.tasks.registerToolTask` can build entries directly. + // ─────────────────────────────────────────────────────────────────────── + + createRegisteredResource( + name: string, + title: string | undefined, + uri: string, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceCallback + ): RegisteredResource { + const r: RegisteredResource = { + name, + title, + metadata, + readCallback, + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ uri: null }), + update: updates => { + if (updates.uri !== undefined && updates.uri !== uri) { + delete this.registeredResources[uri]; + if (updates.uri) this.registeredResources[updates.uri] = r; + } + if (updates.name !== undefined) r.name = updates.name; + if (updates.title !== undefined) r.title = updates.title; + if (updates.metadata !== undefined) r.metadata = updates.metadata; + if (updates.callback !== undefined) r.readCallback = updates.callback; + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.host.sendResourceListChanged(); + } + }; + this.registeredResources[uri] = r; + return r; + } + + createRegisteredResourceTemplate( + name: string, + title: string | undefined, + template: ResourceTemplate, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate { + const r: RegisteredResourceTemplate = { + resourceTemplate: template, + title, + metadata, + readCallback, + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + delete this.registeredResourceTemplates[name]; + if (updates.name) this.registeredResourceTemplates[updates.name] = r; + } + if (updates.title !== undefined) r.title = updates.title; + if (updates.template !== undefined) r.resourceTemplate = updates.template; + if (updates.metadata !== undefined) r.metadata = updates.metadata; + if (updates.callback !== undefined) r.readCallback = updates.callback; + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.host.sendResourceListChanged(); + } + }; + this.registeredResourceTemplates[name] = r; + const variableNames = template.uriTemplate.variableNames; + const hasCompleter = Array.isArray(variableNames) && variableNames.some(v => !!template.completeCallback(v)); + if (hasCompleter) this.setCompletionRequestHandler(); + return r; + } + + createRegisteredPrompt( + name: string, + title: string | undefined, + description: string | undefined, + argsSchema: StandardSchemaWithJSON | undefined, + callback: PromptCallback, + _meta: Record | undefined + ): RegisteredPrompt { + let currentArgsSchema = argsSchema; + let currentCallback = callback; + const r: RegisteredPrompt = { + title, + description, + argsSchema, + _meta, + handler: createPromptHandler(name, argsSchema, callback), + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + delete this.registeredPrompts[name]; + if (updates.name) this.registeredPrompts[updates.name] = r; + } + if (updates.title !== undefined) r.title = updates.title; + if (updates.description !== undefined) r.description = updates.description; + if (updates._meta !== undefined) r._meta = updates._meta; + let needsRegen = false; + if (updates.argsSchema !== undefined) { + r.argsSchema = updates.argsSchema; + currentArgsSchema = updates.argsSchema; + needsRegen = true; + } + if (updates.callback !== undefined) { + currentCallback = updates.callback as PromptCallback; + needsRegen = true; + } + if (needsRegen) r.handler = createPromptHandler(name, currentArgsSchema, currentCallback); + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.host.sendPromptListChanged(); + } + }; + this.registeredPrompts[name] = r; + if (argsSchema) { + const shape = getSchemaShape(argsSchema); + if (shape) { + const hasCompletable = Object.values(shape).some(f => isCompletable(unwrapOptionalSchema(f))); + if (hasCompletable) this.setCompletionRequestHandler(); + } + } + return r; + } + + createRegisteredTool( + name: string, + title: string | undefined, + description: string | undefined, + inputSchema: StandardSchemaWithJSON | undefined, + outputSchema: StandardSchemaWithJSON | undefined, + annotations: ToolAnnotations | undefined, + execution: ToolExecution | undefined, + _meta: Record | undefined, + handler: AnyToolHandler + ): RegisteredTool { + validateAndWarnToolName(name); + let currentHandler = handler; + const r: RegisteredTool = { + title, + description, + inputSchema, + outputSchema, + annotations, + execution, + _meta, + handler, + executor: createToolExecutor(inputSchema, handler), + enabled: true, + disable: () => r.update({ enabled: false }), + enable: () => r.update({ enabled: true }), + remove: () => r.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + if (typeof updates.name === 'string') validateAndWarnToolName(updates.name); + delete this.registeredTools[name]; + if (updates.name) this.registeredTools[updates.name] = r; + } + if (updates.title !== undefined) r.title = updates.title; + if (updates.description !== undefined) r.description = updates.description; + let needsRegen = false; + if (updates.paramsSchema !== undefined) { + r.inputSchema = updates.paramsSchema; + needsRegen = true; + } + if (updates.callback !== undefined) { + r.handler = updates.callback; + currentHandler = updates.callback as AnyToolHandler; + needsRegen = true; + } + if (needsRegen) r.executor = createToolExecutor(r.inputSchema, currentHandler); + if (updates.outputSchema !== undefined) r.outputSchema = updates.outputSchema; + if (updates.annotations !== undefined) r.annotations = updates.annotations; + if (updates._meta !== undefined) r._meta = updates._meta; + if (updates.enabled !== undefined) r.enabled = updates.enabled; + this.host.sendToolListChanged(); + } + }; + this.registeredTools[name] = r; + this.setToolRequestHandlers(); + this.host.sendToolListChanged(); + return r; + } + + /** Expose lazy installers for callers (legacy `.prompt()/.resource()`) that build entries via `create*` directly. */ + installResourceHandlers(): void { + this.setResourceRequestHandlers(); + } + installPromptHandlers(): void { + this.setPromptRequestHandlers(); + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Public types +// ─────────────────────────────────────────────────────────────────────────── + +export type BaseToolCallback< + SendResultT extends Result, + Ctx extends ServerContext, + Args extends StandardSchemaWithJSON | undefined +> = Args extends StandardSchemaWithJSON + ? (args: StandardSchemaWithJSON.InferOutput, ctx: Ctx) => SendResultT | Promise + : (ctx: Ctx) => SendResultT | Promise; + +export type ToolCallback = BaseToolCallback< + CallToolResult, + ServerContext, + Args +>; + +export type AnyToolHandler = ToolCallback | ToolTaskHandler; + +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; + +export type RegisteredTool = { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + execution?: ToolExecution; + _meta?: Record; + handler: AnyToolHandler; + /** @hidden */ + executor: ToolExecutor; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + _meta?: Record; + callback?: ToolCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +export type ResourceMetadata = Omit; +export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; + +export type RegisteredResource = { + name: string; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string; + title?: string; + uri?: string | null; + metadata?: ResourceMetadata; + callback?: ReadResourceCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +export type ReadResourceTemplateCallback = ( + uri: URL, + variables: Variables, + ctx: ServerContext +) => ReadResourceResult | Promise; + +export type RegisteredResourceTemplate = { + resourceTemplate: ResourceTemplate; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceTemplateCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + template?: ResourceTemplate; + metadata?: ResourceMetadata; + callback?: ReadResourceTemplateCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +export type PromptCallback = Args extends StandardSchemaWithJSON + ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise + : (ctx: ServerContext) => GetPromptResult | Promise; + +type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; +type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; +type TaskHandlerInternal = { + createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; +}; + +export type RegisteredPrompt = { + title?: string; + description?: string; + argsSchema?: StandardSchemaWithJSON; + _meta?: Record; + /** @hidden */ + handler: PromptHandler; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + callback?: PromptCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +// Re-export for path compat. + +// ─────────────────────────────────────────────────────────────────────────── +// Helpers +// ─────────────────────────────────────────────────────────────────────────── + +const EMPTY_OBJECT_JSON_SCHEMA = { type: 'object' as const, properties: {} }; +const EMPTY_COMPLETION_RESULT: CompleteResult = { completion: { values: [], hasMore: false } }; + +function createToolError(errorMessage: string): CallToolResult { + return { content: [{ type: 'text', text: errorMessage }], isError: true }; +} + +function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { + const values = suggestions.map(String).slice(0, 100); + return { completion: { values, total: suggestions.length, hasMore: suggestions.length > 100 } }; +} + +function createToolExecutor( + inputSchema: StandardSchemaWithJSON | undefined, + handler: AnyToolHandler +): ToolExecutor { + const isTaskHandler = 'createTask' in handler; + if (isTaskHandler) { + const th = handler as TaskHandlerInternal; + return async (args, ctx) => { + if (!ctx.task?.store) throw new Error('No task store provided.'); + const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; + if (inputSchema) return th.createTask(args, taskCtx); + return (th.createTask as (ctx: CreateTaskServerContext) => CreateTaskResult | Promise)(taskCtx); + }; + } + if (inputSchema) { + const cb = handler as ToolCallbackInternal; + return async (args, ctx) => cb(args, ctx); + } + const cb = handler as (ctx: ServerContext) => CallToolResult | Promise; + return async (_args, ctx) => cb(ctx); +} + +function createPromptHandler( + name: string, + argsSchema: StandardSchemaWithJSON | undefined, + callback: PromptCallback +): PromptHandler { + if (argsSchema) { + const typed = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; + return async (args, ctx) => { + const parsed = await validateStandardSchema(argsSchema, args); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parsed.error}`); + } + return typed(parsed.data, ctx); + }; + } + const typed = callback as (ctx: ServerContext) => GetPromptResult | Promise; + return async (_args, ctx) => typed(ctx); +} + +function getSchemaShape(schema: unknown): Record | undefined { + const c = schema as { shape?: unknown }; + if (c.shape && typeof c.shape === 'object') return c.shape as Record; + return undefined; +} + +function isOptionalSchema(schema: unknown): boolean { + return (schema as { type?: string } | null | undefined)?.type === 'optional'; +} + +function unwrapOptionalSchema(schema: unknown): unknown { + if (!isOptionalSchema(schema)) return schema; + const c = schema as { def?: { innerType?: unknown } }; + return c.def?.innerType ?? schema; +} + +export { type ListResourcesCallback } from './resourceTemplate.js'; From 92c64a9d40fa23fd577ebcf9db3d0b46e67778d2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 15:24:15 +0000 Subject: [PATCH 13/55] =?UTF-8?q?refactor(client):=20unify=20Client=20?= =?UTF-8?q?=E2=80=94=20clientV2=20becomes=20the=20only=20Client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced client.ts with clientV2's Dispatcher-based implementation - Ported: applyElicitationDefaults + setRequestHandler validation, listChanged debounce, getSupportedElicitationModes, ClientTasksCapabilityWithRuntime, ClientOptions extends ProtocolOptions - pipeAsClientTransport accepts StreamDriverOptions (tasks config flows through) - Deleted clientV2.ts, renamed test file - Fixed onclose double-fire, task option threading, cancel SdkError type All 1537 tests + lint + typecheck green. --- packages/client/src/client/client.ts | 1434 ++++++++--------- packages/client/src/client/clientTransport.ts | 32 +- packages/client/src/client/clientV2.ts | 597 ------- .../{clientV2.test.ts => client.test.ts} | 11 +- 4 files changed, 674 insertions(+), 1400 deletions(-) delete mode 100644 packages/client/src/client/clientV2.ts rename packages/client/test/client/{clientV2.test.ts => client.test.ts} (97%) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 21a43bd15..fe84650f7 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,15 +1,21 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; import type { - BaseContext, + AnySchema, CallToolRequest, + CallToolResult, + CancelTaskRequest, ClientCapabilities, ClientContext, ClientNotification, ClientRequest, ClientResult, CompleteRequest, + CreateTaskResult, GetPromptRequest, + GetTaskRequest, + GetTaskResult, Implementation, + JSONRPCRequest, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, @@ -18,76 +24,82 @@ import type { ListPromptsRequest, ListResourcesRequest, ListResourceTemplatesRequest, + ListTasksRequest, ListToolsRequest, LoggingLevel, - MessageExtraInfo, + Notification, NotificationMethod, + NotificationOptions, + NotificationTypeMap, ProtocolOptions, ReadResourceRequest, + Request, RequestMethod, RequestOptions, RequestTypeMap, ResultTypeMap, + SchemaOutput, ServerCapabilities, + StreamDriverOptions, SubscribeRequest, + TaskManager, TaskManagerOptions, Tool, Transport, UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - assertClientRequestTaskCapability, - assertToolsCallTaskCapability, CallToolResultSchema, + CancelTaskResultSchema, CompleteResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, + Dispatcher, ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, extractTaskManagerOptions, GetPromptResultSchema, + getResultSchema, + GetTaskResultSchema, InitializeResultSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, + ListTasksResultSchema, ListToolsResultSchema, mergeCapabilities, parseSchema, - Protocol, ProtocolError, ProtocolErrorCode, ReadResourceResultSchema, SdkError, - SdkErrorCode + SdkErrorCode, + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; +import type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; +import { isJSONRPCErrorResponse, isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; /** * 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 { if (!schema || data === null || typeof data !== 'object') return; - // Handle object properties if (schema.type === 'object' && schema.properties && typeof schema.properties === 'object') { const obj = data as Record; const props = schema.properties as Record; for (const key of Object.keys(props)) { const propSchema = props[key]!; - // If missing or explicitly undefined, apply default if present if (obj[key] === undefined && Object.prototype.hasOwnProperty.call(propSchema, 'default')) { obj[key] = propSchema.default; } - // Recurse into existing nested objects/arrays if (obj[key] !== undefined) { applyElicitationDefaults(propSchema, obj[key]); } @@ -96,20 +108,12 @@ function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unkn if (Array.isArray(schema.anyOf)) { for (const sub of schema.anyOf) { - // Skip boolean schemas (true/false are valid JSON Schemas but have no defaults) - if (typeof sub !== 'boolean') { - applyElicitationDefaults(sub, data); - } + if (typeof sub !== 'boolean') applyElicitationDefaults(sub, data); } } - - // Combine schemas if (Array.isArray(schema.oneOf)) { for (const sub of schema.oneOf) { - // Skip boolean schemas (true/false are valid JSON Schemas but have no defaults) - if (typeof sub !== 'boolean') { - applyElicitationDefaults(sub, data); - } + if (typeof sub !== 'boolean') applyElicitationDefaults(sub, data); } } } @@ -117,12 +121,8 @@ function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unkn /** * Determines which elicitation modes are supported based on declared client capabilities. * - * According to the spec: * - An empty elicitation capability object defaults to form mode support (backwards compatibility) * - URL mode is only supported if explicitly declared - * - * @param capabilities - The client's elicitation capabilities - * @returns An object indicating which modes are supported */ export function getSupportedElicitationModes(capabilities: ClientCapabilities['elicitation']): { supportsFormMode: boolean; @@ -131,17 +131,49 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e if (!capabilities) { return { supportsFormMode: false, supportsUrlMode: false }; } - const hasFormCapability = capabilities.form !== undefined; const hasUrlCapability = capabilities.url !== undefined; - - // If neither form nor url are explicitly declared, form mode is supported (backwards compatibility) const supportsFormMode = hasFormCapability || (!hasFormCapability && !hasUrlCapability); const supportsUrlMode = hasUrlCapability; - return { supportsFormMode, supportsUrlMode }; } +/** + * Runtime guard for the polymorphic `tools/call` (and per SEP-2557, any + * task-capable method) result. SEP-2557 lets servers return a task even when + * the client did not request one. + */ +function isCreateTaskResult(r: unknown): r is CreateTaskResult { + return ( + typeof r === 'object' && + r !== null && + typeof (r as { task?: unknown }).task === 'object' && + (r as { task?: unknown }).task !== null && + typeof (r as { task: { taskId?: unknown } }).task.taskId === 'string' + ); +} + +/** + * Loose envelope for the (draft) 2026-06 MRTR `input_required` result. Typed + * minimally so this compiles before the spec types land; runtime detection is + * by shape. + */ +type InputRequiredEnvelope = { + ResultType: 'input_required'; + InputRequests: Record }>; +}; +function isInputRequired(r: unknown): r is InputRequiredEnvelope { + return ( + typeof r === 'object' && + r !== null && + (r as { ResultType?: unknown }).ResultType === 'input_required' && + typeof (r as { InputRequests?: unknown }).InputRequests === 'object' + ); +} + +const MRTR_INPUT_RESPONSES_META_KEY = 'modelcontextprotocol.io/mrtr/inputResponses'; +const DEFAULT_MRTR_MAX_ROUNDS = 16; + /** * Extended tasks capability that includes runtime configuration (store, messageQueue). * The runtime-only fields are stripped before advertising capabilities to servers. @@ -149,920 +181,736 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e export type ClientTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; export type ClientOptions = ProtocolOptions & { - /** - * Capabilities to advertise as being supported by this client. - */ + /** Capabilities to advertise to the server. */ capabilities?: Omit & { tasks?: ClientTasksCapabilityWithRuntime; }; - - /** - * JSON Schema validator for tool output validation. - * - * The validator is used to validate structured content returned by tools - * against their declared output schemas. - * - * @default {@linkcode DefaultJsonSchemaValidator} ({@linkcode index.AjvJsonSchemaValidator | AjvJsonSchemaValidator} on Node.js, `CfWorkerJsonSchemaValidator` on Cloudflare Workers) - */ + /** Validator for tool `outputSchema`. Defaults to the runtime-appropriate Ajv/CF validator. */ jsonSchemaValidator?: jsonSchemaValidator; - + /** Handlers for `notifications/*_list_changed`. */ + listChanged?: ListChangedHandlers; /** - * Configure handlers for list changed notifications (tools, prompts, resources). - * - * @example - * ```ts source="./client.examples.ts#ClientOptions_listChanged" - * const client = new Client( - * { name: 'my-client', version: '1.0.0' }, - * { - * listChanged: { - * tools: { - * onChanged: (error, tools) => { - * if (error) { - * console.error('Failed to refresh tools:', error); - * return; - * } - * console.log('Tools updated:', tools); - * } - * }, - * prompts: { - * onChanged: (error, prompts) => console.log('Prompts updated:', prompts) - * } - * } - * } - * ); - * ``` + * Upper bound on MRTR rounds for one logical request before throwing + * {@linkcode SdkErrorCode.InternalError}. Default 16. */ - listChanged?: ListChangedHandlers; + mrtrMaxRounds?: number; }; /** - * An MCP client on top of a pluggable transport. - * - * The client will automatically begin the initialization flow with the server when {@linkcode connect} is called. + * MCP client built on a request-shaped {@linkcode ClientTransport}. * + * - 2026-06-native: every request is independent; `request()` runs the MRTR + * loop, servicing `input_required` rounds via locally registered handlers. + * - 2025-11-compat: {@linkcode connect} accepts the legacy pipe-shaped + * {@linkcode Transport} and runs the initialize handshake. */ -export class Client extends Protocol { +export class Client { + private _ct?: ClientTransport; + private _localDispatcher: Dispatcher = new Dispatcher(); + private _capabilities: ClientCapabilities; private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; - private _negotiatedProtocolVersion?: string; - private _capabilities: ClientCapabilities; private _instructions?: string; + private _negotiatedProtocolVersion?: string; + private _supportedProtocolVersions: string[]; + private _enforceStrictCapabilities: boolean; + private _mrtrMaxRounds: number; private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); private _cachedKnownTaskTools: Set = new Set(); private _cachedRequiredTaskTools: Set = new Set(); + private _requestMessageId = 0; + private _pendingListChangedConfig?: ListChangedHandlers; private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); - private _pendingListChangedConfig?: ListChangedHandlers; - private _enforceStrictCapabilities: boolean; + private _tasksOptions?: TaskManagerOptions; + + onclose?: () => void; + onerror?: (error: Error) => void; - /** - * Initializes this client with the given name and version information. - */ constructor( private _clientInfo: Implementation, - options?: ClientOptions + private _options?: ClientOptions ) { - super({ - ...options, - tasks: extractTaskManagerOptions(options?.capabilities?.tasks) - }); - this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + this._capabilities = _options?.capabilities ? { ..._options.capabilities } : {}; + this._jsonSchemaValidator = _options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + this._enforceStrictCapabilities = _options?.enforceStrictCapabilities ?? false; + this._mrtrMaxRounds = _options?.mrtrMaxRounds ?? DEFAULT_MRTR_MAX_ROUNDS; + this._pendingListChangedConfig = _options?.listChanged; + this._tasksOptions = extractTaskManagerOptions(_options?.capabilities?.tasks); // Strip runtime-only fields from advertised capabilities - if (options?.capabilities?.tasks) { + if (_options?.capabilities?.tasks) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = - options.capabilities.tasks; + _options.capabilities.tasks; this._capabilities.tasks = wireCapabilities; } - // 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 { - return ctx; + this._localDispatcher.setRequestHandler('ping', async () => ({})); } /** - * 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 + * Connects to a server. Accepts either a {@linkcode ClientTransport} + * (2026-06-native, request-shaped) or a legacy pipe {@linkcode Transport} + * (stdio, SSE, the v1 SHTTP class). Pipe transports are adapted via + * {@linkcode pipeAsClientTransport} and the 2025-11 initialize handshake + * is performed. */ - 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; - }); + async connect(transport: Transport | ClientTransport, options?: RequestOptions): Promise { + if (isPipeTransport(transport)) { + const driverOpts: StreamDriverOptions = { + supportedProtocolVersions: this._supportedProtocolVersions, + debouncedNotificationMethods: this._options?.debouncedNotificationMethods, + tasks: this._tasksOptions, + enforceStrictCapabilities: this._enforceStrictCapabilities + }; + this._ct = pipeAsClientTransport(transport, this._localDispatcher, driverOpts); + this._ct.driver!.onclose = () => this.onclose?.(); + this._ct.driver!.onerror = e => this.onerror?.(e); + const skipInit = transport.sessionId !== undefined; + if (skipInit) { + if (this._negotiatedProtocolVersion && transport.setProtocolVersion) { + transport.setProtocolVersion(this._negotiatedProtocolVersion); + } + return; + } + try { + await this._initializeHandshake(options, v => transport.setProtocolVersion?.(v)); + } catch (error) { + void this.close(); + throw error; + } + return; } - - 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; - }); + this._ct = transport; + try { + await this._discoverOrInitialize(options); + } catch (error) { + void this.close(); + throw error; } } - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalClientTasks } { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalClientTasks(this) - }; - } - return this._experimental; + async close(): Promise { + const ct = this._ct; + this._ct = undefined; + for (const t of this._listChangedDebounceTimers.values()) clearTimeout(t); + this._listChangedDebounceTimers.clear(); + // For pipe transports, driver.onclose (wired in connect) fires this.onclose. + // For ClientTransport (no driver), fire it here. + const fireOnclose = !ct?.driver; + await ct?.close(); + if (fireOnclose) this.onclose?.(); } - /** - * 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). - */ - public registerCapabilities(capabilities: ClientCapabilities): void { - if (this.transport) { - throw new Error('Cannot register capabilities after connecting to transport'); - } + get transport(): Transport | undefined { + return this._ct?.driver?.pipe; + } + /** Register additional capabilities. Must be called before {@linkcode connect}. */ + registerCapabilities(capabilities: ClientCapabilities): void { + if (this._ct) throw new Error('Cannot register capabilities after connecting to transport'); this._capabilities = mergeCapabilities(this._capabilities, capabilities); } + getServerCapabilities(): ServerCapabilities | undefined { + return this._serverCapabilities; + } + getServerVersion(): Implementation | undefined { + return this._serverVersion; + } + getNegotiatedProtocolVersion(): string | undefined { + return this._negotiatedProtocolVersion; + } + getInstructions(): string | undefined { + return this._instructions; + } + /** - * Registers a handler for server-initiated requests (sampling, elicitation, roots). - * The client must declare the corresponding capability for the handler to be accepted. - * Replaces any previously registered handler for the same method. + * Register a handler for server-initiated requests (sampling, elicitation, + * roots, ping). In MRTR mode these handlers service `input_required` rounds. + * In pipe mode they are dispatched directly by the {@linkcode StreamDriver}. * * For `sampling/createMessage` and `elicitation/create`, the handler is automatically * wrapped with schema validation for both the incoming request and the returned result. - * - * @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' - * } - * }; - * }); - * ``` */ - public override setRequestHandler( + setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise ): void { - if (method === 'elicitation/create') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { - const validatedRequest = parseSchema(ElicitRequestSchema, request); - if (!validatedRequest.success) { - // Type guard: if success is false, error is guaranteed to exist - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`); - } - - const { params } = validatedRequest.data; - params.mode = params.mode ?? 'form'; - const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); - - if (params.mode === 'form' && !supportsFormMode) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); - } - - if (params.mode === 'url' && !supportsUrlMode) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); - } - - const result = await Promise.resolve(handler(request, ctx)); - - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against ElicitResultSchema - const validationResult = parseSchema(ElicitResultSchema, result); - if (!validationResult.success) { - // Type guard: if success is false, error is guaranteed to exist - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`); - } - - const validatedResult = validationResult.data; - const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; - - if ( - params.mode === 'form' && - validatedResult.action === 'accept' && - validatedResult.content && - requestedSchema && - this._capabilities.elicitation?.form?.applyDefaults - ) { - try { - applyElicitationDefaults(requestedSchema, validatedResult.content); - } catch { - // gracefully ignore errors in default application - } - } - - return validatedResult; - }; + this._assertRequestHandlerCapability(method); - // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + if (method === 'elicitation/create') { + this._localDispatcher.setRequestHandler(method, this._wrapElicitationHandler(handler)); + return; } - if (method === 'sampling/createMessage') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); - } + this._localDispatcher.setRequestHandler(method, this._wrapSamplingHandler(handler)); + return; + } + this._localDispatcher.setRequestHandler(method, handler); + } + removeRequestHandler(method: string): void { + this._localDispatcher.removeRequestHandler(method); + } + setNotificationHandler( + method: M, + handler: (notification: NotificationTypeMap[M]) => void | Promise + ): void { + this._localDispatcher.setNotificationHandler(method, handler); + } + removeNotificationHandler(method: string): void { + this._localDispatcher.removeNotificationHandler(method); + } + set fallbackNotificationHandler(h: ((n: Notification) => Promise) | undefined) { + this._localDispatcher.fallbackNotificationHandler = h; + } - const { params } = validatedRequest.data; + /** Low-level: send one typed request. Runs the MRTR loop. */ + async request(req: { method: M; params?: RequestTypeMap[M]['params'] }, options?: RequestOptions) { + const schema = getResultSchema(req.method); + return this._request({ method: req.method, params: req.params }, schema, options) as Promise; + } - const result = await Promise.resolve(handler(request, ctx)); + /** Low-level: send a notification to the server. */ + async notification(n: Notification, _options?: NotificationOptions): Promise { + if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + if (this._enforceStrictCapabilities) this._assertNotificationCapability(n.method as NotificationMethod); + await this._ct.notify(n); + } - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } + // -- typed RPC sugar ------------------------------------------------------ - // For non-task requests, validate against appropriate schema based on tools presence - const hasTools = params.tools || params.toolChoice; - const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; - const validationResult = parseSchema(resultSchema, result); - if (!validationResult.success) { - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); + async ping(options?: RequestOptions) { + return this._request({ method: 'ping' }, EmptyResultSchema, options); + } + async complete(params: CompleteRequest['params'], options?: RequestOptions) { + return this._request({ method: 'completion/complete', params }, CompleteResultSchema, options); + } + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + return this._request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + } + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { + return this._request({ method: 'prompts/get', params }, GetPromptResultSchema, options); + } + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) return { prompts: [] }; + return this._request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + } + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) return { resources: [] }; + return this._request({ method: 'resources/list', params }, ListResourcesResultSchema, options); + } + async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) return { resourceTemplates: [] }; + return this._request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + } + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { + return this._request({ method: 'resources/read', params }, ReadResourceResultSchema, options); + } + async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { + return this._request({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + } + async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { + return this._request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + } + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) return { tools: [] }; + const result = await this._request({ method: 'tools/list', params }, ListToolsResultSchema, options); + this._cacheToolMetadata(result.tools); + return result; + } + async callTool( + params: CallToolRequest['params'], + options: RequestOptions & { task: NonNullable } + ): Promise; + async callTool(params: CallToolRequest['params'], options?: RequestOptions & { awaitTask?: boolean }): Promise; + async callTool( + params: CallToolRequest['params'], + options?: RequestOptions & { awaitTask?: boolean } + ): Promise { + if (this._cachedRequiredTaskTools.has(params.name) && !options?.task && !options?.awaitTask) { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() or pass {awaitTask: true}.` + ); + } + const raw = await this._requestRaw({ method: 'tools/call', params }, options); + // SEP-2557: server may return a task even when not requested. With options.task + // the caller asked for it; with awaitTask we poll to completion; otherwise (truly + // unsolicited) throw with guidance so the v1 callTool() return type stays CallToolResult. + if (isCreateTaskResult(raw)) { + if (options?.task) return raw; + if (options?.awaitTask) return this._pollTaskToCompletion(raw.task.taskId, options); + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Server returned a task for "${params.name}". Pass {task: {...}} or {awaitTask: true}, or use client.experimental.tasks.callToolStream().` + ); + } + const parsed = parseSchema(CallToolResultSchema, raw); + if (!parsed.success) throw parsed.error; + const result = parsed.data; + const validator = this._cachedToolOutputValidators.get(params.name); + if (validator) { + if (!result.structuredContent && !result.isError) { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ); + } + if (result.structuredContent) { + const v = validator(result.structuredContent); + if (!v.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${v.errorMessage}` + ); } - - return validationResult.data; - }; - - // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); + } } + return result; + } + async sendRootsListChanged() { + return this.notification({ method: 'notifications/roots/list_changed' }); + } - // Other handlers use default behavior - return super.setRequestHandler(method, handler); + // -- tasks (SEP-1686 / SEP-2557) ----------------------------------------- + // Kept isolated: typed RPCs + the polymorphism check in callTool above. The + // streaming/polling helpers live in {@linkcode ExperimentalClientTasks}. + + async getTask(params: GetTaskRequest['params'], options?: RequestOptions) { + return this._request({ method: 'tasks/get', params }, GetTaskResultSchema, options); + } + async listTasks(params?: ListTasksRequest['params'], options?: RequestOptions) { + return this._request({ method: 'tasks/list', params }, ListTasksResultSchema, options); + } + async cancelTask(params: CancelTaskRequest['params'], options?: RequestOptions) { + return this._request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); } - protected assertCapability(capability: keyof ServerCapabilities, method: string): void { - if (!this._serverCapabilities?.[capability]) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support ${capability} (required for ${method})`); + /** + * The connection's {@linkcode TaskManager}. Only present when connected over a + * pipe-shaped transport (the StreamDriver owns it). Request-shaped + * transports have no per-connection task buffer. + */ + get taskManager(): TaskManager { + const tm = this._ct?.driver?.taskManager; + if (!tm) { + throw new SdkError( + SdkErrorCode.NotConnected, + 'taskManager is only available when connected via a pipe-shaped Transport (stdio/SSE/InMemory).' + ); } + return tm; } /** - * Connects to a server via the given transport and performs the MCP initialization handshake. + * Access experimental task helpers (callToolStream, getTaskResult, ...). * - * @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 }; - * } - * ``` + * @experimental */ - 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; + get experimental(): { tasks: ExperimentalClientTasks } { + if (!this._experimental) { + this._experimental = { tasks: new ExperimentalClientTasks(this as never) }; } - try { - const result = await this._requestWithSchema( - { - method: 'initialize', - params: { - protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, - capabilities: this._capabilities, - clientInfo: this._clientInfo - } - }, - InitializeResultSchema, - options - ); + return this._experimental; + } - if (result === undefined) { - throw new Error(`Server sent invalid initialize result: ${result}`); - } + /** @internal structural compat for {@linkcode ExperimentalClientTasks} */ + private isToolTask(toolName: string): boolean { + return this._cachedKnownTaskTools.has(toolName); + } + /** @internal structural compat for {@linkcode ExperimentalClientTasks} */ + private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { + return this._cachedToolOutputValidators.get(toolName); + } - if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { - throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + private async _pollTaskToCompletion(taskId: string, options?: RequestOptions): Promise { + // SEP-2557 collapses tasks/result into tasks/get; poll status, then + // fetch payload. Backoff is fixed-interval; the streaming variant lives + // in ExperimentalClientTasks. + const intervalMs = 500; + while (true) { + options?.signal?.throwIfAborted(); + const r: GetTaskResult = await this.getTask({ taskId }, options); + const status = r.status; + if (status === 'completed' || status === 'failed' || status === 'cancelled') { + try { + return await this._request({ method: 'tasks/result', params: { taskId } }, CallToolResultSchema, options); + } catch { + return { content: [], isError: status !== 'completed' }; + } } + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + } - this._serverCapabilities = result.capabilities; - this._serverVersion = result.serverInfo; - this._negotiatedProtocolVersion = result.protocolVersion; - // HTTP transports must set the protocol version in each header after initialization. - if (transport.setProtocolVersion) { - transport.setProtocolVersion(result.protocolVersion); - } + // -- internals ----------------------------------------------------------- - this._instructions = result.instructions; + /** @internal alias for {@linkcode ExperimentalClientTasks} structural compat */ + private _requestWithSchema(req: Request, resultSchema: T, options?: RequestOptions): Promise> { + return this._request(req, resultSchema, options); + } - await this.notification({ - method: 'notifications/initialized' - }); + private async _request(req: Request, resultSchema: T, options?: RequestOptions): Promise> { + const raw = await this._requestRaw(req, options); + const parsed = parseSchema(resultSchema, raw); + if (!parsed.success) throw parsed.error; + return parsed.data as SchemaOutput; + } - // Set up list changed handlers now that we know server capabilities - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; + /** Like {@linkcode _request} but returns the unparsed result. Used where the result is polymorphic (e.g. SEP-2557 task results). */ + private async _requestRaw(req: Request, options?: RequestOptions): Promise { + if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + if (this._enforceStrictCapabilities) this._assertCapabilityForMethod(req.method as RequestMethod); + let inputResponses: Record = {}; + for (let round = 0; round < this._mrtrMaxRounds; round++) { + const id = this._requestMessageId++; + const meta = { + ...(req.params?._meta as Record | undefined), + ...(round > 0 ? { [MRTR_INPUT_RESPONSES_META_KEY]: inputResponses } : {}) + }; + const jr: JSONRPCRequest = { + jsonrpc: '2.0', + id, + method: req.method, + params: req.params || round > 0 ? { ...req.params, _meta: Object.keys(meta).length > 0 ? meta : undefined } : undefined + }; + const opts: ClientFetchOptions = { + signal: options?.signal, + timeout: options?.timeout, + resetTimeoutOnProgress: options?.resetTimeoutOnProgress, + maxTotalTimeout: options?.maxTotalTimeout, + onprogress: options?.onprogress, + relatedRequestId: options?.relatedRequestId, + task: options?.task, + relatedTask: options?.relatedTask, + resumptionToken: options?.resumptionToken, + onresumptiontoken: options?.onresumptiontoken, + onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)) + }; + const resp = await this._ct.fetch(jr, opts); + if (isJSONRPCErrorResponse(resp)) { + throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); } - } catch (error) { - // Disconnect if initialization fails. - void this.close(); - throw error; + const raw = resp.result; + if (isInputRequired(raw)) { + inputResponses = { ...inputResponses, ...(await this._serviceInputRequests(raw.InputRequests)) }; + continue; + } + return raw; } + throw new ProtocolError(ProtocolErrorCode.InternalError, `MRTR exceeded ${this._mrtrMaxRounds} rounds for ${req.method}`); } - /** - * After initialization has completed, this will be populated with the server's reported capabilities. - */ - getServerCapabilities(): ServerCapabilities | undefined { - return this._serverCapabilities; + private async _serviceInputRequests( + reqs: Record }> + ): Promise> { + const out: Record = {}; + for (const [key, ir] of Object.entries(reqs)) { + const synthetic: JSONRPCRequest = { jsonrpc: '2.0', id: `mrtr:${key}`, method: ir.method, params: ir.params }; + const resp = await this._localDispatcher.dispatchToResponse(synthetic); + if (isJSONRPCErrorResponse(resp)) { + throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); + } + out[key] = resp.result; + } + return out; } - /** - * After initialization has completed, this will be populated with information about the server's name and version. - */ - getServerVersion(): Implementation | undefined { - return this._serverVersion; + private async _initializeHandshake(options: RequestOptions | undefined, setProtocolVersion: (v: string) => void): Promise { + const result = await this._request( + { + method: 'initialize', + params: { + protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, + InitializeResultSchema, + options + ); + if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } + this._serverCapabilities = result.capabilities; + this._serverVersion = result.serverInfo; + this._negotiatedProtocolVersion = result.protocolVersion; + this._instructions = result.instructions; + setProtocolVersion(result.protocolVersion); + await this.notification({ method: 'notifications/initialized' }); + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } } - /** - * 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._negotiatedProtocolVersion; + private async _discoverOrInitialize(options?: RequestOptions): Promise { + // 2026-06: try server/discover, fall back to initialize. Discover schema + // is not yet in spec types, so probe and accept the result loosely. + try { + const resp = await this._ct!.fetch( + { jsonrpc: '2.0', id: this._requestMessageId++, method: 'server/discover' as RequestMethod }, + { timeout: options?.timeout, signal: options?.signal } + ); + if (!isJSONRPCErrorResponse(resp)) { + const r = resp.result as { capabilities?: ServerCapabilities; serverInfo?: Implementation; instructions?: string }; + this._serverCapabilities = r.capabilities; + this._serverVersion = r.serverInfo; + this._instructions = r.instructions; + return; + } + if (resp.error.code !== ProtocolErrorCode.MethodNotFound) { + throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); + } + } catch (error) { + // Surface non-MethodNotFound protocol errors from discover; otherwise fall through to initialize. + if (error instanceof ProtocolError && error.code !== ProtocolErrorCode.MethodNotFound) throw error; + } + await this._initializeHandshake(options, () => {}); } - /** - * After initialization has completed, this may be populated with information about the server's instructions. - */ - getInstructions(): string | undefined { - return this._instructions; + private _cacheToolMetadata(tools: Tool[]): void { + this._cachedToolOutputValidators.clear(); + this._cachedKnownTaskTools.clear(); + this._cachedRequiredTaskTools.clear(); + for (const tool of tools) { + if (tool.outputSchema) { + this._cachedToolOutputValidators.set( + tool.name, + this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) + ); + } + const ts = tool.execution?.taskSupport; + if (ts === 'required' || ts === 'optional') this._cachedKnownTaskTools.add(tool.name); + if (ts === 'required') this._cachedRequiredTaskTools.add(tool.name); + } + } + + private _wrapElicitationHandler( + handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise + ) { + return async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { + const validatedRequest = parseSchema(ElicitRequestSchema, request); + if (!validatedRequest.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid elicitation request: ${formatErr(validatedRequest.error)}` + ); + } + const { params } = validatedRequest.data; + params.mode = params.mode ?? 'form'; + const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); + if (params.mode === 'form' && !supportsFormMode) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); + } + if (params.mode === 'url' && !supportsUrlMode) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); + } + const result = await Promise.resolve(handler(request, ctx)); + if (params.task) { + const tv = parseSchema(CreateTaskResultSchema, result); + if (!tv.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${formatErr(tv.error)}`); + } + return tv.data; + } + const vr = parseSchema(ElicitResultSchema, result); + if (!vr.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${formatErr(vr.error)}`); + } + const validatedResult = vr.data; + const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; + if ( + params.mode === 'form' && + validatedResult.action === 'accept' && + validatedResult.content && + requestedSchema && + this._capabilities.elicitation?.form?.applyDefaults + ) { + try { + applyElicitationDefaults(requestedSchema, validatedResult.content); + } catch { + // gracefully ignore errors in default application + } + } + return validatedResult; + }; } - protected assertCapabilityForMethod(method: RequestMethod): void { + private _wrapSamplingHandler( + handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise + ) { + return async (request: RequestTypeMap[M], ctx: ClientContext): Promise => { + const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + if (!validatedRequest.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${formatErr(validatedRequest.error)}`); + } + const { params } = validatedRequest.data; + const result = await Promise.resolve(handler(request, ctx)); + if (params.task) { + const tv = parseSchema(CreateTaskResultSchema, result); + if (!tv.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${formatErr(tv.error)}`); + } + return tv.data; + } + const hasTools = params.tools || params.toolChoice; + const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const vr = parseSchema(resultSchema, result); + if (!vr.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${formatErr(vr.error)}`); + } + return vr.data; + }; + } + + 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; + }); + } + } + + private _setupListChangedHandler( + listType: string, + notificationMethod: NotificationMethod, + options: ListChangedOptions, + fetcher: () => Promise + ): void { + const parseResult = parseSchema(ListChangedOptionsBaseSchema, options); + if (!parseResult.success) { + throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); + } + if (typeof options.onChanged !== 'function') { + throw new TypeError(`Invalid ${listType} listChanged options: onChanged must be a function`); + } + const { autoRefresh, debounceMs } = parseResult.data; + const { onChanged } = options; + + const refresh = async () => { + if (!autoRefresh) { + onChanged(null, null); + return; + } + try { + onChanged(null, await fetcher()); + } catch (error) { + onChanged(error instanceof Error ? error : new Error(String(error)), null); + } + }; + + this.setNotificationHandler(notificationMethod, () => { + if (debounceMs) { + const existing = this._listChangedDebounceTimers.get(listType); + if (existing) clearTimeout(existing); + this._listChangedDebounceTimers.set(listType, setTimeout(refresh, debounceMs)); + } else { + void refresh(); + } + }); + } + + private _assertCapabilityForMethod(method: RequestMethod): void { switch (method as ClientRequest['method']) { case 'logging/setLevel': { - if (!this._serverCapabilities?.logging) { + if (!this._serverCapabilities?.logging) throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); - } break; } - case 'prompts/get': case 'prompts/list': { - if (!this._serverCapabilities?.prompts) { + if (!this._serverCapabilities?.prompts) throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); - } break; } - case 'resources/list': case 'resources/templates/list': case 'resources/read': case 'resources/subscribe': case 'resources/unsubscribe': { - if (!this._serverCapabilities?.resources) { + if (!this._serverCapabilities?.resources) throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); - } - - if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) { + if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) throw new SdkError( SdkErrorCode.CapabilityNotSupported, `Server does not support resource subscriptions (required for ${method})` ); - } - break; } - case 'tools/call': case 'tools/list': { - if (!this._serverCapabilities?.tools) { + if (!this._serverCapabilities?.tools) throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); - } break; } - case 'completion/complete': { - if (!this._serverCapabilities?.completions) { + if (!this._serverCapabilities?.completions) throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); - } - break; - } - - case 'initialize': { - // No specific capability required for initialize - break; - } - - case 'ping': { - // No specific capability required for ping break; } } } - protected assertNotificationCapability(method: NotificationMethod): void { - switch (method as ClientNotification['method']) { - case 'notifications/roots/list_changed': { - if (!this._capabilities.roots?.listChanged) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Client does not support roots list changed notifications (required for ${method})` - ); - } - break; - } - - case 'notifications/initialized': { - // No specific capability required for initialized - break; - } - - case 'notifications/cancelled': { - // Cancellation notifications are always allowed - break; - } - - case 'notifications/progress': { - // Progress notifications are always allowed - break; - } + private _assertNotificationCapability(method: NotificationMethod): void { + if ((method as ClientNotification['method']) === 'notifications/roots/list_changed' && !this._capabilities.roots?.listChanged) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support roots list changed notifications (required for ${method})` + ); } } - protected assertRequestHandlerCapability(method: string): void { + private _assertRequestHandlerCapability(method: string): void { switch (method) { case 'sampling/createMessage': { - if (!this._capabilities.sampling) { + if (!this._capabilities.sampling) throw new SdkError( SdkErrorCode.CapabilityNotSupported, `Client does not support sampling capability (required for ${method})` ); - } break; } - case 'elicitation/create': { - if (!this._capabilities.elicitation) { + if (!this._capabilities.elicitation) throw new SdkError( SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation capability (required for ${method})` ); - } break; } - case 'roots/list': { - if (!this._capabilities.roots) { + if (!this._capabilities.roots) throw new SdkError( SdkErrorCode.CapabilityNotSupported, `Client does not support roots capability (required for ${method})` ); - } break; } - - case 'ping': { - // No specific capability required for ping - break; - } - } - } - - protected assertTaskCapability(method: string): void { - assertToolsCallTaskCapability(this._serverCapabilities?.tasks?.requests, method, 'Server'); - } - - protected assertTaskHandlerCapability(method: string): void { - assertClientRequestTaskCapability(this._capabilities?.tasks?.requests, method, 'Client'); - } - - async ping(options?: RequestOptions) { - 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._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); - } - - /** Sets the minimum severity level for log messages sent by the server. */ - async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { - 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._requestWithSchema({ 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._requestWithSchema({ 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._requestWithSchema({ 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._requestWithSchema({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); - } - - /** Reads the contents of a resource by URI. */ - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); - } - - /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ - async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); - } - - /** Unsubscribes from change notifications for a resource. */ - async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { - 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). - * - * For task-based execution with streaming behavior, use {@linkcode ExperimentalClientTasks.callToolStream | client.experimental.tasks.callToolStream()} instead. - * - * @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) { - // Guard: required-task tools need experimental API - if (this.isToolTaskRequired(params.name)) { - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` - ); - } - - const result = await this._requestWithSchema({ 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; - } - - private isToolTask(toolName: string): boolean { - if (!this._serverCapabilities?.tasks?.requests?.tools?.call) { - return false; } - - return this._cachedKnownTaskTools.has(toolName); - } - - /** - * Check if a tool requires task-based execution. - * Unlike {@linkcode isToolTask} which includes `'optional'` tools, this only checks for `'required'`. - */ - private isToolTaskRequired(toolName: string): boolean { - return this._cachedRequiredTaskTools.has(toolName); - } - - /** - * 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(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.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); - } - - // If the tool supports task-based execution, cache that information - const taskSupport = tool.execution?.taskSupport; - if (taskSupport === 'required' || taskSupport === 'optional') { - this._cachedKnownTaskTools.add(tool.name); - } - if (taskSupport === 'required') { - this._cachedRequiredTaskTools.add(tool.name); - } - } - } - - /** - * 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._requestWithSchema({ 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( - listType: string, - notificationMethod: NotificationMethod, - options: ListChangedOptions, - fetcher: () => Promise - ): void { - // Validate options using Zod schema (validates autoRefresh and debounceMs) - const parseResult = parseSchema(ListChangedOptionsBaseSchema, options); - if (!parseResult.success) { - throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); - } - - // Validate callback - if (typeof options.onChanged !== 'function') { - throw new TypeError(`Invalid ${listType} listChanged options: onChanged must be a function`); - } - - const { autoRefresh, debounceMs } = parseResult.data; - const { onChanged } = options; - - const refresh = async () => { - if (!autoRefresh) { - onChanged(null, null); - return; - } - - try { - const items = await fetcher(); - onChanged(null, items); - } catch (error) { - const newError = error instanceof Error ? error : new Error(String(error)); - onChanged(newError, null); - } - }; - - const handler = () => { - if (debounceMs) { - // Clear any pending debounce timer for this list type - const existingTimer = this._listChangedDebounceTimers.get(listType); - if (existingTimer) { - clearTimeout(existingTimer); - } - - // Set up debounced refresh - const timer = setTimeout(refresh, debounceMs); - this._listChangedDebounceTimers.set(listType, timer); - } else { - // No debounce, refresh immediately - refresh(); - } - }; - - // Register notification handler - this.setNotificationHandler(notificationMethod, handler); } +} - /** Notifies the server that the client's root list has changed. Requires the `roots.listChanged` capability. */ - async sendRootsListChanged() { - return this.notification({ method: 'notifications/roots/list_changed' }); - } +function formatErr(e: unknown): string { + return e instanceof Error ? e.message : String(e); } + +export type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; +export { isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index de5d8decc..60e063aea 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -6,11 +6,15 @@ import type { JSONRPCResultResponse, Notification, Progress, + RelatedTaskMetadata, Request, + RequestId, RequestOptions, + StreamDriverOptions, + TaskCreationParams, Transport } from '@modelcontextprotocol/core'; -import { getResultSchema, StreamDriver } from '@modelcontextprotocol/core'; +import { getResultSchema, SdkError, SdkErrorCode, StreamDriver } from '@modelcontextprotocol/core'; /** * Per-call options for {@linkcode ClientTransport.fetch}. @@ -28,6 +32,16 @@ export type ClientFetchOptions = { resetTimeoutOnProgress?: boolean; /** Absolute upper bound (ms) regardless of progress. */ maxTotalTimeout?: number; + /** Associates this outbound request with an inbound one (pipe transports only). */ + relatedRequestId?: RequestId; + /** Augment as a task-creating request (pipe transports only; threaded to TaskManager). */ + task?: TaskCreationParams; + /** Associate with an existing task (pipe transports only). */ + relatedTask?: RelatedTaskMetadata; + /** Resumption token to continue a previous request (SHTTP only). */ + resumptionToken?: string; + /** Called when the resumption token changes (SHTTP only). */ + onresumptiontoken?: (token: string) => void; }; /** @@ -84,11 +98,13 @@ export function isPipeTransport(t: Transport | ClientTransport): t is Transport * server-initiated requests (sampling, elicitation, roots) that arrive on the pipe. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- adapter is context-agnostic; the caller's Dispatcher subclass owns ContextT -export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher): ClientTransport { - const driver = new StreamDriver(dispatcher, pipe); +export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher, options?: StreamDriverOptions): ClientTransport { + const driver = new StreamDriver(dispatcher, pipe, options); let started = false; const subscribers: Set<(n: JSONRPCNotification) => void> = new Set(); + const priorFallback = dispatcher.fallbackNotificationHandler; dispatcher.fallbackNotificationHandler = async n => { + await priorFallback?.(n); const msg: JSONRPCNotification = { jsonrpc: '2.0', method: n.method, params: n.params }; for (const s of subscribers) s(msg); }; @@ -102,6 +118,9 @@ export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher }>; -}; -function isInputRequired(r: unknown): r is InputRequiredEnvelope { - return ( - typeof r === 'object' && - r !== null && - (r as { ResultType?: unknown }).ResultType === 'input_required' && - typeof (r as { InputRequests?: unknown }).InputRequests === 'object' - ); -} - -const MRTR_INPUT_RESPONSES_META_KEY = 'modelcontextprotocol.io/mrtr/inputResponses'; -const DEFAULT_MRTR_MAX_ROUNDS = 16; - -export type ClientOptions = { - /** Capabilities to advertise to the server. */ - capabilities?: ClientCapabilities; - /** Validator for tool `outputSchema`. Defaults to the runtime-appropriate Ajv/CF validator. */ - jsonSchemaValidator?: jsonSchemaValidator; - /** Handlers for `notifications/*_list_changed`. */ - listChanged?: ListChangedHandlers; - /** Protocol versions this client supports. First entry is preferred. */ - supportedProtocolVersions?: string[]; - /** - * If true, list* methods throw on missing server capability instead of - * returning empty. Default false. - */ - enforceStrictCapabilities?: boolean; - /** - * Upper bound on MRTR rounds for one logical request before throwing - * {@linkcode SdkErrorCode.InternalError}. Default 16. - */ - mrtrMaxRounds?: number; -}; - -/** - * MCP client built on a request-shaped {@linkcode ClientTransport}. - * - * - 2026-06-native: every request is independent; `request()` runs the MRTR - * loop, servicing `input_required` rounds via locally registered handlers. - * - 2025-11-compat: {@linkcode connect} accepts the legacy pipe-shaped - * {@linkcode Transport} and runs the initialize handshake. - */ -export class Client { - private _ct?: ClientTransport; - private _localDispatcher: Dispatcher = new Dispatcher(); - private _capabilities: ClientCapabilities; - private _serverCapabilities?: ServerCapabilities; - private _serverVersion?: Implementation; - private _instructions?: string; - private _negotiatedProtocolVersion?: string; - private _supportedProtocolVersions: string[]; - private _enforceStrictCapabilities: boolean; - private _mrtrMaxRounds: number; - private _jsonSchemaValidator: jsonSchemaValidator; - private _cachedToolOutputValidators: Map> = new Map(); - private _cachedKnownTaskTools: Set = new Set(); - private _cachedRequiredTaskTools: Set = new Set(); - private _requestMessageId = 0; - private _pendingListChangedConfig?: ListChangedHandlers; - private _experimental?: { tasks: ExperimentalClientTasks }; - - onclose?: () => void; - onerror?: (error: Error) => void; - - constructor( - private _clientInfo: Implementation, - options?: ClientOptions - ) { - this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - this._supportedProtocolVersions = options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; - this._mrtrMaxRounds = options?.mrtrMaxRounds ?? DEFAULT_MRTR_MAX_ROUNDS; - this._pendingListChangedConfig = options?.listChanged; - this._localDispatcher.setRequestHandler('ping', async () => ({})); - } - - /** - * Connects to a server. Accepts either a {@linkcode ClientTransport} - * (2026-06-native, request-shaped) or a legacy pipe {@linkcode Transport} - * (stdio, SSE, the v1 SHTTP class). Pipe transports are adapted via - * {@linkcode pipeAsClientTransport} and the 2025-11 initialize handshake - * is performed. - */ - async connect(transport: Transport | ClientTransport, options?: RequestOptions): Promise { - if (isPipeTransport(transport)) { - this._ct = pipeAsClientTransport(transport, this._localDispatcher); - this._ct.driver!.onclose = () => this.onclose?.(); - this._ct.driver!.onerror = e => this.onerror?.(e); - const skipInit = transport.sessionId !== undefined; - if (skipInit) { - if (this._negotiatedProtocolVersion && transport.setProtocolVersion) { - transport.setProtocolVersion(this._negotiatedProtocolVersion); - } - return; - } - try { - await this._initializeHandshake(options, v => transport.setProtocolVersion?.(v)); - } catch (error) { - void this.close(); - throw error; - } - return; - } - this._ct = transport; - try { - await this._discoverOrInitialize(options); - } catch (error) { - void this.close(); - throw error; - } - } - - async close(): Promise { - const ct = this._ct; - this._ct = undefined; - await ct?.close(); - this.onclose?.(); - } - - get transport(): Transport | undefined { - return this._ct?.driver?.pipe; - } - - /** Register additional capabilities. Must be called before {@linkcode connect}. */ - registerCapabilities(capabilities: ClientCapabilities): void { - if (this._ct) throw new Error('Cannot register capabilities after connecting to transport'); - this._capabilities = mergeCapabilities(this._capabilities, capabilities); - } - - getServerCapabilities(): ServerCapabilities | undefined { - return this._serverCapabilities; - } - getServerVersion(): Implementation | undefined { - return this._serverVersion; - } - getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; - } - getInstructions(): string | undefined { - return this._instructions; - } - - /** - * Register a handler for server-initiated requests (sampling, elicitation, - * roots, ping). In MRTR mode these handlers service `input_required` rounds. - * In pipe mode they are dispatched directly by the {@linkcode StreamDriver}. - */ - setRequestHandler( - method: M, - handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise - ): void { - this._localDispatcher.setRequestHandler(method, handler); - } - removeRequestHandler(method: string): void { - this._localDispatcher.removeRequestHandler(method); - } - setNotificationHandler(method: M, handler: (n: Notification) => void | Promise): void { - this._localDispatcher.setNotificationHandler(method, handler as never); - } - removeNotificationHandler(method: string): void { - this._localDispatcher.removeNotificationHandler(method); - } - set fallbackNotificationHandler(h: ((n: Notification) => Promise) | undefined) { - this._localDispatcher.fallbackNotificationHandler = h; - } - - /** Low-level: send one typed request. Runs the MRTR loop. */ - async request(req: { method: M; params?: RequestTypeMap[M]['params'] }, options?: RequestOptions) { - const schema = getResultSchema(req.method); - return this._request({ method: req.method, params: req.params }, schema, options) as Promise; - } - - /** Low-level: send a notification to the server. */ - async notification(n: Notification): Promise { - if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); - await this._ct.notify(n); - } - - // -- typed RPC sugar (ported from client.ts) ------------------------------ - - async ping(options?: RequestOptions) { - return this._request({ method: 'ping' }, EmptyResultSchema, options); - } - async complete(params: CompleteRequest['params'], options?: RequestOptions) { - return this._request({ method: 'completion/complete', params }, CompleteResultSchema, options); - } - async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { - return this._request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); - } - async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { - return this._request({ method: 'prompts/get', params }, GetPromptResultSchema, options); - } - async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) return { prompts: [] }; - return this._request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); - } - async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) return { resources: [] }; - return this._request({ method: 'resources/list', params }, ListResourcesResultSchema, options); - } - async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) return { resourceTemplates: [] }; - return this._request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); - } - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this._request({ method: 'resources/read', params }, ReadResourceResultSchema, options); - } - async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { - return this._request({ method: 'resources/subscribe', params }, EmptyResultSchema, options); - } - async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { - return this._request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); - } - async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { - if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) return { tools: [] }; - const result = await this._request({ method: 'tools/list', params }, ListToolsResultSchema, options); - this._cacheToolMetadata(result.tools); - return result; - } - async callTool( - params: CallToolRequest['params'], - options?: RequestOptions & { awaitTask?: boolean } - ): Promise { - if (this._cachedRequiredTaskTools.has(params.name) && !options?.task && !options?.awaitTask) { - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() or pass {awaitTask: true}.` - ); - } - const raw = await this._requestRaw({ method: 'tools/call', params }, options); - // SEP-2557: server may return a task even when not requested. - if (isCreateTaskResult(raw)) { - if (options?.awaitTask) { - return this._pollTaskToCompletion(raw.task.taskId, options); - } - return raw; - } - const parsed = parseSchema(CallToolResultSchema, raw); - if (!parsed.success) throw parsed.error; - const result = parsed.data; - const validator = this._cachedToolOutputValidators.get(params.name); - if (validator) { - if (!result.structuredContent && !result.isError) { - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Tool ${params.name} has an output schema but did not return structured content` - ); - } - if (result.structuredContent) { - const v = validator(result.structuredContent); - if (!v.valid) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${v.errorMessage}` - ); - } - } - } - return result; - } - async sendRootsListChanged() { - return this.notification({ method: 'notifications/roots/list_changed' }); - } - - // -- tasks (SEP-1686 / SEP-2557) ----------------------------------------- - // Kept isolated: typed RPCs + the polymorphism check in callTool above. The - // streaming/polling helpers live in {@linkcode ExperimentalClientTasks}. - - async getTask(params: GetTaskRequest['params'], options?: RequestOptions) { - return this._request({ method: 'tasks/get', params }, GetTaskResultSchema, options); - } - async listTasks(params?: ListTasksRequest['params'], options?: RequestOptions) { - return this._request({ method: 'tasks/list', params }, ListTasksResultSchema, options); - } - async cancelTask(params: CancelTaskRequest['params'], options?: RequestOptions) { - return this._request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); - } - - /** - * The connection's {@linkcode TaskManager}. Only present when connected over a - * pipe-shaped transport (the StreamDriver owns it). Request-shaped - * transports have no per-connection task buffer. - */ - get taskManager(): TaskManager { - const tm = this._ct?.driver?.taskManager; - if (!tm) { - throw new SdkError( - SdkErrorCode.NotConnected, - 'taskManager is only available when connected via a pipe-shaped Transport (stdio/SSE/InMemory).' - ); - } - return tm; - } - - /** - * Access experimental task helpers (callToolStream, getTaskResult, ...). - * - * @experimental - */ - get experimental(): { tasks: ExperimentalClientTasks } { - if (!this._experimental) { - this._experimental = { tasks: new ExperimentalClientTasks(this as never) }; - } - return this._experimental; - } - - /** @internal structural compat for {@linkcode ExperimentalClientTasks} */ - private isToolTask(toolName: string): boolean { - return this._cachedKnownTaskTools.has(toolName); - } - /** @internal structural compat for {@linkcode ExperimentalClientTasks} */ - private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { - return this._cachedToolOutputValidators.get(toolName); - } - - private async _pollTaskToCompletion(taskId: string, options?: RequestOptions): Promise { - // SEP-2557 collapses tasks/result into tasks/get; poll status, then - // fetch payload. Backoff is fixed-interval; the streaming variant lives - // in ExperimentalClientTasks. - const intervalMs = 500; - while (true) { - options?.signal?.throwIfAborted(); - const r: GetTaskResult = await this.getTask({ taskId }, options); - const status = r.status; - if (status === 'completed' || status === 'failed' || status === 'cancelled') { - try { - return await this._request({ method: 'tasks/result', params: { taskId } }, CallToolResultSchema, options); - } catch { - return { content: [], isError: status !== 'completed' }; - } - } - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } - } - - // -- internals ----------------------------------------------------------- - - private async _request(req: Request, resultSchema: T, options?: RequestOptions): Promise> { - const raw = await this._requestRaw(req, options); - const parsed = parseSchema(resultSchema, raw); - if (!parsed.success) throw parsed.error; - return parsed.data as SchemaOutput; - } - - /** Like {@linkcode _request} but returns the unparsed result. Used where the result is polymorphic (e.g. SEP-2557 task results). */ - private async _requestRaw(req: Request, options?: RequestOptions): Promise { - if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); - let inputResponses: Record = {}; - for (let round = 0; round < this._mrtrMaxRounds; round++) { - const id = this._requestMessageId++; - const meta = { - ...(req.params?._meta as Record | undefined), - ...(round > 0 ? { [MRTR_INPUT_RESPONSES_META_KEY]: inputResponses } : {}) - }; - const jr: JSONRPCRequest = { - jsonrpc: '2.0', - id, - method: req.method, - params: req.params || round > 0 ? { ...req.params, _meta: Object.keys(meta).length > 0 ? meta : undefined } : undefined - }; - const opts: ClientFetchOptions = { - signal: options?.signal, - timeout: options?.timeout, - resetTimeoutOnProgress: options?.resetTimeoutOnProgress, - maxTotalTimeout: options?.maxTotalTimeout, - onprogress: options?.onprogress, - onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)) - }; - const resp = await this._ct.fetch(jr, opts); - if (isJSONRPCErrorResponse(resp)) { - throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); - } - const raw = resp.result; - if (isInputRequired(raw)) { - inputResponses = { ...inputResponses, ...(await this._serviceInputRequests(raw.InputRequests)) }; - continue; - } - return raw; - } - throw new ProtocolError(ProtocolErrorCode.InternalError, `MRTR exceeded ${this._mrtrMaxRounds} rounds for ${req.method}`); - } - - private async _serviceInputRequests( - reqs: Record }> - ): Promise> { - const out: Record = {}; - for (const [key, ir] of Object.entries(reqs)) { - const synthetic: JSONRPCRequest = { jsonrpc: '2.0', id: `mrtr:${key}`, method: ir.method, params: ir.params }; - const resp = await this._localDispatcher.dispatchToResponse(synthetic); - if (isJSONRPCErrorResponse(resp)) { - throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); - } - out[key] = resp.result; - } - return out; - } - - private async _initializeHandshake(options: RequestOptions | undefined, setProtocolVersion: (v: string) => void): Promise { - const result = await this._request( - { - method: 'initialize', - params: { - protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, - capabilities: this._capabilities, - clientInfo: this._clientInfo - } - }, - InitializeResultSchema, - options - ); - if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { - throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); - } - this._serverCapabilities = result.capabilities; - this._serverVersion = result.serverInfo; - this._negotiatedProtocolVersion = result.protocolVersion; - this._instructions = result.instructions; - setProtocolVersion(result.protocolVersion); - await this.notification({ method: 'notifications/initialized' }); - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; - } - } - - private async _discoverOrInitialize(options?: RequestOptions): Promise { - // 2026-06: try server/discover, fall back to initialize. Discover schema - // is not yet in spec types, so probe and accept the result loosely. - try { - const resp = await this._ct!.fetch( - { jsonrpc: '2.0', id: this._requestMessageId++, method: 'server/discover' as RequestMethod }, - { timeout: options?.timeout, signal: options?.signal } - ); - if (!isJSONRPCErrorResponse(resp)) { - const r = resp.result as { capabilities?: ServerCapabilities; serverInfo?: Implementation; instructions?: string }; - this._serverCapabilities = r.capabilities; - this._serverVersion = r.serverInfo; - this._instructions = r.instructions; - return; - } - if (resp.error.code !== ProtocolErrorCode.MethodNotFound) { - throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); - } - } catch (error) { - if ( - (!(error instanceof ProtocolError) || (error as ProtocolError).code !== ProtocolErrorCode.MethodNotFound) && // Non-method-not-found error from discover: surface it. - error instanceof ProtocolError - ) - throw error; - } - await this._initializeHandshake(options, () => {}); - } - - private _cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.clear(); - for (const tool of tools) { - if (tool.outputSchema) { - this._cachedToolOutputValidators.set( - tool.name, - this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) - ); - } - const ts = tool.execution?.taskSupport; - if (ts === 'required' || ts === 'optional') this._cachedKnownTaskTools.add(tool.name); - if (ts === 'required') this._cachedRequiredTaskTools.add(tool.name); - } - } - - private _setupListChangedHandlers(config: ListChangedHandlers): void { - const wire = (kind: 'tools' | 'prompts' | 'resources', notif: NotificationMethod, fetch: () => Promise) => { - const c = config[kind]; - if (!c) return; - const cap = this._serverCapabilities?.[kind] as { listChanged?: boolean } | undefined; - if (!cap?.listChanged) return; - this._localDispatcher.setNotificationHandler(notif, async () => { - if (c.autoRefresh === false) return c.onChanged(null, null); - try { - c.onChanged(null, (await fetch()) as never); - } catch (error) { - c.onChanged(error instanceof Error ? error : new Error(String(error)), null); - } - }); - }; - wire('tools', 'notifications/tools/list_changed', async () => { - const r = await this.listTools(); - return r.tools; - }); - wire('prompts', 'notifications/prompts/list_changed', async () => { - const r = await this.listPrompts(); - return r.prompts ?? []; - }); - wire('resources', 'notifications/resources/list_changed', async () => { - const r = await this.listResources(); - return r.resources ?? []; - }); - } -} - -export type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; -export { isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; diff --git a/packages/client/test/client/clientV2.test.ts b/packages/client/test/client/client.test.ts similarity index 97% rename from packages/client/test/client/clientV2.test.ts rename to packages/client/test/client/client.test.ts index d2053b7bd..95179bcf5 100644 --- a/packages/client/test/client/clientV2.test.ts +++ b/packages/client/test/client/client.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { ClientFetchOptions, ClientTransport } from '../../src/client/clientTransport.js'; import { isPipeTransport } from '../../src/client/clientTransport.js'; -import { Client } from '../../src/client/clientV2.js'; +import { Client } from '../../src/client/client.js'; type FetchResp = JSONRPCResultResponse | JSONRPCErrorResponse; @@ -153,7 +153,7 @@ describe('Client (V2)', () => { opts?.onprogress?.({ progress: 1, total: 2 }); return ok(r.id, { content: [] }); }); - await c.callTool({ name: 'x', arguments: {} }, { onprogress: p => seen.push(p) }); + await c.callTool({ name: 'x', arguments: {} }, { onprogress: (p: unknown) => seen.push(p) }); expect(seen).toEqual([{ progress: 1, total: 2 }]); }); }); @@ -277,7 +277,7 @@ describe('Client (V2)', () => { return ok(r.id, { content: [] }); }); const c = new Client({ name: 'c', version: '1' }); - c.setNotificationHandler('notifications/message', n => void got.push(n as JSONRPCNotification)); + c.setNotificationHandler('notifications/message', (n: unknown) => void got.push(n as JSONRPCNotification)); await c.connect(ct); await c.callTool({ name: 't', arguments: {} }); expect(got).toHaveLength(1); @@ -303,11 +303,10 @@ describe('Client (V2)', () => { expect(typeof a.callToolStream).toBe('function'); }); - it('callTool returns CreateTaskResult unchanged when server returns a task (SEP-2557 unsolicited)', async () => { + it('callTool throws with guidance when server returns a task without awaitTask (v1-compat surface)', async () => { const taskResult = { task: { taskId: 't-1', status: 'working', createdAt: '2026-01-01T00:00:00Z' } }; const { c } = await connected(r => (r.method === 'tools/call' ? ok(r.id, taskResult) : err(r.id, -32601, ''))); - const result = (await c.callTool({ name: 'slow', arguments: {} })) as CreateTaskResult; - expect(result.task.taskId).toBe('t-1'); + await expect(c.callTool({ name: 'slow', arguments: {} })).rejects.toThrow(/returned a task.*awaitTask/); }); const taskBody = (overrides: Record = {}) => ({ From 4548bd08fa4e031e9108e2b3bf5a6bbb223d6f00 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 15:35:37 +0000 Subject: [PATCH 14/55] feat: add @modelcontextprotocol/sdk meta-package + missing type exports for v1 path compat - packages/sdk/: 33 stub files re-exporting from client/server/node/express at v1 paths - Adds Prompt/Resource/*Result/*Schema exports to types.js path - 6 sdk tests passing, typecheck clean, builds + packs Enables bump-only consumers using @modelcontextprotocol/sdk/* paths. --- packages/core/src/exports/public/index.ts | 10 +- packages/core/src/shared/protocol.ts | 24 ++ packages/sdk/README.md | 25 ++ packages/sdk/eslint.config.mjs | 27 ++ packages/sdk/package.json | 331 ++++++++++++++++++ packages/sdk/src/client/auth.ts | 1 + packages/sdk/src/client/index.ts | 1 + packages/sdk/src/client/sse.ts | 1 + packages/sdk/src/client/stdio.ts | 2 + packages/sdk/src/client/streamableHttp.ts | 6 + packages/sdk/src/experimental/tasks.ts | 5 + packages/sdk/src/inMemory.ts | 1 + packages/sdk/src/index.ts | 90 +++++ packages/sdk/src/server/auth/clients.ts | 3 + packages/sdk/src/server/auth/errors.ts | 2 + .../src/server/auth/middleware/bearerAuth.ts | 3 + packages/sdk/src/server/auth/provider.ts | 3 + packages/sdk/src/server/auth/router.ts | 4 + packages/sdk/src/server/auth/types.ts | 2 + packages/sdk/src/server/completable.ts | 1 + packages/sdk/src/server/index.ts | 1 + packages/sdk/src/server/mcp.ts | 21 ++ packages/sdk/src/server/sse.ts | 4 + packages/sdk/src/server/stdio.ts | 1 + packages/sdk/src/server/streamableHttp.ts | 4 + .../src/server/webStandardStreamableHttp.ts | 4 + packages/sdk/src/server/zod-compat.ts | 16 + packages/sdk/src/shared/auth.ts | 33 ++ packages/sdk/src/shared/protocol.ts | 15 + packages/sdk/src/shared/stdio.ts | 1 + packages/sdk/src/shared/transport.ts | 2 + packages/sdk/src/stdio.ts | 3 + packages/sdk/src/types.ts | 23 ++ packages/sdk/src/validation/ajv-provider.ts | 1 + .../sdk/src/validation/cfworker-provider.ts | 1 + packages/sdk/src/validation/types.ts | 1 + packages/sdk/test/compat.test.ts | 40 +++ packages/sdk/tsconfig.build.json | 14 + packages/sdk/tsconfig.json | 24 ++ packages/sdk/tsdown.config.ts | 46 +++ packages/sdk/vitest.config.js | 3 + packages/server/package.json | 4 + packages/server/src/zodSchemas.ts | 12 + packages/server/tsdown.config.ts | 2 +- pnpm-lock.yaml | 55 +++ 45 files changed, 869 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/eslint.config.mjs create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/client/auth.ts create mode 100644 packages/sdk/src/client/index.ts create mode 100644 packages/sdk/src/client/sse.ts create mode 100644 packages/sdk/src/client/stdio.ts create mode 100644 packages/sdk/src/client/streamableHttp.ts create mode 100644 packages/sdk/src/experimental/tasks.ts create mode 100644 packages/sdk/src/inMemory.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/server/auth/clients.ts create mode 100644 packages/sdk/src/server/auth/errors.ts create mode 100644 packages/sdk/src/server/auth/middleware/bearerAuth.ts create mode 100644 packages/sdk/src/server/auth/provider.ts create mode 100644 packages/sdk/src/server/auth/router.ts create mode 100644 packages/sdk/src/server/auth/types.ts create mode 100644 packages/sdk/src/server/completable.ts create mode 100644 packages/sdk/src/server/index.ts create mode 100644 packages/sdk/src/server/mcp.ts create mode 100644 packages/sdk/src/server/sse.ts create mode 100644 packages/sdk/src/server/stdio.ts create mode 100644 packages/sdk/src/server/streamableHttp.ts create mode 100644 packages/sdk/src/server/webStandardStreamableHttp.ts create mode 100644 packages/sdk/src/server/zod-compat.ts create mode 100644 packages/sdk/src/shared/auth.ts create mode 100644 packages/sdk/src/shared/protocol.ts create mode 100644 packages/sdk/src/shared/stdio.ts create mode 100644 packages/sdk/src/shared/transport.ts create mode 100644 packages/sdk/src/stdio.ts create mode 100644 packages/sdk/src/types.ts create mode 100644 packages/sdk/src/validation/ajv-provider.ts create mode 100644 packages/sdk/src/validation/cfworker-provider.ts create mode 100644 packages/sdk/src/validation/types.ts create mode 100644 packages/sdk/test/compat.test.ts create mode 100644 packages/sdk/tsconfig.build.json create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/tsdown.config.ts create mode 100644 packages/sdk/vitest.config.js create mode 100644 packages/server/src/zodSchemas.ts diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 2dc1e13a8..2d88fa44e 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -38,7 +38,8 @@ export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/aut // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils.js'; -// Protocol types (NOT the Protocol class itself or mergeCapabilities) +// Protocol types. The Protocol class is exported for v1-compat (subclassed by +// some consumers); new code should use Dispatcher / McpServer / Client directly. export type { BaseContext, ClientContext, @@ -48,7 +49,10 @@ export type { RequestOptions, ServerContext } from '../../shared/protocol.js'; -export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js'; +export { DEFAULT_REQUEST_TIMEOUT_MSEC, mergeCapabilities, Protocol } from '../../shared/protocol.js'; + +// In-memory transport (testing + v1 compat) +export { InMemoryTransport } from '../../util/inMemory.js'; // Task manager types (NOT TaskManager class itself — internal) export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; @@ -136,7 +140,7 @@ export { isTerminal } from '../../experimental/tasks/interfaces.js'; export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; // Validator types and classes -export type { StandardSchemaWithJSON } from '../../util/standardSchema.js'; +export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js'; export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js'; // fromJsonSchema is intentionally NOT exported here — the server and client packages diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab6932..ed43aaf44 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -227,6 +227,30 @@ export type BaseContext = { * Task context, available when task storage is configured. */ task?: TaskContext; + + // ─── v1 flat aliases (deprecated) ──────────────────────────────────── + // v1's RequestHandlerExtra exposed these at the top level. v2 nests them + // under {@linkcode mcpReq} / {@linkcode http}. The flat forms are kept + // typed (and populated at runtime by McpServer.buildContext) so v1 handler + // code keeps compiling. Prefer the nested paths for new code. + + /** @deprecated Use {@linkcode mcpReq.signal}. */ + signal?: AbortSignal; + /** @deprecated Use {@linkcode mcpReq.id}. */ + requestId?: RequestId; + /** @deprecated Use {@linkcode mcpReq._meta}. */ + _meta?: RequestMeta; + /** @deprecated Use {@linkcode mcpReq.notify}. */ + sendNotification?: (notification: Notification) => Promise; + /** @deprecated Use {@linkcode mcpReq.send}. */ + sendRequest?: ( + request: { method: M; params?: Record }, + options?: TaskRequestOptions + ) => Promise; + /** @deprecated Use {@linkcode http.authInfo}. */ + authInfo?: AuthInfo; + /** @deprecated v1 carried raw request info here. v2 surfaces the web `Request` via {@linkcode ServerContext.http}. */ + requestInfo?: globalThis.Request; }; /** diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 000000000..a64d003cb --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,25 @@ +# @modelcontextprotocol/sdk + +The **primary entry point** for the Model Context Protocol TypeScript SDK. + +This meta-package re-exports the full public surface of [`@modelcontextprotocol/server`](../server), [`@modelcontextprotocol/client`](../client), and [`@modelcontextprotocol/node`](../middleware/node), so most applications can depend on this package alone: + +```ts +import { McpServer, Client, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk'; +``` + +## Upgrading from v1 + +`@modelcontextprotocol/sdk` v2 is a drop-in upgrade for most v1 servers — just bump the version. v1 deep-import paths (`@modelcontextprotocol/sdk/types.js`, `/server/mcp.js`, `/client/index.js`, `/shared/transport.js`, etc.) are preserved as compatibility subpaths that re-export +the matching v2 symbols and emit one-time deprecation warnings where the API shape changed. + +See [`docs/migration.md`](../../docs/migration.md) for the full mapping. + +## When to use the sub-packages directly + +Bundle-sensitive targets (browsers, Cloudflare Workers) should import from `@modelcontextprotocol/client` or `@modelcontextprotocol/server` directly to avoid pulling in Node-only transports. + +## Optional subpaths + +The `./server/auth/*` subpaths re-export the legacy Authorization Server helpers from `@modelcontextprotocol/server-auth-legacy`, which require `express` to be installed by the consumer. Similarly, `./server/sse.js` (the deprecated `SSEServerTransport`) is provided by +`@modelcontextprotocol/node`. Both `express` and `hono` are optional peer dependencies — install them only if you use those subpaths. diff --git a/packages/sdk/eslint.config.mjs b/packages/sdk/eslint.config.mjs new file mode 100644 index 000000000..e34a2a51a --- /dev/null +++ b/packages/sdk/eslint.config.mjs @@ -0,0 +1,27 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/' + } + }, + { + // This package is the v1-compat surface; deprecated re-exports are intentional. + // import/no-unresolved: subpaths re-export from sibling packages (server-auth-legacy, + // node/sse, server/zod-schemas) that don't exist on this branch standalone — they + // land via separate PRs in this BC series. Resolves once those merge. + // import/export: types.ts deliberately shadows `export *` names with v1-compat aliases + // (TS spec: named export wins over re-export). + // unicorn/filename-case: validation/ajv-provider.ts etc. match v1 subpath names. + rules: { + '@typescript-eslint/no-deprecated': 'off', + 'import/no-unresolved': 'off', + 'import/export': 'off', + 'unicorn/filename-case': 'off' + } + } +]; diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 000000000..9a779645f --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,331 @@ +{ + "name": "@modelcontextprotocol/sdk", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Full SDK (re-exports client, server, and node middleware)", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs" + }, + "./stdio": { + "types": "./dist/stdio.d.ts", + "import": "./dist/stdio.mjs" + }, + "./types.js": { + "types": "./dist/types.d.ts", + "import": "./dist/types.mjs" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.mjs" + }, + "./server/index.js": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.mjs" + }, + "./server/index": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.mjs" + }, + "./server/mcp.js": { + "types": "./dist/server/mcp.d.ts", + "import": "./dist/server/mcp.mjs" + }, + "./server/mcp": { + "types": "./dist/server/mcp.d.ts", + "import": "./dist/server/mcp.mjs" + }, + "./server/zod-compat.js": { + "types": "./dist/server/zod-compat.d.ts", + "import": "./dist/server/zod-compat.mjs" + }, + "./server/zod-compat": { + "types": "./dist/server/zod-compat.d.ts", + "import": "./dist/server/zod-compat.mjs" + }, + "./server/stdio.js": { + "types": "./dist/server/stdio.d.ts", + "import": "./dist/server/stdio.mjs" + }, + "./server/stdio": { + "types": "./dist/server/stdio.d.ts", + "import": "./dist/server/stdio.mjs" + }, + "./server/streamableHttp.js": { + "types": "./dist/server/streamableHttp.d.ts", + "import": "./dist/server/streamableHttp.mjs" + }, + "./server/streamableHttp": { + "types": "./dist/server/streamableHttp.d.ts", + "import": "./dist/server/streamableHttp.mjs" + }, + "./server/auth/types.js": { + "types": "./dist/server/auth/types.d.ts", + "import": "./dist/server/auth/types.mjs" + }, + "./server/auth/types": { + "types": "./dist/server/auth/types.d.ts", + "import": "./dist/server/auth/types.mjs" + }, + "./server/auth/errors.js": { + "types": "./dist/server/auth/errors.d.ts", + "import": "./dist/server/auth/errors.mjs" + }, + "./server/auth/errors": { + "types": "./dist/server/auth/errors.d.ts", + "import": "./dist/server/auth/errors.mjs" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.mjs" + }, + "./client/index.js": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.mjs" + }, + "./client/index": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.mjs" + }, + "./client/stdio.js": { + "types": "./dist/client/stdio.d.ts", + "import": "./dist/client/stdio.mjs" + }, + "./client/stdio": { + "types": "./dist/client/stdio.d.ts", + "import": "./dist/client/stdio.mjs" + }, + "./client/streamableHttp.js": { + "types": "./dist/client/streamableHttp.d.ts", + "import": "./dist/client/streamableHttp.mjs" + }, + "./client/streamableHttp": { + "types": "./dist/client/streamableHttp.d.ts", + "import": "./dist/client/streamableHttp.mjs" + }, + "./client/sse.js": { + "types": "./dist/client/sse.d.ts", + "import": "./dist/client/sse.mjs" + }, + "./client/sse": { + "types": "./dist/client/sse.d.ts", + "import": "./dist/client/sse.mjs" + }, + "./client/auth.js": { + "types": "./dist/client/auth.d.ts", + "import": "./dist/client/auth.mjs" + }, + "./client/auth": { + "types": "./dist/client/auth.d.ts", + "import": "./dist/client/auth.mjs" + }, + "./shared/protocol.js": { + "types": "./dist/shared/protocol.d.ts", + "import": "./dist/shared/protocol.mjs" + }, + "./shared/protocol": { + "types": "./dist/shared/protocol.d.ts", + "import": "./dist/shared/protocol.mjs" + }, + "./shared/transport.js": { + "types": "./dist/shared/transport.d.ts", + "import": "./dist/shared/transport.mjs" + }, + "./shared/transport": { + "types": "./dist/shared/transport.d.ts", + "import": "./dist/shared/transport.mjs" + }, + "./shared/auth.js": { + "types": "./dist/shared/auth.d.ts", + "import": "./dist/shared/auth.mjs" + }, + "./shared/auth": { + "types": "./dist/shared/auth.d.ts", + "import": "./dist/shared/auth.mjs" + }, + "./server/auth/middleware/bearerAuth.js": { + "types": "./dist/server/auth/middleware/bearerAuth.d.ts", + "import": "./dist/server/auth/middleware/bearerAuth.mjs" + }, + "./server/auth/middleware/bearerAuth": { + "types": "./dist/server/auth/middleware/bearerAuth.d.ts", + "import": "./dist/server/auth/middleware/bearerAuth.mjs" + }, + "./server/auth/router.js": { + "types": "./dist/server/auth/router.d.ts", + "import": "./dist/server/auth/router.mjs" + }, + "./server/auth/router": { + "types": "./dist/server/auth/router.d.ts", + "import": "./dist/server/auth/router.mjs" + }, + "./server/auth/provider.js": { + "types": "./dist/server/auth/provider.d.ts", + "import": "./dist/server/auth/provider.mjs" + }, + "./server/auth/provider": { + "types": "./dist/server/auth/provider.d.ts", + "import": "./dist/server/auth/provider.mjs" + }, + "./server/auth/clients.js": { + "types": "./dist/server/auth/clients.d.ts", + "import": "./dist/server/auth/clients.mjs" + }, + "./server/auth/clients": { + "types": "./dist/server/auth/clients.d.ts", + "import": "./dist/server/auth/clients.mjs" + }, + "./inMemory.js": { + "types": "./dist/inMemory.d.ts", + "import": "./dist/inMemory.mjs" + }, + "./inMemory": { + "types": "./dist/inMemory.d.ts", + "import": "./dist/inMemory.mjs" + }, + "./server/completable.js": { + "types": "./dist/server/completable.d.ts", + "import": "./dist/server/completable.mjs" + }, + "./server/completable": { + "types": "./dist/server/completable.d.ts", + "import": "./dist/server/completable.mjs" + }, + "./server/sse.js": { + "types": "./dist/server/sse.d.ts", + "import": "./dist/server/sse.mjs" + }, + "./server/sse": { + "types": "./dist/server/sse.d.ts", + "import": "./dist/server/sse.mjs" + }, + "./experimental/tasks": { + "types": "./dist/experimental/tasks.d.ts", + "import": "./dist/experimental/tasks.mjs" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.mjs" + }, + "./server.js": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.mjs" + }, + "./client.js": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.mjs" + }, + "./server/webStandardStreamableHttp.js": { + "types": "./dist/server/webStandardStreamableHttp.d.ts", + "import": "./dist/server/webStandardStreamableHttp.mjs" + }, + "./server/webStandardStreamableHttp": { + "types": "./dist/server/webStandardStreamableHttp.d.ts", + "import": "./dist/server/webStandardStreamableHttp.mjs" + }, + "./shared/stdio.js": { + "types": "./dist/shared/stdio.d.ts", + "import": "./dist/shared/stdio.mjs" + }, + "./shared/stdio": { + "types": "./dist/shared/stdio.d.ts", + "import": "./dist/shared/stdio.mjs" + }, + "./validation/types.js": { + "types": "./dist/validation/types.d.ts", + "import": "./dist/validation/types.mjs" + }, + "./validation/types": { + "types": "./dist/validation/types.d.ts", + "import": "./dist/validation/types.mjs" + }, + "./validation/cfworker-provider.js": { + "types": "./dist/validation/cfworker-provider.d.ts", + "import": "./dist/validation/cfworker-provider.mjs" + }, + "./validation/cfworker-provider": { + "types": "./dist/validation/cfworker-provider.d.ts", + "import": "./dist/validation/cfworker-provider.mjs" + }, + "./validation/ajv-provider.js": { + "types": "./dist/validation/ajv-provider.d.ts", + "import": "./dist/validation/ajv-provider.mjs" + }, + "./validation/ajv-provider": { + "types": "./dist/validation/ajv-provider.d.ts", + "import": "./dist/validation/ajv-provider.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown && tsc -p tsconfig.build.json", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "prepack": "pnpm run build" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0", + "hono": "*" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "hono": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "vitest": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "typesVersions": { + "*": { + "*.js": [ + "dist/*.d.ts" + ], + "*": [ + "dist/*.d.ts", + "dist/*/index.d.ts" + ] + } + } +} diff --git a/packages/sdk/src/client/auth.ts b/packages/sdk/src/client/auth.ts new file mode 100644 index 000000000..55eaaedf6 --- /dev/null +++ b/packages/sdk/src/client/auth.ts @@ -0,0 +1 @@ +export * from '@modelcontextprotocol/client'; diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts new file mode 100644 index 000000000..55eaaedf6 --- /dev/null +++ b/packages/sdk/src/client/index.ts @@ -0,0 +1 @@ +export * from '@modelcontextprotocol/client'; diff --git a/packages/sdk/src/client/sse.ts b/packages/sdk/src/client/sse.ts new file mode 100644 index 000000000..de4e3b56e --- /dev/null +++ b/packages/sdk/src/client/sse.ts @@ -0,0 +1 @@ +export { SSEClientTransport, type SSEClientTransportOptions, SseError } from '@modelcontextprotocol/client'; diff --git a/packages/sdk/src/client/stdio.ts b/packages/sdk/src/client/stdio.ts new file mode 100644 index 000000000..3b7c7397d --- /dev/null +++ b/packages/sdk/src/client/stdio.ts @@ -0,0 +1,2 @@ +export type { StdioServerParameters } from '@modelcontextprotocol/client'; +export { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client'; diff --git a/packages/sdk/src/client/streamableHttp.ts b/packages/sdk/src/client/streamableHttp.ts new file mode 100644 index 000000000..f4dd1b007 --- /dev/null +++ b/packages/sdk/src/client/streamableHttp.ts @@ -0,0 +1,6 @@ +export { + type StartSSEOptions, + StreamableHTTPClientTransport, + type StreamableHTTPClientTransportOptions, + type StreamableHTTPReconnectionOptions +} from '@modelcontextprotocol/client'; diff --git a/packages/sdk/src/experimental/tasks.ts b/packages/sdk/src/experimental/tasks.ts new file mode 100644 index 000000000..cf883d266 --- /dev/null +++ b/packages/sdk/src/experimental/tasks.ts @@ -0,0 +1,5 @@ +// v1 compat: `@modelcontextprotocol/sdk/experimental/tasks` +// Re-exports the full server surface (task stores, handlers, and related types +// are scattered between core/public and server/experimental/tasks; the root +// barrel includes both). +export * from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/inMemory.ts b/packages/sdk/src/inMemory.ts new file mode 100644 index 000000000..7f2e9c6be --- /dev/null +++ b/packages/sdk/src/inMemory.ts @@ -0,0 +1 @@ +export { InMemoryTransport } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 000000000..71126956f --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,90 @@ +// Root barrel for @modelcontextprotocol/sdk — the everything package. +// +// Re-exports the full public surface of the server, client, and node packages +// so consumers can `import { McpServer, Client, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk'` +// without choosing a sub-package. +// +// Bundle-sensitive consumers (browser, Workers) should import from +// @modelcontextprotocol/client or @modelcontextprotocol/server directly instead. + +// Server gives us all server-specific exports + the entire core/public surface +// (spec types, error classes, transport interface, constants, guards). +export * from '@modelcontextprotocol/server'; + +// Node middleware — explicit named exports only. Not `export *`, because the +// node package re-exports core types from server and `export *` from both +// packages would collide on overlapping symbols (TS2308). +export { NodeStreamableHTTPServerTransport, type StreamableHTTPServerTransportOptions } from '@modelcontextprotocol/node'; +/** @deprecated Renamed to {@linkcode NodeStreamableHTTPServerTransport}. */ +export { NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport } from '@modelcontextprotocol/node'; + +// Client-specific exports only — NOT `export *`, because client also re-exports +// core/public and the duplicate runtime-value identities (each package bundles +// core separately) trigger TS2308. core/public is already covered by server above. +export type { + AddClientAuthentication, + AssertionCallback, + AuthProvider, + AuthResult, + ClientAuthMethod, + ClientCredentialsProviderOptions, + ClientOptions, + CrossAppAccessContext, + CrossAppAccessProviderOptions, + DiscoverAndRequestJwtAuthGrantOptions, + JwtAuthGrantResult, + LoggingOptions, + Middleware, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthServerInfo, + PrivateKeyJwtProviderOptions, + ReconnectionScheduler, + RequestJwtAuthGrantOptions, + RequestLogger, + SSEClientTransportOptions, + StartSSEOptions, + StaticPrivateKeyJwtProviderOptions, + StreamableHTTPClientTransportOptions, + StreamableHTTPReconnectionOptions +} from '@modelcontextprotocol/client'; +export { + applyMiddlewares, + auth, + buildDiscoveryUrls, + Client, + ClientCredentialsProvider, + createMiddleware, + createPrivateKeyJwtAuth, + CrossAppAccessProvider, + discoverAndRequestJwtAuthGrant, + discoverAuthorizationServerMetadata, + discoverOAuthMetadata, + discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, + exchangeAuthorization, + exchangeJwtAuthGrant, + ExperimentalClientTasks, + extractResourceMetadataUrl, + extractWWWAuthenticateParams, + fetchToken, + getSupportedElicitationModes, + isHttpsUrl, + parseErrorResponse, + prepareAuthorizationCodeRequest, + PrivateKeyJwtProvider, + refreshAuthorization, + registerClient, + requestJwtAuthorizationGrant, + selectClientAuthMethod, + selectResourceURL, + SSEClientTransport, + SseError, + startAuthorization, + StaticPrivateKeyJwtProvider, + StreamableHTTPClientTransport, + UnauthorizedError, + validateClientMetadataUrl, + withLogging, + withOAuth +} from '@modelcontextprotocol/client'; diff --git a/packages/sdk/src/server/auth/clients.ts b/packages/sdk/src/server/auth/clients.ts new file mode 100644 index 000000000..a40530585 --- /dev/null +++ b/packages/sdk/src/server/auth/clients.ts @@ -0,0 +1,3 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/auth/clients.js` +// OAuthRegisteredClientsStore was removed in v2. Stub keeps the module path resolvable. +export {}; diff --git a/packages/sdk/src/server/auth/errors.ts b/packages/sdk/src/server/auth/errors.ts new file mode 100644 index 000000000..9c39916aa --- /dev/null +++ b/packages/sdk/src/server/auth/errors.ts @@ -0,0 +1,2 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/auth/errors.js` +export { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/auth/middleware/bearerAuth.ts b/packages/sdk/src/server/auth/middleware/bearerAuth.ts new file mode 100644 index 000000000..e48579f08 --- /dev/null +++ b/packages/sdk/src/server/auth/middleware/bearerAuth.ts @@ -0,0 +1,3 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js` +// requireBearerAuth was removed in v2. Stub keeps the module path resolvable. +export {}; diff --git a/packages/sdk/src/server/auth/provider.ts b/packages/sdk/src/server/auth/provider.ts new file mode 100644 index 000000000..ec80d89ff --- /dev/null +++ b/packages/sdk/src/server/auth/provider.ts @@ -0,0 +1,3 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/auth/provider.js` +// OAuthServerProvider was removed in v2. Stub keeps the module path resolvable. +export {}; diff --git a/packages/sdk/src/server/auth/router.ts b/packages/sdk/src/server/auth/router.ts new file mode 100644 index 000000000..83b68086d --- /dev/null +++ b/packages/sdk/src/server/auth/router.ts @@ -0,0 +1,4 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/auth/router.js` +// The Express OAuth server router was removed in v2. This stub keeps the +// module path resolvable; symbols are not provided. +export {}; diff --git a/packages/sdk/src/server/auth/types.ts b/packages/sdk/src/server/auth/types.ts new file mode 100644 index 000000000..f10524f66 --- /dev/null +++ b/packages/sdk/src/server/auth/types.ts @@ -0,0 +1,2 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/auth/types.js` +export type { AuthInfo } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/completable.ts b/packages/sdk/src/server/completable.ts new file mode 100644 index 000000000..84eee372f --- /dev/null +++ b/packages/sdk/src/server/completable.ts @@ -0,0 +1 @@ +export { completable, type CompletableSchema, type CompleteCallback, isCompletable } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts new file mode 100644 index 000000000..6ecbcce01 --- /dev/null +++ b/packages/sdk/src/server/index.ts @@ -0,0 +1 @@ +export * from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/mcp.ts b/packages/sdk/src/server/mcp.ts new file mode 100644 index 000000000..56a5c924d --- /dev/null +++ b/packages/sdk/src/server/mcp.ts @@ -0,0 +1,21 @@ +export { + type AnyToolHandler, + type BaseToolCallback, + completable, + type CompletableSchema, + type CompleteCallback, + type CompleteResourceTemplateCallback, + isCompletable, + type ListResourcesCallback, + McpServer, + type PromptCallback, + type ReadResourceCallback, + type ReadResourceTemplateCallback, + type RegisteredPrompt, + type RegisteredResource, + type RegisteredResourceTemplate, + type RegisteredTool, + type ResourceMetadata, + ResourceTemplate, + type ToolCallback +} from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/sse.ts b/packages/sdk/src/server/sse.ts new file mode 100644 index 000000000..a1de467b6 --- /dev/null +++ b/packages/sdk/src/server/sse.ts @@ -0,0 +1,4 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/sse.js` +// The SSE server transport was removed in v2. Use Streamable HTTP instead. +// This stub keeps the module path resolvable but exports nothing. +export {}; diff --git a/packages/sdk/src/server/stdio.ts b/packages/sdk/src/server/stdio.ts new file mode 100644 index 000000000..9a7f02eee --- /dev/null +++ b/packages/sdk/src/server/stdio.ts @@ -0,0 +1 @@ +export { StdioServerTransport } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/streamableHttp.ts b/packages/sdk/src/server/streamableHttp.ts new file mode 100644 index 000000000..4cfda1b20 --- /dev/null +++ b/packages/sdk/src/server/streamableHttp.ts @@ -0,0 +1,4 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/streamableHttp.js` +export * from '@modelcontextprotocol/node'; +/** @deprecated Renamed to {@link NodeStreamableHTTPServerTransport} and moved to `@modelcontextprotocol/node` in v2. */ +export { NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport } from '@modelcontextprotocol/node'; diff --git a/packages/sdk/src/server/webStandardStreamableHttp.ts b/packages/sdk/src/server/webStandardStreamableHttp.ts new file mode 100644 index 000000000..75870a36b --- /dev/null +++ b/packages/sdk/src/server/webStandardStreamableHttp.ts @@ -0,0 +1,4 @@ +export { + WebStandardStreamableHTTPServerTransport, + type WebStandardStreamableHTTPServerTransportOptions +} from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/zod-compat.ts b/packages/sdk/src/server/zod-compat.ts new file mode 100644 index 000000000..5486bac0e --- /dev/null +++ b/packages/sdk/src/server/zod-compat.ts @@ -0,0 +1,16 @@ +// v1 compat: `@modelcontextprotocol/sdk/server/zod-compat.js` +// v1 unified Zod v3 + v4 types. v2 is Zod v4-only, so these collapse to the +// v4 types. Prefer `StandardSchemaV1` / `StandardSchemaWithJSON` for new code. + +import type * as z from 'zod'; + +/** @deprecated Use `StandardSchemaV1` (any Standard Schema) or a Zod type directly in v2. */ +export type AnySchema = z.core.$ZodType; + +/** @deprecated Use `Record` directly in v2. */ +export type ZodRawShapeCompat = Record; + +/** @deprecated */ +export type AnyObjectSchema = z.core.$ZodObject | AnySchema; + +export type { StandardSchemaV1, StandardSchemaWithJSON } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/shared/auth.ts b/packages/sdk/src/shared/auth.ts new file mode 100644 index 000000000..3c8b49505 --- /dev/null +++ b/packages/sdk/src/shared/auth.ts @@ -0,0 +1,33 @@ +// v1 compat: `@modelcontextprotocol/sdk/shared/auth.js` +export type { + AuthorizationServerMetadata, + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientRegistrationError, + OAuthErrorResponse, + OAuthMetadata, + OAuthProtectedResourceMetadata, + OAuthTokenRevocationRequest, + OAuthTokens, + OpenIdProviderDiscoveryMetadata, + OpenIdProviderMetadata +} from '@modelcontextprotocol/server'; +export { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +export { + IdJagTokenExchangeResponseSchema, + OAuthClientInformationFullSchema, + OAuthClientInformationSchema, + OAuthClientMetadataSchema, + OAuthClientRegistrationErrorSchema, + OAuthErrorResponseSchema, + OAuthMetadataSchema, + OAuthProtectedResourceMetadataSchema, + OAuthTokenRevocationRequestSchema, + OAuthTokensSchema, + OpenIdProviderDiscoveryMetadataSchema, + OpenIdProviderMetadataSchema, + OptionalSafeUrlSchema, + SafeUrlSchema +} from '@modelcontextprotocol/server/zod-schemas'; diff --git a/packages/sdk/src/shared/protocol.ts b/packages/sdk/src/shared/protocol.ts new file mode 100644 index 000000000..bd9b6cdc1 --- /dev/null +++ b/packages/sdk/src/shared/protocol.ts @@ -0,0 +1,15 @@ +// v1 compat: `@modelcontextprotocol/sdk/shared/protocol.js` + +export type { + BaseContext, + ClientContext, + NotificationOptions, + ProtocolOptions, + RequestOptions, + ServerContext +} from '@modelcontextprotocol/server'; +export { DEFAULT_REQUEST_TIMEOUT_MSEC, Protocol } from '@modelcontextprotocol/server'; + +/** @deprecated Use {@link ServerContext} (server handlers) or {@link ClientContext} (client handlers) in v2. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type RequestHandlerExtra<_ReqT = unknown, _NotifT = unknown> = import('@modelcontextprotocol/server').ServerContext; diff --git a/packages/sdk/src/shared/stdio.ts b/packages/sdk/src/shared/stdio.ts new file mode 100644 index 000000000..87ff5ce32 --- /dev/null +++ b/packages/sdk/src/shared/stdio.ts @@ -0,0 +1 @@ +export { deserializeMessage, ReadBuffer, serializeMessage } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/shared/transport.ts b/packages/sdk/src/shared/transport.ts new file mode 100644 index 000000000..2cee788b9 --- /dev/null +++ b/packages/sdk/src/shared/transport.ts @@ -0,0 +1,2 @@ +export type { FetchLike, Transport, TransportSendOptions } from '@modelcontextprotocol/server'; +export { createFetchWithInit } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/stdio.ts b/packages/sdk/src/stdio.ts new file mode 100644 index 000000000..491941f10 --- /dev/null +++ b/packages/sdk/src/stdio.ts @@ -0,0 +1,3 @@ +export type { StdioServerParameters } from '@modelcontextprotocol/client'; +export { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client'; +export { StdioServerTransport } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 000000000..f56f2174d --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,23 @@ +// v1 compat: `@modelcontextprotocol/sdk/types.js` +// In v1 this was the giant types.ts file with all spec types + Zod schemas. +// v2 splits them: spec TypeScript types live in the server barrel (via core/public), +// zod schema constants live at @modelcontextprotocol/server/zod-schemas. + +export * from '@modelcontextprotocol/server/zod-schemas'; +export * from '@modelcontextprotocol/server'; +// Explicit tie-break for symbols both barrels export. +export { fromJsonSchema } from '@modelcontextprotocol/server'; + +/** + * @deprecated Use {@link ResourceTemplateType}. + * + * v1's `types.js` exported the spec-derived ResourceTemplate data type under + * this name. v2 renamed it to `ResourceTemplateType` to avoid clashing with the + * `ResourceTemplate` helper class exported by `@modelcontextprotocol/server`. + */ +export type { ResourceTemplateType as ResourceTemplate } from '@modelcontextprotocol/server'; + +/** @deprecated Use {@link ProtocolError}. */ +export { ProtocolError as McpError } from '@modelcontextprotocol/server'; +/** @deprecated Use {@link ProtocolErrorCode}. */ +export { ProtocolErrorCode as ErrorCode } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/validation/ajv-provider.ts b/packages/sdk/src/validation/ajv-provider.ts new file mode 100644 index 000000000..8f6e0f51e --- /dev/null +++ b/packages/sdk/src/validation/ajv-provider.ts @@ -0,0 +1 @@ +export { AjvJsonSchemaValidator } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/validation/cfworker-provider.ts b/packages/sdk/src/validation/cfworker-provider.ts new file mode 100644 index 000000000..08b23bf34 --- /dev/null +++ b/packages/sdk/src/validation/cfworker-provider.ts @@ -0,0 +1 @@ +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; diff --git a/packages/sdk/src/validation/types.ts b/packages/sdk/src/validation/types.ts new file mode 100644 index 000000000..b83e4cb5a --- /dev/null +++ b/packages/sdk/src/validation/types.ts @@ -0,0 +1 @@ +export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/test/compat.test.ts b/packages/sdk/test/compat.test.ts new file mode 100644 index 000000000..f254e7c41 --- /dev/null +++ b/packages/sdk/test/compat.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; + +import * as serverIndex from '../src/server/index.js'; +import * as serverMcp from '../src/server/mcp.js'; +import * as serverStdio from '../src/server/stdio.js'; +import * as serverSHttp from '../src/server/streamableHttp.js'; +import * as sharedProtocol from '../src/shared/protocol.js'; +import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '../src/types.js'; + +describe('@modelcontextprotocol/sdk meta-package v1 paths', () => { + test('types.js re-exports zod schemas + error aliases', () => { + expect(CallToolRequestSchema).toBeDefined(); + expect(ListToolsRequestSchema).toBeDefined(); + expect(McpError).toBeDefined(); + expect(ErrorCode.MethodNotFound).toBeDefined(); + }); + + test('server/mcp.js exports McpServer', () => { + expect(serverMcp.McpServer).toBeDefined(); + expect(serverMcp.ResourceTemplate).toBeDefined(); + }); + + test('server/index.js exports Server (alias)', () => { + expect(serverIndex.Server).toBeDefined(); + }); + + test('server/stdio.js exports StdioServerTransport', () => { + expect(serverStdio.StdioServerTransport).toBeDefined(); + }); + + test('server/streamableHttp.js exports the v1 alias', () => { + expect(serverSHttp.StreamableHTTPServerTransport).toBeDefined(); + expect(serverSHttp.NodeStreamableHTTPServerTransport).toBeDefined(); + }); + + test('shared/protocol.js exports Protocol + RequestHandlerExtra type alias', () => { + expect(sharedProtocol.Protocol).toBeDefined(); + expect(sharedProtocol.DEFAULT_REQUEST_TIMEOUT_MSEC).toBeGreaterThan(0); + }); +}); diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json new file mode 100644 index 000000000..ad0d8e637 --- /dev/null +++ b/packages/sdk/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "src", + "incremental": false, + "paths": {} + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 000000000..5a30e6c18 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/client/stdio.ts"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/server/stdio.ts"], + "@modelcontextprotocol/server/zod-schemas": ["./node_modules/@modelcontextprotocol/server/src/zodSchemas.ts"], + "@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"], + "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], + "@modelcontextprotocol/node/sse": ["./node_modules/@modelcontextprotocol/node/src/sse.ts"], + "@modelcontextprotocol/server-auth-legacy": ["./node_modules/@modelcontextprotocol/server-auth-legacy/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"] + } + } +} diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts new file mode 100644 index 000000000..8c6595654 --- /dev/null +++ b/packages/sdk/tsdown.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: false, + entry: [ + 'src/index.ts', + 'src/stdio.ts', + 'src/types.ts', + 'src/inMemory.ts', + 'src/experimental/tasks.ts', + 'src/server/index.ts', + 'src/server/mcp.ts', + 'src/server/zod-compat.ts', + 'src/server/completable.ts', + 'src/server/sse.ts', + 'src/server/stdio.ts', + 'src/server/streamableHttp.ts', + 'src/server/auth/types.ts', + 'src/server/auth/errors.ts', + 'src/server/auth/middleware/bearerAuth.ts', + 'src/server/auth/router.ts', + 'src/server/auth/provider.ts', + 'src/server/auth/clients.ts', + 'src/client/index.ts', + 'src/client/stdio.ts', + 'src/client/streamableHttp.ts', + 'src/client/sse.ts', + 'src/client/auth.ts', + 'src/shared/protocol.ts', + 'src/shared/transport.ts', + 'src/shared/auth.ts', + 'src/shared/stdio.ts', + 'src/server/webStandardStreamableHttp.ts', + 'src/validation/types.ts', + 'src/validation/cfworker-provider.ts', + 'src/validation/ajv-provider.ts' + ], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + dts: false, + external: [/^@modelcontextprotocol\//] +}); diff --git a/packages/sdk/vitest.config.js b/packages/sdk/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/sdk/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/server/package.json b/packages/server/package.json index b40135ec9..b2f0dc6ee 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,6 +24,10 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./zod-schemas": { + "types": "./dist/zodSchemas.d.mts", + "import": "./dist/zodSchemas.mjs" + }, "./validators/cf-worker": { "types": "./dist/validators/cfWorker.d.mts", "import": "./dist/validators/cfWorker.mjs" diff --git a/packages/server/src/zodSchemas.ts b/packages/server/src/zodSchemas.ts new file mode 100644 index 000000000..d8f1383f8 --- /dev/null +++ b/packages/server/src/zodSchemas.ts @@ -0,0 +1,12 @@ +// v1-compat subpath: `@modelcontextprotocol/server/zod-schemas` +// +// Re-exports the Zod schema constants (`*Schema`) that v1's `types.js` +// exposed alongside the spec types. v2 keeps these out of the main barrel +// (they pull in zod at runtime); this subpath lets the `@modelcontextprotocol/sdk` +// meta-package's `types.js` shim restore them for v1 callers of +// `setRequestHandler(SomeRequestSchema, handler)`. +// +// Source of truth: core's internal types/schemas.ts + shared/auth.ts. + +// eslint-disable-next-line import/export -- intentional bulk re-export of internal zod constants +export * from '@modelcontextprotocol/core'; diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index fb0cd8a93..08676f626 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ failOnWarn: 'ci-only', // 1. Entry Points // Directly matches package.json include/exclude globs - entry: ['src/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/validators/cfWorker.ts'], + entry: ['src/index.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/validators/cfWorker.ts', 'src/zodSchemas.ts'], // 2. Output Configuration format: ['esm'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 899586750..4f6c62e7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -862,6 +862,61 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/sdk: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../client + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:../middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../server + express: + specifier: ^4.18.0 || ^5.0.0 + version: 5.2.1 + hono: + specifier: '*' + version: 4.12.9 + devDependencies: + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/test-helpers': + specifier: workspace:^ + version: link:../../test/helpers + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + packages/server: dependencies: zod: From 083ebde56ae94d193613d67ca64a653f1e6148b2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 16:46:41 +0000 Subject: [PATCH 15/55] =?UTF-8?q?feat:=20meta-package=20parity=20fixes=20?= =?UTF-8?q?=E2=80=94=20sdk=20collision,=20server-auth-legacy,=20Streamable?= =?UTF-8?q?HTTPError,=20SSEServerTransport=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Root package renamed sdk-workspace (fixes tarball collision) - packages/server-auth-legacy/ from BC-carve E2 (+142 tests) - StreamableHTTPError deprecated alias in client - sdk auth stubs re-export from server-auth-legacy - SSEServerTransport deprecated alias to NodeStreamableHTTPServerTransport - typed setRequestHandler(schema) overload Bump-only: 5/14 -> 9/14 at zero (browserbase, shortcut, console, mcp-ext-apps-host now 0). 1685 tests, typecheck/build clean. --- package.json | 2 +- packages/client/src/client/streamableHttp.ts | 12 + packages/client/src/index.ts | 2 +- packages/sdk/package.json | 3 +- packages/sdk/src/client/streamableHttp.ts | 1 + packages/sdk/src/server/auth/clients.ts | 3 +- packages/sdk/src/server/auth/errors.ts | 23 +- .../src/server/auth/middleware/bearerAuth.ts | 3 +- packages/sdk/src/server/auth/provider.ts | 3 +- packages/sdk/src/server/auth/router.ts | 11 +- packages/sdk/src/server/sse.ts | 9 +- packages/sdk/src/types.ts | 15 + packages/server-auth-legacy/README.md | 22 + packages/server-auth-legacy/eslint.config.mjs | 12 + packages/server-auth-legacy/package.json | 83 +++ packages/server-auth-legacy/src/clients.ts | 22 + packages/server-auth-legacy/src/errors.ts | 212 ++++++++ .../src/handlers/authorize.ts | 203 +++++++ .../src/handlers/metadata.ts | 21 + .../src/handlers/register.ts | 124 +++++ .../server-auth-legacy/src/handlers/revoke.ts | 82 +++ .../server-auth-legacy/src/handlers/token.ts | 160 ++++++ packages/server-auth-legacy/src/index.ts | 34 ++ .../src/middleware/allowedMethods.ts | 21 + .../src/middleware/bearerAuth.ts | 104 ++++ .../src/middleware/clientAuth.ts | 65 +++ packages/server-auth-legacy/src/provider.ts | 84 +++ .../src/providers/proxyProvider.ts | 233 ++++++++ packages/server-auth-legacy/src/router.ts | 246 +++++++++ packages/server-auth-legacy/src/types.ts | 8 + .../test/handlers/authorize.test.ts | 400 ++++++++++++++ .../test/handlers/metadata.test.ts | 78 +++ .../test/handlers/register.test.ts | 272 ++++++++++ .../test/handlers/revoke.test.ts | 231 ++++++++ .../test/handlers/token.test.ts | 479 +++++++++++++++++ .../server-auth-legacy/test/helpers/http.ts | 56 ++ .../server-auth-legacy/test/index.test.ts | 70 +++ .../test/middleware/allowedMethods.test.ts | 75 +++ .../test/middleware/bearerAuth.test.ts | 501 ++++++++++++++++++ .../test/middleware/clientAuth.test.ts | 132 +++++ .../test/providers/proxyProvider.test.ts | 344 ++++++++++++ .../server-auth-legacy/test/router.test.ts | 463 ++++++++++++++++ packages/server-auth-legacy/tsconfig.json | 12 + packages/server-auth-legacy/tsdown.config.ts | 22 + packages/server-auth-legacy/typedoc.json | 10 + packages/server-auth-legacy/vitest.config.js | 3 + packages/server/src/server/mcpServer.ts | 10 +- pnpm-lock.yaml | 92 +++- 48 files changed, 5047 insertions(+), 26 deletions(-) create mode 100644 packages/server-auth-legacy/README.md create mode 100644 packages/server-auth-legacy/eslint.config.mjs create mode 100644 packages/server-auth-legacy/package.json create mode 100644 packages/server-auth-legacy/src/clients.ts create mode 100644 packages/server-auth-legacy/src/errors.ts create mode 100644 packages/server-auth-legacy/src/handlers/authorize.ts create mode 100644 packages/server-auth-legacy/src/handlers/metadata.ts create mode 100644 packages/server-auth-legacy/src/handlers/register.ts create mode 100644 packages/server-auth-legacy/src/handlers/revoke.ts create mode 100644 packages/server-auth-legacy/src/handlers/token.ts create mode 100644 packages/server-auth-legacy/src/index.ts create mode 100644 packages/server-auth-legacy/src/middleware/allowedMethods.ts create mode 100644 packages/server-auth-legacy/src/middleware/bearerAuth.ts create mode 100644 packages/server-auth-legacy/src/middleware/clientAuth.ts create mode 100644 packages/server-auth-legacy/src/provider.ts create mode 100644 packages/server-auth-legacy/src/providers/proxyProvider.ts create mode 100644 packages/server-auth-legacy/src/router.ts create mode 100644 packages/server-auth-legacy/src/types.ts create mode 100644 packages/server-auth-legacy/test/handlers/authorize.test.ts create mode 100644 packages/server-auth-legacy/test/handlers/metadata.test.ts create mode 100644 packages/server-auth-legacy/test/handlers/register.test.ts create mode 100644 packages/server-auth-legacy/test/handlers/revoke.test.ts create mode 100644 packages/server-auth-legacy/test/handlers/token.test.ts create mode 100644 packages/server-auth-legacy/test/helpers/http.ts create mode 100644 packages/server-auth-legacy/test/index.test.ts create mode 100644 packages/server-auth-legacy/test/middleware/allowedMethods.test.ts create mode 100644 packages/server-auth-legacy/test/middleware/bearerAuth.test.ts create mode 100644 packages/server-auth-legacy/test/middleware/clientAuth.test.ts create mode 100644 packages/server-auth-legacy/test/providers/proxyProvider.test.ts create mode 100644 packages/server-auth-legacy/test/router.test.ts create mode 100644 packages/server-auth-legacy/tsconfig.json create mode 100644 packages/server-auth-legacy/tsdown.config.ts create mode 100644 packages/server-auth-legacy/typedoc.json create mode 100644 packages/server-auth-legacy/vitest.config.js diff --git a/package.json b/package.json index a2cb93f62..98b3da976 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@modelcontextprotocol/sdk", + "name": "@modelcontextprotocol/sdk-workspace", "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cd643c96d..6c27d9424 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -17,6 +17,18 @@ import { EventSourceParserStream } from 'eventsource-parser/stream'; import type { AuthProvider, OAuthClientProvider } from './auth.js'; import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js'; +/** + * @deprecated Use {@linkcode SdkError} with {@linkcode SdkErrorCode}. Kept for v1 import compatibility. + */ +export class StreamableHTTPError extends SdkError { + constructor( + public readonly statusCode: number | undefined, + message: string + ) { + super(SdkErrorCode.ClientHttpUnexpectedContent, message); + } +} + // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { initialReconnectionDelay: 1000, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 48b79b5ce..cfa977874 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -69,7 +69,7 @@ export type { StreamableHTTPClientTransportOptions, StreamableHTTPReconnectionOptions } from './client/streamableHttp.js'; -export { StreamableHTTPClientTransport } from './client/streamableHttp.js'; +export { StreamableHTTPClientTransport, StreamableHTTPError } from './client/streamableHttp.js'; // experimental exports export { ExperimentalClientTasks } from './experimental/tasks/client.js'; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9a779645f..fc1aec119 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -289,7 +289,8 @@ "dependencies": { "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/node": "workspace:^", - "@modelcontextprotocol/server": "workspace:^" + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/server-auth-legacy": "workspace:^" }, "peerDependencies": { "express": "^4.18.0 || ^5.0.0", diff --git a/packages/sdk/src/client/streamableHttp.ts b/packages/sdk/src/client/streamableHttp.ts index f4dd1b007..0d2d4d14b 100644 --- a/packages/sdk/src/client/streamableHttp.ts +++ b/packages/sdk/src/client/streamableHttp.ts @@ -2,5 +2,6 @@ export { type StartSSEOptions, StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions, + StreamableHTTPError, type StreamableHTTPReconnectionOptions } from '@modelcontextprotocol/client'; diff --git a/packages/sdk/src/server/auth/clients.ts b/packages/sdk/src/server/auth/clients.ts index a40530585..f7916a07a 100644 --- a/packages/sdk/src/server/auth/clients.ts +++ b/packages/sdk/src/server/auth/clients.ts @@ -1,3 +1,2 @@ // v1 compat: `@modelcontextprotocol/sdk/server/auth/clients.js` -// OAuthRegisteredClientsStore was removed in v2. Stub keeps the module path resolvable. -export {}; +export type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/server-auth-legacy'; diff --git a/packages/sdk/src/server/auth/errors.ts b/packages/sdk/src/server/auth/errors.ts index 9c39916aa..68c248ac7 100644 --- a/packages/sdk/src/server/auth/errors.ts +++ b/packages/sdk/src/server/auth/errors.ts @@ -1,2 +1,23 @@ // v1 compat: `@modelcontextprotocol/sdk/server/auth/errors.js` -export { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +export { + AccessDeniedError, + CustomOAuthError, + InsufficientScopeError, + InvalidClientError, + InvalidClientMetadataError, + InvalidGrantError, + InvalidRequestError, + InvalidScopeError, + InvalidTargetError, + InvalidTokenError, + MethodNotAllowedError, + OAuthError, + ServerError, + TemporarilyUnavailableError, + TooManyRequestsError, + UnauthorizedClientError, + UnsupportedGrantTypeError, + UnsupportedResponseTypeError, + UnsupportedTokenTypeError +} from '@modelcontextprotocol/server-auth-legacy'; +export { OAuthErrorCode } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/server/auth/middleware/bearerAuth.ts b/packages/sdk/src/server/auth/middleware/bearerAuth.ts index e48579f08..4cc64c6b5 100644 --- a/packages/sdk/src/server/auth/middleware/bearerAuth.ts +++ b/packages/sdk/src/server/auth/middleware/bearerAuth.ts @@ -1,3 +1,2 @@ // v1 compat: `@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js` -// requireBearerAuth was removed in v2. Stub keeps the module path resolvable. -export {}; +export { type BearerAuthMiddlewareOptions, requireBearerAuth } from '@modelcontextprotocol/server-auth-legacy'; diff --git a/packages/sdk/src/server/auth/provider.ts b/packages/sdk/src/server/auth/provider.ts index ec80d89ff..a6dfaade3 100644 --- a/packages/sdk/src/server/auth/provider.ts +++ b/packages/sdk/src/server/auth/provider.ts @@ -1,3 +1,2 @@ // v1 compat: `@modelcontextprotocol/sdk/server/auth/provider.js` -// OAuthServerProvider was removed in v2. Stub keeps the module path resolvable. -export {}; +export type { AuthorizationParams, OAuthServerProvider, OAuthTokenVerifier } from '@modelcontextprotocol/server-auth-legacy'; diff --git a/packages/sdk/src/server/auth/router.ts b/packages/sdk/src/server/auth/router.ts index 83b68086d..3b0c1d037 100644 --- a/packages/sdk/src/server/auth/router.ts +++ b/packages/sdk/src/server/auth/router.ts @@ -1,4 +1,9 @@ // v1 compat: `@modelcontextprotocol/sdk/server/auth/router.js` -// The Express OAuth server router was removed in v2. This stub keeps the -// module path resolvable; symbols are not provided. -export {}; +export { + type AuthMetadataOptions, + type AuthRouterOptions, + createOAuthMetadata, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + mcpAuthRouter +} from '@modelcontextprotocol/server-auth-legacy'; diff --git a/packages/sdk/src/server/sse.ts b/packages/sdk/src/server/sse.ts index a1de467b6..e81316838 100644 --- a/packages/sdk/src/server/sse.ts +++ b/packages/sdk/src/server/sse.ts @@ -1,4 +1,9 @@ // v1 compat: `@modelcontextprotocol/sdk/server/sse.js` // The SSE server transport was removed in v2. Use Streamable HTTP instead. -// This stub keeps the module path resolvable but exports nothing. -export {}; + +/** + * @deprecated SSE server transport was removed in v2. Use {@link NodeStreamableHTTPServerTransport} + * (from `@modelcontextprotocol/node`) instead. This alias is provided for source-compat only; + * the wire behavior is Streamable HTTP, not legacy SSE. + */ +export { NodeStreamableHTTPServerTransport as SSEServerTransport } from '@modelcontextprotocol/node'; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f56f2174d..7c8c8d4d7 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -7,6 +7,21 @@ export * from '@modelcontextprotocol/server/zod-schemas'; export * from '@modelcontextprotocol/server'; // Explicit tie-break for symbols both barrels export. export { fromJsonSchema } from '@modelcontextprotocol/server'; +// Explicit re-exports of commonly-used spec types (belt-and-suspenders over the +// wildcard above; some d.ts toolchains drop type-only symbols across export-*). +export type { + CallToolResult, + ClientCapabilities, + GetPromptResult, + Implementation, + ListResourcesResult, + ListToolsResult, + Prompt, + ReadResourceResult, + Resource, + ServerCapabilities, + Tool +} from '@modelcontextprotocol/server'; /** * @deprecated Use {@link ResourceTemplateType}. diff --git a/packages/server-auth-legacy/README.md b/packages/server-auth-legacy/README.md new file mode 100644 index 000000000..a21d0b710 --- /dev/null +++ b/packages/server-auth-legacy/README.md @@ -0,0 +1,22 @@ +# @modelcontextprotocol/server-auth-legacy + + +> [!WARNING] +> **Deprecated.** This package is a frozen copy of the v1 SDK's `src/server/auth/` Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, etc.). It exists solely to ease migration from `@modelcontextprotocol/sdk` v1 and will not receive new features or non-critical bug fixes. + +The v2 SDK no longer ships an OAuth Authorization Server implementation. MCP servers are Resource Servers; running your own AS is an anti-pattern for most deployments. + +## Migration + +- **Resource Server glue** (`requireBearerAuth`, `mcpAuthMetadataRouter`, Protected Resource Metadata): use the first-class helpers in `@modelcontextprotocol/express`. +- **Authorization Server**: use a dedicated IdP (Auth0, Keycloak, Okta, etc.) or a purpose-built OAuth library. + +## Usage (legacy) + +```ts +import express from 'express'; +import { mcpAuthRouter, ProxyOAuthServerProvider } from '@modelcontextprotocol/server-auth-legacy'; + +const app = express(); +app.use(mcpAuthRouter({ provider, issuerUrl: new URL('https://example.com') })); +``` diff --git a/packages/server-auth-legacy/eslint.config.mjs b/packages/server-auth-legacy/eslint.config.mjs new file mode 100644 index 000000000..4f034f223 --- /dev/null +++ b/packages/server-auth-legacy/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/core' + } + } +]; diff --git a/packages/server-auth-legacy/package.json b/packages/server-auth-legacy/package.json new file mode 100644 index 000000000..0329a06ca --- /dev/null +++ b/packages/server-auth-legacy/package.json @@ -0,0 +1,83 @@ +{ + "name": "@modelcontextprotocol/server-auth-legacy", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Frozen v1 OAuth Authorization Server helpers (mcpAuthRouter, ProxyOAuthServerProvider) for the Model Context Protocol TypeScript SDK. Deprecated; use a dedicated OAuth server in production.", + "deprecated": "The MCP SDK no longer ships an Authorization Server implementation. This package is a frozen copy of the v1 src/server/auth helpers for migration purposes only and will not receive new features. Use a dedicated OAuth Authorization Server (e.g. an IdP) and the Resource Server helpers in @modelcontextprotocol/express instead.", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "oauth", + "express", + "legacy" + ], + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "cors": "catalog:runtimeServerOnly", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependencies": { + "express": "catalog:runtimeServerOnly" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@types/supertest": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "express": "catalog:runtimeServerOnly", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/server-auth-legacy/src/clients.ts b/packages/server-auth-legacy/src/clients.ts new file mode 100644 index 000000000..f6aca1be9 --- /dev/null +++ b/packages/server-auth-legacy/src/clients.ts @@ -0,0 +1,22 @@ +import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; + +/** + * Stores information about registered OAuth clients for this server. + */ +export interface OAuthRegisteredClientsStore { + /** + * Returns information about a registered client, based on its ID. + */ + getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; + + /** + * Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server. + * + * NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well. + * + * If unimplemented, dynamic client registration is unsupported. + */ + registerClient?( + client: Omit + ): OAuthClientInformationFull | Promise; +} diff --git a/packages/server-auth-legacy/src/errors.ts b/packages/server-auth-legacy/src/errors.ts new file mode 100644 index 000000000..eac277779 --- /dev/null +++ b/packages/server-auth-legacy/src/errors.ts @@ -0,0 +1,212 @@ +import type { OAuthErrorResponse } from '@modelcontextprotocol/core'; + +/** + * Base class for all OAuth errors + */ +export class OAuthError extends Error { + static errorCode: string; + + constructor( + message: string, + public readonly errorUri?: string + ) { + super(message); + this.name = this.constructor.name; + } + + /** + * Converts the error to a standard OAuth error response object + */ + toResponseObject(): OAuthErrorResponse { + const response: OAuthErrorResponse = { + error: this.errorCode, + error_description: this.message + }; + + if (this.errorUri) { + response.error_uri = this.errorUri; + } + + return response; + } + + get errorCode(): string { + return (this.constructor as typeof OAuthError).errorCode; + } +} + +/** + * Invalid request error - The request is missing a required parameter, + * includes an invalid parameter value, includes a parameter more than once, + * or is otherwise malformed. + */ +export class InvalidRequestError extends OAuthError { + static override errorCode = 'invalid_request'; +} + +/** + * Invalid client error - Client authentication failed (e.g., unknown client, no client + * authentication included, or unsupported authentication method). + */ +export class InvalidClientError extends OAuthError { + static override errorCode = 'invalid_client'; +} + +/** + * Invalid grant error - The provided authorization grant or refresh token is + * invalid, expired, revoked, does not match the redirection URI used in the + * authorization request, or was issued to another client. + */ +export class InvalidGrantError extends OAuthError { + static override errorCode = 'invalid_grant'; +} + +/** + * Unauthorized client error - The authenticated client is not authorized to use + * this authorization grant type. + */ +export class UnauthorizedClientError extends OAuthError { + static override errorCode = 'unauthorized_client'; +} + +/** + * Unsupported grant type error - The authorization grant type is not supported + * by the authorization server. + */ +export class UnsupportedGrantTypeError extends OAuthError { + static override errorCode = 'unsupported_grant_type'; +} + +/** + * Invalid scope error - The requested scope is invalid, unknown, malformed, or + * exceeds the scope granted by the resource owner. + */ +export class InvalidScopeError extends OAuthError { + static override errorCode = 'invalid_scope'; +} + +/** + * Access denied error - The resource owner or authorization server denied the request. + */ +export class AccessDeniedError extends OAuthError { + static override errorCode = 'access_denied'; +} + +/** + * Server error - The authorization server encountered an unexpected condition + * that prevented it from fulfilling the request. + */ +export class ServerError extends OAuthError { + static override errorCode = 'server_error'; +} + +/** + * Temporarily unavailable error - The authorization server is currently unable to + * handle the request due to a temporary overloading or maintenance of the server. + */ +export class TemporarilyUnavailableError extends OAuthError { + static override errorCode = 'temporarily_unavailable'; +} + +/** + * Unsupported response type error - The authorization server does not support + * obtaining an authorization code using this method. + */ +export class UnsupportedResponseTypeError extends OAuthError { + static override errorCode = 'unsupported_response_type'; +} + +/** + * Unsupported token type error - The authorization server does not support + * the requested token type. + */ +export class UnsupportedTokenTypeError extends OAuthError { + static override errorCode = 'unsupported_token_type'; +} + +/** + * Invalid token error - The access token provided is expired, revoked, malformed, + * or invalid for other reasons. + */ +export class InvalidTokenError extends OAuthError { + static override errorCode = 'invalid_token'; +} + +/** + * Method not allowed error - The HTTP method used is not allowed for this endpoint. + * (Custom, non-standard error) + */ +export class MethodNotAllowedError extends OAuthError { + static override errorCode = 'method_not_allowed'; +} + +/** + * Too many requests error - Rate limit exceeded. + * (Custom, non-standard error based on RFC 6585) + */ +export class TooManyRequestsError extends OAuthError { + static override errorCode = 'too_many_requests'; +} + +/** + * Invalid client metadata error - The client metadata is invalid. + * (Custom error for dynamic client registration - RFC 7591) + */ +export class InvalidClientMetadataError extends OAuthError { + static override errorCode = 'invalid_client_metadata'; +} + +/** + * Insufficient scope error - The request requires higher privileges than provided by the access token. + */ +export class InsufficientScopeError extends OAuthError { + static override errorCode = 'insufficient_scope'; +} + +/** + * Invalid target error - The requested resource is invalid, missing, unknown, or malformed. + * (Custom error for resource indicators - RFC 8707) + */ +export class InvalidTargetError extends OAuthError { + static override errorCode = 'invalid_target'; +} + +/** + * A utility class for defining one-off error codes + */ +export class CustomOAuthError extends OAuthError { + constructor( + private readonly customErrorCode: string, + message: string, + errorUri?: string + ) { + super(message, errorUri); + } + + override get errorCode(): string { + return this.customErrorCode; + } +} + +/** + * A full list of all OAuthErrors, enabling parsing from error responses + */ +export const OAUTH_ERRORS = { + [InvalidRequestError.errorCode]: InvalidRequestError, + [InvalidClientError.errorCode]: InvalidClientError, + [InvalidGrantError.errorCode]: InvalidGrantError, + [UnauthorizedClientError.errorCode]: UnauthorizedClientError, + [UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError, + [InvalidScopeError.errorCode]: InvalidScopeError, + [AccessDeniedError.errorCode]: AccessDeniedError, + [ServerError.errorCode]: ServerError, + [TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError, + [UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError, + [UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError, + [InvalidTokenError.errorCode]: InvalidTokenError, + [MethodNotAllowedError.errorCode]: MethodNotAllowedError, + [TooManyRequestsError.errorCode]: TooManyRequestsError, + [InvalidClientMetadataError.errorCode]: InvalidClientMetadataError, + [InsufficientScopeError.errorCode]: InsufficientScopeError, + [InvalidTargetError.errorCode]: InvalidTargetError +} as const; diff --git a/packages/server-auth-legacy/src/handlers/authorize.ts b/packages/server-auth-legacy/src/handlers/authorize.ts new file mode 100644 index 000000000..3f84bf329 --- /dev/null +++ b/packages/server-auth-legacy/src/handlers/authorize.ts @@ -0,0 +1,203 @@ +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; +import * as z from 'zod/v4'; + +import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js'; +import { allowedMethods } from '../middleware/allowedMethods.js'; +import type { OAuthServerProvider } from '../provider.js'; + +export type AuthorizationHandlerOptions = { + provider: OAuthServerProvider; + /** + * Rate limiting configuration for the authorization endpoint. + * Set to false to disable rate limiting for this endpoint. + */ + rateLimit?: Partial | false; +}; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Validates a requested redirect_uri against a registered one. + * + * Per RFC 8252 §7.3 (OAuth 2.0 for Native Apps), authorization servers MUST + * allow any port for loopback redirect URIs (localhost, 127.0.0.1, [::1]) to + * accommodate native clients that obtain an ephemeral port from the OS. For + * non-loopback URIs, exact match is required. + * + * @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 + */ +export function redirectUriMatches(requested: string, registered: string): boolean { + if (requested === registered) { + return true; + } + let req: URL, reg: URL; + try { + req = new URL(requested); + reg = new URL(registered); + } catch { + return false; + } + // Port relaxation only applies when both URIs target a loopback host. + if (!LOOPBACK_HOSTS.has(req.hostname) || !LOOPBACK_HOSTS.has(reg.hostname)) { + return false; + } + // RFC 8252 relaxes the port only — scheme, host, path, and query must + // still match exactly. Note: hostname must match exactly too (the RFC + // does not allow localhost↔127.0.0.1 cross-matching). + return req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search; +} + +// Parameters that must be validated in order to issue redirects. +const ClientAuthorizationParamsSchema = z.object({ + client_id: z.string(), + redirect_uri: z + .string() + .optional() + .refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' }) +}); + +// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI. +const RequestAuthorizationParamsSchema = z.object({ + response_type: z.literal('code'), + code_challenge: z.string(), + code_challenge_method: z.literal('S256'), + scope: z.string().optional(), + state: z.string().optional(), + resource: z.string().url().optional() +}); + +export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { + // Create a router to apply middleware + const router = express.Router(); + router.use(allowedMethods(['GET', 'POST'])); + router.use(express.urlencoded({ extended: false })); + + // Apply rate limiting unless explicitly disabled + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // 100 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + router.all('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + // In the authorization flow, errors are split into two categories: + // 1. Pre-redirect errors (direct response with 400) + // 2. Post-redirect errors (redirect with error parameters) + + // Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses. + let client_id, client; + let redirect_uri: string; + try { + const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); + if (!result.success) { + throw new InvalidRequestError(result.error.message); + } + + client_id = result.data.client_id; + const requested_redirect_uri = result.data.redirect_uri; + + client = await provider.clientsStore.getClient(client_id); + if (!client) { + throw new InvalidClientError('Invalid client_id'); + } + + if (requested_redirect_uri !== undefined) { + const requested = requested_redirect_uri; + if (!client.redirect_uris.some(registered => redirectUriMatches(requested, registered))) { + throw new InvalidRequestError('Unregistered redirect_uri'); + } + redirect_uri = requested_redirect_uri; + } else if (client.redirect_uris.length === 1) { + redirect_uri = client.redirect_uris[0]!; + } else { + throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs'); + } + } catch (error) { + // Pre-redirect errors - return direct response + // + // These don't need to be JSON encoded, as they'll be displayed in a user + // agent, but OTOH they all represent exceptional situations (arguably, + // "programmer error"), so presenting a nice HTML page doesn't help the + // user anyway. + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + + return; + } + + // Phase 2: Validate other parameters. Any errors here should go into redirect responses. + let state; + try { + // Parse and validate authorization parameters + const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { scope, code_challenge, resource } = parseResult.data; + state = parseResult.data.state; + + // Validate scopes + let requestedScopes: string[] = []; + if (scope !== undefined) { + requestedScopes = scope.split(' '); + } + + // All validation passed, proceed with authorization + await provider.authorize( + client, + { + state, + scopes: requestedScopes, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + resource: resource ? new URL(resource) : undefined + }, + res + ); + } catch (error) { + // Post-redirect errors - redirect with error parameters + if (error instanceof OAuthError) { + res.redirect(302, createErrorRedirect(redirect_uri, error, state)); + } else { + const serverError = new ServerError('Internal Server Error'); + res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); + } + } + }); + + return router; +} + +/** + * Helper function to create redirect URL with error parameters + */ +function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string { + const errorUrl = new URL(redirectUri); + errorUrl.searchParams.set('error', error.errorCode); + errorUrl.searchParams.set('error_description', error.message); + if (error.errorUri) { + errorUrl.searchParams.set('error_uri', error.errorUri); + } + if (state) { + errorUrl.searchParams.set('state', state); + } + return errorUrl.href; +} diff --git a/packages/server-auth-legacy/src/handlers/metadata.ts b/packages/server-auth-legacy/src/handlers/metadata.ts new file mode 100644 index 000000000..529a6e57a --- /dev/null +++ b/packages/server-auth-legacy/src/handlers/metadata.ts @@ -0,0 +1,21 @@ +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core'; +import cors from 'cors'; +import type { RequestHandler } from 'express'; +import express from 'express'; + +import { allowedMethods } from '../middleware/allowedMethods.js'; + +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['GET', 'OPTIONS'])); + router.get('/', (req, res) => { + res.status(200).json(metadata); + }); + + return router; +} diff --git a/packages/server-auth-legacy/src/handlers/register.ts b/packages/server-auth-legacy/src/handlers/register.ts new file mode 100644 index 000000000..6ca5324eb --- /dev/null +++ b/packages/server-auth-legacy/src/handlers/register.ts @@ -0,0 +1,124 @@ +import crypto from 'node:crypto'; + +import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; +import { OAuthClientMetadataSchema } from '@modelcontextprotocol/core'; +import cors from 'cors'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; + +import type { OAuthRegisteredClientsStore } from '../clients.js'; +import { InvalidClientMetadataError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js'; +import { allowedMethods } from '../middleware/allowedMethods.js'; + +export type ClientRegistrationHandlerOptions = { + /** + * A store used to save information about dynamically registered OAuth clients. + */ + clientsStore: OAuthRegisteredClientsStore; + + /** + * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended). + * + * If not set, defaults to 30 days. + */ + clientSecretExpirySeconds?: number; + + /** + * Rate limiting configuration for the client registration endpoint. + * Set to false to disable rate limiting for this endpoint. + * Registration endpoints are particularly sensitive to abuse and should be rate limited. + */ + rateLimit?: Partial | false; + + /** + * Whether to generate a client ID before calling the client registration endpoint. + * + * If not set, defaults to true. + */ + clientIdGeneration?: boolean; +}; + +const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days + +export function clientRegistrationHandler({ + clientsStore, + clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS, + rateLimit: rateLimitConfig, + clientIdGeneration = true +}: ClientRegistrationHandlerOptions): RequestHandler { + if (!clientsStore.registerClient) { + throw new Error('Client registration store does not support registering clients'); + } + + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['POST'])); + router.use(express.json()); + + // Apply rate limiting unless explicitly disabled - stricter limits for registration + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 20, // 20 requests per hour - stricter as registration is sensitive + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + router.post('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + try { + const parseResult = OAuthClientMetadataSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidClientMetadataError(parseResult.error.message); + } + + const clientMetadata = parseResult.data; + const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none'; + + // Generate client credentials + const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex'); + const clientIdIssuedAt = Math.floor(Date.now() / 1000); + + // Calculate client secret expiry time + const clientsDoExpire = clientSecretExpirySeconds > 0; + const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0; + const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime; + + let clientInfo: Omit & { client_id?: string } = { + ...clientMetadata, + client_secret: clientSecret, + client_secret_expires_at: clientSecretExpiresAt + }; + + if (clientIdGeneration) { + clientInfo.client_id = crypto.randomUUID(); + clientInfo.client_id_issued_at = clientIdIssuedAt; + } + + clientInfo = await clientsStore.registerClient!(clientInfo); + res.status(201).json(clientInfo); + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }); + + return router; +} diff --git a/packages/server-auth-legacy/src/handlers/revoke.ts b/packages/server-auth-legacy/src/handlers/revoke.ts new file mode 100644 index 000000000..2a34a4449 --- /dev/null +++ b/packages/server-auth-legacy/src/handlers/revoke.ts @@ -0,0 +1,82 @@ +import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/core'; +import cors from 'cors'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; + +import { InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js'; +import { allowedMethods } from '../middleware/allowedMethods.js'; +import { authenticateClient } from '../middleware/clientAuth.js'; +import type { OAuthServerProvider } from '../provider.js'; + +export type RevocationHandlerOptions = { + provider: OAuthServerProvider; + /** + * Rate limiting configuration for the token revocation endpoint. + * Set to false to disable rate limiting for this endpoint. + */ + rateLimit?: Partial | false; +}; + +export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { + if (!provider.revokeToken) { + throw new Error('Auth provider does not support revoking tokens'); + } + + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['POST'])); + router.use(express.urlencoded({ extended: false })); + + // Apply rate limiting unless explicitly disabled + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + // Authenticate and extract client details + router.use(authenticateClient({ clientsStore: provider.clientsStore })); + + router.post('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + try { + const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const client = req.client; + if (!client) { + // This should never happen + throw new ServerError('Internal Server Error'); + } + + await provider.revokeToken!(client, parseResult.data); + res.status(200).json({}); + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }); + + return router; +} diff --git a/packages/server-auth-legacy/src/handlers/token.ts b/packages/server-auth-legacy/src/handlers/token.ts new file mode 100644 index 000000000..7adfa7809 --- /dev/null +++ b/packages/server-auth-legacy/src/handlers/token.ts @@ -0,0 +1,160 @@ +import cors from 'cors'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; +import { verifyChallenge } from 'pkce-challenge'; +import * as z from 'zod/v4'; + +import { + InvalidGrantError, + InvalidRequestError, + OAuthError, + ServerError, + TooManyRequestsError, + UnsupportedGrantTypeError +} from '../errors.js'; +import { allowedMethods } from '../middleware/allowedMethods.js'; +import { authenticateClient } from '../middleware/clientAuth.js'; +import type { OAuthServerProvider } from '../provider.js'; + +export type TokenHandlerOptions = { + provider: OAuthServerProvider; + /** + * Rate limiting configuration for the token endpoint. + * Set to false to disable rate limiting for this endpoint. + */ + rateLimit?: Partial | false; +}; + +const TokenRequestSchema = z.object({ + grant_type: z.string() +}); + +const AuthorizationCodeGrantSchema = z.object({ + code: z.string(), + code_verifier: z.string(), + redirect_uri: z.string().optional(), + resource: z.string().url().optional() +}); + +const RefreshTokenGrantSchema = z.object({ + refresh_token: z.string(), + scope: z.string().optional(), + resource: z.string().url().optional() +}); + +export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['POST'])); + router.use(express.urlencoded({ extended: false })); + + // Apply rate limiting unless explicitly disabled + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + // Authenticate and extract client details + router.use(authenticateClient({ clientsStore: provider.clientsStore })); + + router.post('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + try { + const parseResult = TokenRequestSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { grant_type } = parseResult.data; + + const client = req.client; + if (!client) { + // This should never happen + throw new ServerError('Internal Server Error'); + } + + switch (grant_type) { + case 'authorization_code': { + const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { code, code_verifier, redirect_uri, resource } = parseResult.data; + + const skipLocalPkceValidation = provider.skipLocalPkceValidation; + + // Perform local PKCE validation unless explicitly skipped + // (e.g. to validate code_verifier in upstream server) + if (!skipLocalPkceValidation) { + const codeChallenge = await provider.challengeForAuthorizationCode(client, code); + if (!(await verifyChallenge(code_verifier, codeChallenge))) { + throw new InvalidGrantError('code_verifier does not match the challenge'); + } + } + + // Passes the code_verifier to the provider if PKCE validation didn't occur locally + const tokens = await provider.exchangeAuthorizationCode( + client, + code, + skipLocalPkceValidation ? code_verifier : undefined, + redirect_uri, + resource ? new URL(resource) : undefined + ); + res.status(200).json(tokens); + break; + } + + case 'refresh_token': { + const parseResult = RefreshTokenGrantSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { refresh_token, scope, resource } = parseResult.data; + + const scopes = scope?.split(' '); + const tokens = await provider.exchangeRefreshToken( + client, + refresh_token, + scopes, + resource ? new URL(resource) : undefined + ); + res.status(200).json(tokens); + break; + } + // Additional auth methods will not be added on the server side of the SDK. + // eslint-disable-next-line unicorn/no-useless-switch-case -- frozen v1 copy; explicit for clarity + case 'client_credentials': + default: { + throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.'); + } + } + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }); + + return router; +} diff --git a/packages/server-auth-legacy/src/index.ts b/packages/server-auth-legacy/src/index.ts new file mode 100644 index 000000000..166384f30 --- /dev/null +++ b/packages/server-auth-legacy/src/index.ts @@ -0,0 +1,34 @@ +/** + * @packageDocumentation + * + * Frozen copy of the v1 SDK's `src/server/auth/` Authorization Server helpers. + * + * @deprecated The MCP SDK no longer ships an Authorization Server implementation. + * This package exists solely to ease migration from `@modelcontextprotocol/sdk` v1 + * and will not receive new features. Use a dedicated OAuth Authorization Server + * (e.g. an IdP) and the Resource Server helpers in `@modelcontextprotocol/express` + * instead. + */ + +export type { OAuthRegisteredClientsStore } from './clients.js'; +export * from './errors.js'; +export type { AuthorizationHandlerOptions } from './handlers/authorize.js'; +export { authorizationHandler, redirectUriMatches } from './handlers/authorize.js'; +export { metadataHandler } from './handlers/metadata.js'; +export type { ClientRegistrationHandlerOptions } from './handlers/register.js'; +export { clientRegistrationHandler } from './handlers/register.js'; +export type { RevocationHandlerOptions } from './handlers/revoke.js'; +export { revocationHandler } from './handlers/revoke.js'; +export type { TokenHandlerOptions } from './handlers/token.js'; +export { tokenHandler } from './handlers/token.js'; +export { allowedMethods } from './middleware/allowedMethods.js'; +export type { BearerAuthMiddlewareOptions } from './middleware/bearerAuth.js'; +export { requireBearerAuth } from './middleware/bearerAuth.js'; +export type { ClientAuthenticationMiddlewareOptions } from './middleware/clientAuth.js'; +export { authenticateClient } from './middleware/clientAuth.js'; +export type { AuthorizationParams, OAuthServerProvider, OAuthTokenVerifier } from './provider.js'; +export type { ProxyEndpoints, ProxyOptions } from './providers/proxyProvider.js'; +export { ProxyOAuthServerProvider } from './providers/proxyProvider.js'; +export type { AuthMetadataOptions, AuthRouterOptions } from './router.js'; +export { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, mcpAuthRouter } from './router.js'; +export type { AuthInfo } from './types.js'; diff --git a/packages/server-auth-legacy/src/middleware/allowedMethods.ts b/packages/server-auth-legacy/src/middleware/allowedMethods.ts new file mode 100644 index 000000000..b24dac3f2 --- /dev/null +++ b/packages/server-auth-legacy/src/middleware/allowedMethods.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from 'express'; + +import { MethodNotAllowedError } from '../errors.js'; + +/** + * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. + * + * @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST']) + * @returns Express middleware that returns a 405 error if method not in allowed list + */ +export function allowedMethods(allowedMethods: string[]): RequestHandler { + return (req, res, next) => { + if (allowedMethods.includes(req.method)) { + next(); + return; + } + + const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`); + res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject()); + }; +} diff --git a/packages/server-auth-legacy/src/middleware/bearerAuth.ts b/packages/server-auth-legacy/src/middleware/bearerAuth.ts new file mode 100644 index 000000000..247a3f152 --- /dev/null +++ b/packages/server-auth-legacy/src/middleware/bearerAuth.ts @@ -0,0 +1,104 @@ +import type { RequestHandler } from 'express'; + +import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../errors.js'; +import type { OAuthTokenVerifier } from '../provider.js'; +import type { AuthInfo } from '../types.js'; + +export type BearerAuthMiddlewareOptions = { + /** + * A provider used to verify tokens. + */ + verifier: OAuthTokenVerifier; + + /** + * Optional scopes that the token must have. + */ + requiredScopes?: string[]; + + /** + * Optional resource metadata URL to include in WWW-Authenticate header. + */ + resourceMetadataUrl?: string; +}; + +declare module 'express-serve-static-core' { + interface Request { + /** + * Information about the validated access token, if the `requireBearerAuth` middleware was used. + */ + auth?: AuthInfo; + } +} + +/** + * Middleware that requires a valid Bearer token in the Authorization header. + * + * This will validate the token with the auth provider and add the resulting auth info to the request object. + * + * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header + * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. + */ +export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { + return async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader) { + throw new InvalidTokenError('Missing Authorization header'); + } + + const [type, token] = authHeader.split(' '); + if (type?.toLowerCase() !== 'bearer' || !token) { + throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); + } + + const authInfo = await verifier.verifyAccessToken(token); + + // Check if token has the required scopes (if any) + if (requiredScopes.length > 0) { + const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope)); + + if (!hasAllScopes) { + throw new InsufficientScopeError('Insufficient scope'); + } + } + + // Check if the token is set to expire or if it is expired + if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) { + throw new InvalidTokenError('Token has no expiration time'); + } else if (authInfo.expiresAt < Date.now() / 1000) { + throw new InvalidTokenError('Token has expired'); + } + + req.auth = authInfo; + next(); + } catch (error) { + // Build WWW-Authenticate header parts + // eslint-disable-next-line unicorn/consistent-function-scoping -- frozen v1 copy; closes over middleware options + const buildWwwAuthHeader = (errorCode: string, message: string): string => { + let header = `Bearer error="${errorCode}", error_description="${message}"`; + if (requiredScopes.length > 0) { + header += `, scope="${requiredScopes.join(' ')}"`; + } + if (resourceMetadataUrl) { + header += `, resource_metadata="${resourceMetadataUrl}"`; + } + return header; + }; + + if (error instanceof InvalidTokenError) { + res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); + res.status(401).json(error.toResponseObject()); + } else if (error instanceof InsufficientScopeError) { + res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); + res.status(403).json(error.toResponseObject()); + } else if (error instanceof ServerError) { + res.status(500).json(error.toResponseObject()); + } else if (error instanceof OAuthError) { + res.status(400).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }; +} diff --git a/packages/server-auth-legacy/src/middleware/clientAuth.ts b/packages/server-auth-legacy/src/middleware/clientAuth.ts new file mode 100644 index 000000000..f3f7a3896 --- /dev/null +++ b/packages/server-auth-legacy/src/middleware/clientAuth.ts @@ -0,0 +1,65 @@ +import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; +import type { RequestHandler } from 'express'; +import * as z from 'zod/v4'; + +import type { OAuthRegisteredClientsStore } from '../clients.js'; +import { InvalidClientError, InvalidRequestError, OAuthError, ServerError } from '../errors.js'; + +export type ClientAuthenticationMiddlewareOptions = { + /** + * A store used to read information about registered OAuth clients. + */ + clientsStore: OAuthRegisteredClientsStore; +}; + +const ClientAuthenticatedRequestSchema = z.object({ + client_id: z.string(), + client_secret: z.string().optional() +}); + +declare module 'express-serve-static-core' { + interface Request { + /** + * The authenticated client for this request, if the `authenticateClient` middleware was used. + */ + client?: OAuthClientInformationFull; + } +} + +export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { + return async (req, res, next) => { + try { + const result = ClientAuthenticatedRequestSchema.safeParse(req.body); + if (!result.success) { + throw new InvalidRequestError(String(result.error)); + } + const { client_id, client_secret } = result.data; + const client = await clientsStore.getClient(client_id); + if (!client) { + throw new InvalidClientError('Invalid client_id'); + } + if (client.client_secret) { + if (!client_secret) { + throw new InvalidClientError('Client secret is required'); + } + if (client.client_secret !== client_secret) { + throw new InvalidClientError('Invalid client_secret'); + } + if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { + throw new InvalidClientError('Client secret has expired'); + } + } + + req.client = client; + next(); + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }; +} diff --git a/packages/server-auth-legacy/src/provider.ts b/packages/server-auth-legacy/src/provider.ts new file mode 100644 index 000000000..528e8d27b --- /dev/null +++ b/packages/server-auth-legacy/src/provider.ts @@ -0,0 +1,84 @@ +import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; +import type { Response } from 'express'; + +import type { OAuthRegisteredClientsStore } from './clients.js'; +import type { AuthInfo } from './types.js'; + +export type AuthorizationParams = { + state?: string; + scopes?: string[]; + codeChallenge: string; + redirectUri: string; + resource?: URL; +}; + +/** + * Implements an end-to-end OAuth server. + */ +export interface OAuthServerProvider { + /** + * A store used to read information about registered OAuth clients. + */ + get clientsStore(): OAuthRegisteredClientsStore; + + /** + * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. + * + * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: + * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. + * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. + */ + authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise; + + /** + * Returns the `codeChallenge` that was used when the indicated authorization began. + */ + challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; + + /** + * Exchanges an authorization code for an access token. + */ + exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise; + + /** + * Exchanges a refresh token for an access token. + */ + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; + + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; + + /** + * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). + * + * If the given token is invalid or already revoked, this method should do nothing. + */ + revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; + + /** + * Whether to skip local PKCE validation. + * + * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. + * + * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. + */ + skipLocalPkceValidation?: boolean; +} + +/** + * Slim implementation useful for token verification + */ +export interface OAuthTokenVerifier { + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; +} diff --git a/packages/server-auth-legacy/src/providers/proxyProvider.ts b/packages/server-auth-legacy/src/providers/proxyProvider.ts new file mode 100644 index 000000000..b469ce6df --- /dev/null +++ b/packages/server-auth-legacy/src/providers/proxyProvider.ts @@ -0,0 +1,233 @@ +import type { FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; +import { OAuthClientInformationFullSchema, OAuthTokensSchema } from '@modelcontextprotocol/core'; +import type { Response } from 'express'; + +import type { OAuthRegisteredClientsStore } from '../clients.js'; +import { ServerError } from '../errors.js'; +import type { AuthorizationParams, OAuthServerProvider } from '../provider.js'; +import type { AuthInfo } from '../types.js'; + +export type ProxyEndpoints = { + authorizationUrl: string; + tokenUrl: string; + revocationUrl?: string; + registrationUrl?: string; +}; + +export type ProxyOptions = { + /** + * Individual endpoint URLs for proxying specific OAuth operations + */ + endpoints: ProxyEndpoints; + + /** + * Function to verify access tokens and return auth info + */ + verifyAccessToken: (token: string) => Promise; + + /** + * Function to fetch client information from the upstream server + */ + getClient: (clientId: string) => Promise; + + /** + * Custom fetch implementation used for all network requests. + */ + fetch?: FetchLike; +}; + +/** + * Implements an OAuth server that proxies requests to another OAuth server. + */ +export class ProxyOAuthServerProvider implements OAuthServerProvider { + protected readonly _endpoints: ProxyEndpoints; + protected readonly _verifyAccessToken: (token: string) => Promise; + protected readonly _getClient: (clientId: string) => Promise; + protected readonly _fetch?: FetchLike; + + skipLocalPkceValidation = true; + + revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; + + constructor(options: ProxyOptions) { + this._endpoints = options.endpoints; + this._verifyAccessToken = options.verifyAccessToken; + this._getClient = options.getClient; + this._fetch = options.fetch; + if (options.endpoints?.revocationUrl) { + this.revokeToken = async (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => { + const revocationUrl = this._endpoints.revocationUrl; + + if (!revocationUrl) { + throw new Error('No revocation endpoint configured'); + } + + const params = new URLSearchParams(); + params.set('token', request.token); + params.set('client_id', client.client_id); + if (client.client_secret) { + params.set('client_secret', client.client_secret); + } + if (request.token_type_hint) { + params.set('token_type_hint', request.token_type_hint); + } + + const response = await (this._fetch ?? fetch)(revocationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + await response.body?.cancel(); + + if (!response.ok) { + throw new ServerError(`Token revocation failed: ${response.status}`); + } + }; + } + } + + get clientsStore(): OAuthRegisteredClientsStore { + const registrationUrl = this._endpoints.registrationUrl; + return { + getClient: this._getClient, + ...(registrationUrl && { + registerClient: async (client: OAuthClientInformationFull) => { + const response = await (this._fetch ?? fetch)(registrationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(client) + }); + + if (!response.ok) { + await response.body?.cancel(); + throw new ServerError(`Client registration failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthClientInformationFullSchema.parse(data); + } + }) + }; + } + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + // Start with required OAuth parameters + const targetUrl = new URL(this._endpoints.authorizationUrl); + const searchParams = new URLSearchParams({ + client_id: client.client_id, + response_type: 'code', + redirect_uri: params.redirectUri, + code_challenge: params.codeChallenge, + code_challenge_method: 'S256' + }); + + // Add optional standard OAuth parameters + if (params.state) searchParams.set('state', params.state); + if (params.scopes?.length) searchParams.set('scope', params.scopes.join(' ')); + if (params.resource) searchParams.set('resource', params.resource.href); + + targetUrl.search = searchParams.toString(); + res.redirect(targetUrl.toString()); + } + + async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise { + // In a proxy setup, we don't store the code challenge ourselves + // Instead, we proxy the token request and let the upstream server validate it + return ''; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise { + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: client.client_id, + code: authorizationCode + }); + + if (client.client_secret) { + params.append('client_secret', client.client_secret); + } + + if (codeVerifier) { + params.append('code_verifier', codeVerifier); + } + + if (redirectUri) { + params.append('redirect_uri', redirectUri); + } + + if (resource) { + params.append('resource', resource.href); + } + + const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + await response.body?.cancel(); + throw new ServerError(`Token exchange failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthTokensSchema.parse(data); + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + resource?: URL + ): Promise { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: refreshToken + }); + + if (client.client_secret) { + params.set('client_secret', client.client_secret); + } + + if (scopes?.length) { + params.set('scope', scopes.join(' ')); + } + + if (resource) { + params.set('resource', resource.href); + } + + const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + await response.body?.cancel(); + throw new ServerError(`Token refresh failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthTokensSchema.parse(data); + } + + async verifyAccessToken(token: string): Promise { + return this._verifyAccessToken(token); + } +} diff --git a/packages/server-auth-legacy/src/router.ts b/packages/server-auth-legacy/src/router.ts new file mode 100644 index 000000000..ba8b030e0 --- /dev/null +++ b/packages/server-auth-legacy/src/router.ts @@ -0,0 +1,246 @@ +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core'; +import type { RequestHandler } from 'express'; +import express from 'express'; + +import type { AuthorizationHandlerOptions } from './handlers/authorize.js'; +import { authorizationHandler } from './handlers/authorize.js'; +import { metadataHandler } from './handlers/metadata.js'; +import type { ClientRegistrationHandlerOptions } from './handlers/register.js'; +import { clientRegistrationHandler } from './handlers/register.js'; +import type { RevocationHandlerOptions } from './handlers/revoke.js'; +import { revocationHandler } from './handlers/revoke.js'; +import type { TokenHandlerOptions } from './handlers/token.js'; +import { tokenHandler } from './handlers/token.js'; +import type { OAuthServerProvider } from './provider.js'; + +// Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) +const allowInsecureIssuerUrl = + process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1'; +if (allowInsecureIssuerUrl) { + // eslint-disable-next-line no-console + console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.'); +} + +export type AuthRouterOptions = { + /** + * A provider implementing the actual authorization logic for this router. + */ + provider: OAuthServerProvider; + + /** + * The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. + */ + issuerUrl: URL; + + /** + * The base URL of the authorization server to use for the metadata endpoints. + * + * If not provided, the issuer URL will be used as the base URL. + */ + baseUrl?: URL; + + /** + * An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server. + */ + serviceDocumentationUrl?: URL; + + /** + * An optional list of scopes supported by this authorization server + */ + scopesSupported?: string[]; + + /** + * The resource name to be displayed in protected resource metadata + */ + resourceName?: string; + + /** + * The URL of the protected resource (RS) whose metadata we advertise. + * If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS). + */ + resourceServerUrl?: URL; + + // Individual options per route + authorizationOptions?: Omit; + clientRegistrationOptions?: Omit; + revocationOptions?: Omit; + tokenOptions?: Omit; +}; + +const checkIssuerUrl = (issuer: URL): void => { + // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing + if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) { + throw new Error('Issuer URL must be HTTPS'); + } + if (issuer.hash) { + throw new Error(`Issuer URL must not have a fragment: ${issuer}`); + } + if (issuer.search) { + throw new Error(`Issuer URL must not have a query string: ${issuer}`); + } +}; + +export const createOAuthMetadata = (options: { + provider: OAuthServerProvider; + issuerUrl: URL; + baseUrl?: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; +}): OAuthMetadata => { + const issuer = options.issuerUrl; + const baseUrl = options.baseUrl; + + checkIssuerUrl(issuer); + + const authorization_endpoint = '/authorize'; + const token_endpoint = '/token'; + const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined; + const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined; + + const metadata: OAuthMetadata = { + issuer: issuer.href, + service_documentation: options.serviceDocumentationUrl?.href, + + authorization_endpoint: new URL(authorization_endpoint, baseUrl || issuer).href, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + + token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + grant_types_supported: ['authorization_code', 'refresh_token'], + + scopes_supported: options.scopesSupported, + + revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, + revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined, + + registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined + }; + + return metadata; +}; + +/** + * Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported). + * Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. + * Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead. + * + * By default, rate limiting is applied to all endpoints to prevent abuse. + * + * This router MUST be installed at the application root, like so: + * + * const app = express(); + * app.use(mcpAuthRouter(...)); + */ +export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { + const oauthMetadata = createOAuthMetadata(options); + + const router = express.Router(); + + router.use( + new URL(oauthMetadata.authorization_endpoint).pathname, + authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) + ); + + router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions })); + + router.use( + mcpAuthMetadataRouter({ + oauthMetadata, + // Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat) + resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer), + serviceDocumentationUrl: options.serviceDocumentationUrl, + scopesSupported: options.scopesSupported, + resourceName: options.resourceName + }) + ); + + if (oauthMetadata.registration_endpoint) { + router.use( + new URL(oauthMetadata.registration_endpoint).pathname, + clientRegistrationHandler({ + clientsStore: options.provider.clientsStore, + ...options.clientRegistrationOptions + }) + ); + } + + if (oauthMetadata.revocation_endpoint) { + router.use( + new URL(oauthMetadata.revocation_endpoint).pathname, + revocationHandler({ provider: options.provider, ...options.revocationOptions }) + ); + } + + return router; +} + +export type AuthMetadataOptions = { + /** + * OAuth Metadata as would be returned from the authorization server + * this MCP server relies on + */ + oauthMetadata: OAuthMetadata; + + /** + * The url of the MCP server, for use in protected resource metadata + */ + resourceServerUrl: URL; + + /** + * The url for documentation for the MCP server + */ + serviceDocumentationUrl?: URL; + + /** + * An optional list of scopes supported by this MCP server + */ + scopesSupported?: string[]; + + /** + * An optional resource name to display in resource metadata + */ + resourceName?: string; +}; + +export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router { + checkIssuerUrl(new URL(options.oauthMetadata.issuer)); + + const router = express.Router(); + + const protectedResourceMetadata: OAuthProtectedResourceMetadata = { + resource: options.resourceServerUrl.href, + + authorization_servers: [options.oauthMetadata.issuer], + + scopes_supported: options.scopesSupported, + resource_name: options.resourceName, + resource_documentation: options.serviceDocumentationUrl?.href + }; + + // Serve PRM at the path-specific URL per RFC 9728 + const rsPath = new URL(options.resourceServerUrl.href).pathname; + router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata)); + + // Always add this for OAuth Authorization Server metadata per RFC 8414 + router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata)); + + return router; +} + +/** + * Helper function to construct the OAuth 2.0 Protected Resource Metadata URL + * from a given server URL. This replaces the path with the standard metadata endpoint. + * + * @param serverUrl - The base URL of the protected resource server + * @returns The URL for the OAuth protected resource metadata endpoint + * + * @example + * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) + * // Returns: 'https://api.example.com/.well-known/oauth-protected-resource/mcp' + */ +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { + const u = new URL(serverUrl.href); + const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : ''; + return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href; +} diff --git a/packages/server-auth-legacy/src/types.ts b/packages/server-auth-legacy/src/types.ts new file mode 100644 index 000000000..b15d371fa --- /dev/null +++ b/packages/server-auth-legacy/src/types.ts @@ -0,0 +1,8 @@ +/** + * Information about a validated access token, provided to request handlers. + * + * Re-exported from `@modelcontextprotocol/core` so that tokens verified by the + * legacy `requireBearerAuth` middleware are structurally compatible with + * the v2 SDK's request-handler context. + */ +export type { AuthInfo } from '@modelcontextprotocol/core'; diff --git a/packages/server-auth-legacy/test/handlers/authorize.test.ts b/packages/server-auth-legacy/test/handlers/authorize.test.ts new file mode 100644 index 000000000..215d79df0 --- /dev/null +++ b/packages/server-auth-legacy/test/handlers/authorize.test.ts @@ -0,0 +1,400 @@ +import { authorizationHandler, AuthorizationHandlerOptions, redirectUriMatches } from '../../src/handlers/authorize.js'; +import { OAuthServerProvider, AuthorizationParams } from '../../src/provider.js'; +import { OAuthRegisteredClientsStore } from '../../src/clients.js'; +import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/core'; +import express, { Response } from 'express'; +import supertest from 'supertest'; +import { AuthInfo } from '../../src/types.js'; +import { InvalidTokenError } from '../../src/errors.js'; + +describe('Authorization Handler', () => { + // Mock client data + const validClient: OAuthClientInformationFull = { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'profile email' + }; + + const multiRedirectClient: OAuthClientInformationFull = { + client_id: 'multi-redirect-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback1', 'https://example.com/callback2'], + scope: 'profile email' + }; + + // Native app client with a portless loopback redirect (e.g., from CIMD / SEP-991) + const loopbackClient: OAuthClientInformationFull = { + client_id: 'loopback-client', + client_secret: 'valid-secret', + redirect_uris: ['http://localhost/callback', 'http://127.0.0.1/callback'], + scope: 'profile email' + }; + + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return validClient; + } else if (clientId === 'multi-redirect-client') { + return multiRedirectClient; + } else if (clientId === 'loopback-client') { + return loopbackClient; + } + return undefined; + } + }; + + // Mock provider + const mockProvider: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + // Mock implementation - redirects to redirectUri with code and state + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); + }, + + async revokeToken(): Promise { + // Do nothing in mock + } + }; + + // Setup express app with handler + let app: express.Express; + let options: AuthorizationHandlerOptions; + + beforeEach(() => { + app = express(); + options = { provider: mockProvider }; + const handler = authorizationHandler(options); + app.use('/authorize', handler); + }); + + describe('HTTP method validation', () => { + it('rejects non-GET/POST methods', async () => { + const response = await supertest(app).put('/authorize').query({ client_id: 'valid-client' }); + + expect(response.status).toBe(405); // Method not allowed response from handler + }); + }); + + describe('Client validation', () => { + it('requires client_id parameter', async () => { + const response = await supertest(app).get('/authorize'); + + expect(response.status).toBe(400); + expect(response.text).toContain('client_id'); + }); + + it('validates that client exists', async () => { + const response = await supertest(app).get('/authorize').query({ client_id: 'nonexistent-client' }); + + expect(response.status).toBe(400); + }); + }); + + describe('Redirect URI validation', () => { + it('uses the only redirect_uri if client has just one and none provided', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.origin + location.pathname).toBe('https://example.com/callback'); + }); + + it('requires redirect_uri if client has multiple', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'multi-redirect-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + + it('validates redirect_uri against client registered URIs', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://malicious.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + + it('accepts valid redirect_uri that client registered with', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.origin + location.pathname).toBe('https://example.com/callback'); + }); + + // RFC 8252 §7.3: authorization servers MUST allow any port for loopback + // redirect URIs. Native apps obtain ephemeral ports from the OS. + it('accepts loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.hostname).toBe('localhost'); + expect(location.port).toBe('53428'); + expect(location.pathname).toBe('/callback'); + }); + + it('accepts 127.0.0.1 loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://127.0.0.1:9000/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + }); + + it('rejects loopback redirect_uri with different path', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/evil', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + + it('does not relax port for non-loopback redirect_uri', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com:8443/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + }); + + describe('redirectUriMatches (RFC 8252 §7.3)', () => { + it('exact match passes', () => { + expect(redirectUriMatches('https://example.com/cb', 'https://example.com/cb')).toBe(true); + }); + + it('loopback: any port matches portless registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost/callback')).toBe(true); + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1/callback')).toBe(true); + expect(redirectUriMatches('http://[::1]:9000/cb', 'http://[::1]/cb')).toBe(true); + }); + + it('loopback: any port matches ported registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost:3118/callback')).toBe(true); + }); + + it('loopback: different path rejected', () => { + expect(redirectUriMatches('http://localhost:53428/evil', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: different scheme rejected', () => { + expect(redirectUriMatches('https://localhost:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: localhost↔127.0.0.1 cross-match rejected', () => { + // RFC 8252 relaxes port only, not host + expect(redirectUriMatches('http://127.0.0.1:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('non-loopback: port must match exactly', () => { + expect(redirectUriMatches('https://example.com:8443/cb', 'https://example.com/cb')).toBe(false); + }); + + it('non-loopback: no relaxation for private IPs', () => { + expect(redirectUriMatches('http://192.168.1.1:8080/cb', 'http://192.168.1.1/cb')).toBe(false); + }); + + it('malformed URIs rejected', () => { + expect(redirectUriMatches('not a url', 'http://localhost/cb')).toBe(false); + expect(redirectUriMatches('http://localhost/cb', 'not a url')).toBe(false); + }); + }); + + describe('Authorization request validation', () => { + it('requires response_type=code', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'token', // invalid - we only support code flow + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.get('error')).toBe('invalid_request'); + }); + + it('requires code_challenge parameter', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge_method: 'S256' + // Missing code_challenge + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.get('error')).toBe('invalid_request'); + }); + + it('requires code_challenge_method=S256', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'plain' // Only S256 is supported + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.get('error')).toBe('invalid_request'); + }); + }); + + describe('Resource parameter validation', () => { + it('propagates resource parameter', async () => { + const mockProviderWithResource = vi.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithResource).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: new URL('https://api.example.com/resource'), + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123' + }), + expect.any(Object) + ); + }); + }); + + describe('Successful authorization', () => { + it('handles successful authorization with all parameters', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + scope: 'profile email', + state: 'xyz789' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.origin + location.pathname).toBe('https://example.com/callback'); + expect(location.searchParams.get('code')).toBe('mock_auth_code'); + expect(location.searchParams.get('state')).toBe('xyz789'); + }); + + it('preserves state parameter in response', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + state: 'state-value-123' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.get('state')).toBe('state-value-123'); + }); + + it('handles POST requests the same as GET', async () => { + const response = await supertest(app).post('/authorize').type('form').send({ + client_id: 'valid-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.has('code')).toBe(true); + }); + }); +}); diff --git a/packages/server-auth-legacy/test/handlers/metadata.test.ts b/packages/server-auth-legacy/test/handlers/metadata.test.ts new file mode 100644 index 000000000..3c89134ae --- /dev/null +++ b/packages/server-auth-legacy/test/handlers/metadata.test.ts @@ -0,0 +1,78 @@ +import { metadataHandler } from '../../src/handlers/metadata.js'; +import { OAuthMetadata } from '@modelcontextprotocol/core'; +import express from 'express'; +import supertest from 'supertest'; + +describe('Metadata Handler', () => { + const exampleMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + revocation_endpoint: 'https://auth.example.com/revoke', + scopes_supported: ['profile', 'email'], + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + code_challenge_methods_supported: ['S256'] + }; + + let app: express.Express; + + beforeEach(() => { + // Setup express app with metadata handler + app = express(); + app.use('/.well-known/oauth-authorization-server', metadataHandler(exampleMetadata)); + }); + + it('requires GET method', async () => { + const response = await supertest(app).post('/.well-known/oauth-authorization-server').send({}); + + expect(response.status).toBe(405); + expect(response.headers.allow).toBe('GET, OPTIONS'); + expect(response.body).toEqual({ + error: 'method_not_allowed', + error_description: 'The method POST is not allowed for this endpoint' + }); + }); + + it('returns the metadata object', async () => { + const response = await supertest(app).get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(exampleMetadata); + }); + + it('includes CORS headers in response', async () => { + const response = await supertest(app).get('/.well-known/oauth-authorization-server').set('Origin', 'https://example.com'); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + + it('supports OPTIONS preflight requests', async () => { + const response = await supertest(app) + .options('/.well-known/oauth-authorization-server') + .set('Origin', 'https://example.com') + .set('Access-Control-Request-Method', 'GET'); + + expect(response.status).toBe(204); + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + + it('works with minimal metadata', async () => { + // Setup a new express app with minimal metadata + const minimalApp = express(); + const minimalMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata)); + + const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(minimalMetadata); + }); +}); diff --git a/packages/server-auth-legacy/test/handlers/register.test.ts b/packages/server-auth-legacy/test/handlers/register.test.ts new file mode 100644 index 000000000..dc3e45023 --- /dev/null +++ b/packages/server-auth-legacy/test/handlers/register.test.ts @@ -0,0 +1,272 @@ +import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from '../../src/handlers/register.js'; +import { OAuthRegisteredClientsStore } from '../../src/clients.js'; +import { OAuthClientInformationFull, OAuthClientMetadata } from '@modelcontextprotocol/core'; +import express from 'express'; +import supertest from 'supertest'; +import { MockInstance } from 'vitest'; + +describe('Client Registration Handler', () => { + // Mock client store with registration support + const mockClientStoreWithRegistration: OAuthRegisteredClientsStore = { + async getClient(_clientId: string): Promise { + return undefined; + }, + + async registerClient(client: OAuthClientInformationFull): Promise { + // Return the client info as-is in the mock + return client; + } + }; + + // Mock client store without registration support + const mockClientStoreWithoutRegistration: OAuthRegisteredClientsStore = { + async getClient(_clientId: string): Promise { + return undefined; + } + // No registerClient method + }; + + describe('Handler creation', () => { + it('throws error if client store does not support registration', () => { + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithoutRegistration + }; + + expect(() => clientRegistrationHandler(options)).toThrow('does not support registering clients'); + }); + + it('creates handler if client store supports registration', () => { + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration + }; + + expect(() => clientRegistrationHandler(options)).not.toThrow(); + }); + }); + + describe('Request handling', () => { + let app: express.Express; + let spyRegisterClient: MockInstance; + + beforeEach(() => { + // Setup express app with registration handler + app = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientSecretExpirySeconds: 86400 // 1 day for testing + }; + + app.use('/register', clientRegistrationHandler(options)); + + // Spy on the registerClient method + spyRegisterClient = vi.spyOn(mockClientStoreWithRegistration, 'registerClient'); + }); + + afterEach(() => { + spyRegisterClient.mockRestore(); + }); + + it('requires POST method', async () => { + const response = await supertest(app) + .get('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(405); + expect(response.headers.allow).toBe('POST'); + expect(response.body).toEqual({ + error: 'method_not_allowed', + error_description: 'The method GET is not allowed for this endpoint' + }); + expect(spyRegisterClient).not.toHaveBeenCalled(); + }); + + it('validates required client metadata', async () => { + const response = await supertest(app).post('/register').send({ + // Missing redirect_uris (required) + client_name: 'Test Client' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client_metadata'); + expect(spyRegisterClient).not.toHaveBeenCalled(); + }); + + it('validates redirect URIs format', async () => { + const response = await supertest(app) + .post('/register') + .send({ + redirect_uris: ['invalid-url'] // Invalid URL format + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client_metadata'); + expect(response.body.error_description).toContain('redirect_uris'); + expect(spyRegisterClient).not.toHaveBeenCalled(); + }); + + it('successfully registers client with minimal metadata', async () => { + const clientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'] + }; + + const response = await supertest(app).post('/register').send(clientMetadata); + + expect(response.status).toBe(201); + + // Verify the generated client information + expect(response.body.client_id).toBeDefined(); + expect(response.body.client_secret).toBeDefined(); + expect(response.body.client_id_issued_at).toBeDefined(); + expect(response.body.client_secret_expires_at).toBeDefined(); + expect(response.body.redirect_uris).toEqual(['https://example.com/callback']); + + // Verify client was registered + expect(spyRegisterClient).toHaveBeenCalledTimes(1); + }); + + it('sets client_secret to undefined for token_endpoint_auth_method=none', async () => { + const clientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'none' + }; + + const response = await supertest(app).post('/register').send(clientMetadata); + + expect(response.status).toBe(201); + expect(response.body.client_secret).toBeUndefined(); + expect(response.body.client_secret_expires_at).toBeUndefined(); + }); + + it('sets client_secret_expires_at for public clients only', async () => { + // Test for public client (token_endpoint_auth_method not 'none') + const publicClientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'client_secret_basic' + }; + + const publicResponse = await supertest(app).post('/register').send(publicClientMetadata); + + expect(publicResponse.status).toBe(201); + expect(publicResponse.body.client_secret).toBeDefined(); + expect(publicResponse.body.client_secret_expires_at).toBeDefined(); + + // Test for non-public client (token_endpoint_auth_method is 'none') + const nonPublicClientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'none' + }; + + const nonPublicResponse = await supertest(app).post('/register').send(nonPublicClientMetadata); + + expect(nonPublicResponse.status).toBe(201); + expect(nonPublicResponse.body.client_secret).toBeUndefined(); + expect(nonPublicResponse.body.client_secret_expires_at).toBeUndefined(); + }); + + it('sets expiry based on clientSecretExpirySeconds', async () => { + // Create handler with custom expiry time + const customApp = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientSecretExpirySeconds: 3600 // 1 hour + }; + + customApp.use('/register', clientRegistrationHandler(options)); + + const response = await supertest(customApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(201); + + // Verify the expiration time (~1 hour from now) + const issuedAt = response.body.client_id_issued_at; + const expiresAt = response.body.client_secret_expires_at; + expect(expiresAt - issuedAt).toBe(3600); + }); + + it('sets no expiry when clientSecretExpirySeconds=0', async () => { + // Create handler with no expiry + const customApp = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientSecretExpirySeconds: 0 // No expiry + }; + + customApp.use('/register', clientRegistrationHandler(options)); + + const response = await supertest(customApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(201); + expect(response.body.client_secret_expires_at).toBe(0); + }); + + it('sets no client_id when clientIdGeneration=false', async () => { + // Create handler with no expiry + const customApp = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientIdGeneration: false + }; + + customApp.use('/register', clientRegistrationHandler(options)); + + const response = await supertest(customApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(201); + expect(response.body.client_id).toBeUndefined(); + expect(response.body.client_id_issued_at).toBeUndefined(); + }); + + it('handles client with all metadata fields', async () => { + const fullClientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'client_secret_basic', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: 'Test Client', + client_uri: 'https://example.com', + logo_uri: 'https://example.com/logo.png', + scope: 'profile email', + contacts: ['dev@example.com'], + tos_uri: 'https://example.com/tos', + policy_uri: 'https://example.com/privacy', + jwks_uri: 'https://example.com/jwks', + software_id: 'test-software', + software_version: '1.0.0' + }; + + const response = await supertest(app).post('/register').send(fullClientMetadata); + + expect(response.status).toBe(201); + + // Verify all metadata was preserved + Object.entries(fullClientMetadata).forEach(([key, value]) => { + expect(response.body[key]).toEqual(value); + }); + }); + + it('includes CORS headers in response', async () => { + const response = await supertest(app) + .post('/register') + .set('Origin', 'https://example.com') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + }); +}); diff --git a/packages/server-auth-legacy/test/handlers/revoke.test.ts b/packages/server-auth-legacy/test/handlers/revoke.test.ts new file mode 100644 index 000000000..d0aba1152 --- /dev/null +++ b/packages/server-auth-legacy/test/handlers/revoke.test.ts @@ -0,0 +1,231 @@ +import { revocationHandler, RevocationHandlerOptions } from '../../src/handlers/revoke.js'; +import { OAuthServerProvider, AuthorizationParams } from '../../src/provider.js'; +import { OAuthRegisteredClientsStore } from '../../src/clients.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; +import express, { Response } from 'express'; +import supertest from 'supertest'; +import { AuthInfo } from '../../src/types.js'; +import { InvalidTokenError } from '../../src/errors.js'; +import { MockInstance } from 'vitest'; + +describe('Revocation Handler', () => { + // Mock client data + const validClient: OAuthClientInformationFull = { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return validClient; + } + return undefined; + } + }; + + // Mock provider with revocation capability + const mockProviderWithRevocation: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + res.redirect('https://example.com/callback?code=mock_auth_code'); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); + }, + + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Success - do nothing in mock + } + }; + + // Mock provider without revocation capability + const mockProviderWithoutRevocation: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + res.redirect('https://example.com/callback?code=mock_auth_code'); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); + } + + // No revokeToken method + }; + + describe('Handler creation', () => { + it('throws error if provider does not support token revocation', () => { + const options: RevocationHandlerOptions = { provider: mockProviderWithoutRevocation }; + expect(() => revocationHandler(options)).toThrow('does not support revoking tokens'); + }); + + it('creates handler if provider supports token revocation', () => { + const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; + expect(() => revocationHandler(options)).not.toThrow(); + }); + }); + + describe('Request handling', () => { + let app: express.Express; + let spyRevokeToken: MockInstance; + + beforeEach(() => { + // Setup express app with revocation handler + app = express(); + const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; + app.use('/revoke', revocationHandler(options)); + + // Spy on the revokeToken method + spyRevokeToken = vi.spyOn(mockProviderWithRevocation, 'revokeToken'); + }); + + afterEach(() => { + spyRevokeToken.mockRestore(); + }); + + it('requires POST method', async () => { + const response = await supertest(app).get('/revoke').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + expect(response.status).toBe(405); + expect(response.headers.allow).toBe('POST'); + expect(response.body).toEqual({ + error: 'method_not_allowed', + error_description: 'The method GET is not allowed for this endpoint' + }); + expect(spyRevokeToken).not.toHaveBeenCalled(); + }); + + it('requires token parameter', async () => { + const response = await supertest(app).post('/revoke').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret' + // Missing token + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(spyRevokeToken).not.toHaveBeenCalled(); + }); + + it('authenticates client before revoking token', async () => { + const response = await supertest(app).post('/revoke').type('form').send({ + client_id: 'invalid-client', + client_secret: 'wrong-secret', + token: 'token_to_revoke' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(spyRevokeToken).not.toHaveBeenCalled(); + }); + + it('successfully revokes token', async () => { + const response = await supertest(app).post('/revoke').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({}); // Empty response on success + expect(spyRevokeToken).toHaveBeenCalledTimes(1); + expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { + token: 'token_to_revoke' + }); + }); + + it('accepts optional token_type_hint', async () => { + const response = await supertest(app).post('/revoke').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke', + token_type_hint: 'refresh_token' + }); + + expect(response.status).toBe(200); + expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { + token: 'token_to_revoke', + token_type_hint: 'refresh_token' + }); + }); + + it('includes CORS headers in response', async () => { + const response = await supertest(app).post('/revoke').type('form').set('Origin', 'https://example.com').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + }); +}); diff --git a/packages/server-auth-legacy/test/handlers/token.test.ts b/packages/server-auth-legacy/test/handlers/token.test.ts new file mode 100644 index 000000000..8d5634922 --- /dev/null +++ b/packages/server-auth-legacy/test/handlers/token.test.ts @@ -0,0 +1,479 @@ +import { tokenHandler, TokenHandlerOptions } from '../../src/handlers/token.js'; +import { OAuthServerProvider, AuthorizationParams } from '../../src/provider.js'; +import { OAuthRegisteredClientsStore } from '../../src/clients.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; +import express, { Response } from 'express'; +import supertest from 'supertest'; +import * as pkceChallenge from 'pkce-challenge'; +import { InvalidGrantError, InvalidTokenError } from '../../src/errors.js'; +import { AuthInfo } from '../../src/types.js'; +import { ProxyOAuthServerProvider } from '../../src/providers/proxyProvider.js'; +import { type Mock } from 'vitest'; + +// Mock pkce-challenge +vi.mock('pkce-challenge', () => ({ + verifyChallenge: vi.fn().mockImplementation(async (verifier, challenge) => { + return verifier === 'valid_verifier' && challenge === 'mock_challenge'; + }) +})); + +const mockTokens = { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' +}; + +const mockTokensWithIdToken = { + ...mockTokens, + id_token: 'mock_id_token' +}; + +describe('Token Handler', () => { + // Mock client data + const validClient: OAuthClientInformationFull = { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return validClient; + } + return undefined; + } + }; + + // Mock provider + let mockProvider: OAuthServerProvider; + let app: express.Express; + + beforeEach(() => { + // Create fresh mocks for each test + mockProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + res.redirect('https://example.com/callback?code=mock_auth_code'); + }, + + async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { + if (authorizationCode === 'valid_code') { + return 'mock_challenge'; + } else if (authorizationCode === 'expired_code') { + throw new InvalidGrantError('The authorization code has expired'); + } + throw new InvalidGrantError('The authorization code is invalid'); + }, + + async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { + if (authorizationCode === 'valid_code') { + return mockTokens; + } + throw new InvalidGrantError('The authorization code is invalid or has expired'); + }, + + async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise { + if (refreshToken === 'valid_refresh_token') { + const response: OAuthTokens = { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + + if (scopes) { + response.scope = scopes.join(' '); + } + + return response; + } + throw new InvalidGrantError('The refresh token is invalid or has expired'); + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); + }, + + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Do nothing in mock + } + }; + + // Mock PKCE verification + (pkceChallenge.verifyChallenge as Mock).mockImplementation(async (verifier: string, challenge: string) => { + return verifier === 'valid_verifier' && challenge === 'mock_challenge'; + }); + + // Setup express app with token handler + app = express(); + const options: TokenHandlerOptions = { provider: mockProvider }; + app.use('/token', tokenHandler(options)); + }); + + describe('Basic request validation', () => { + it('requires POST method', async () => { + const response = await supertest(app).get('/token').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code' + }); + + expect(response.status).toBe(405); + expect(response.headers.allow).toBe('POST'); + expect(response.body).toEqual({ + error: 'method_not_allowed', + error_description: 'The method GET is not allowed for this endpoint' + }); + }); + + it('requires grant_type parameter', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret' + // Missing grant_type + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('rejects unsupported grant types', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'password' // Unsupported grant type + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('unsupported_grant_type'); + }); + }); + + describe('Client authentication', () => { + it('requires valid client credentials', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'invalid-client', + client_secret: 'wrong-secret', + grant_type: 'authorization_code' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + }); + + it('accepts valid client credentials', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + }); + }); + + describe('Authorization code grant', () => { + it('requires code parameter', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + // Missing code + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('requires code_verifier parameter', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code' + // Missing code_verifier + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('verifies code_verifier against challenge', async () => { + // Setup invalid verifier + (pkceChallenge.verifyChallenge as Mock).mockResolvedValueOnce(false); + + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'invalid_verifier' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_grant'); + expect(response.body.error_description).toContain('code_verifier'); + }); + + it('rejects expired or invalid authorization codes', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'expired_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_grant'); + }); + + it('returns tokens for valid code exchange', async () => { + const mockExchangeCode = vi.spyOn(mockProvider, 'exchangeAuthorizationCode'); + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('mock_access_token'); + expect(response.body.token_type).toBe('bearer'); + expect(response.body.expires_in).toBe(3600); + expect(response.body.refresh_token).toBe('mock_refresh_token'); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + undefined, // redirect_uri + new URL('https://api.example.com/resource') // resource parameter + ); + }); + + it('returns id token in code exchange if provided', async () => { + mockProvider.exchangeAuthorizationCode = async ( + client: OAuthClientInformationFull, + authorizationCode: string + ): Promise => { + if (authorizationCode === 'valid_code') { + return mockTokensWithIdToken; + } + throw new InvalidGrantError('The authorization code is invalid or has expired'); + }; + + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + expect(response.body.id_token).toBe('mock_id_token'); + }); + + it('passes through code verifier when using proxy provider', async () => { + const originalFetch = global.fetch; + + try { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTokens) + }); + + const proxyProvider = new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: 'https://example.com/authorize', + tokenUrl: 'https://example.com/token' + }, + verifyAccessToken: async token => ({ + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }), + getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined) + }); + + const proxyApp = express(); + const options: TokenHandlerOptions = { provider: proxyProvider }; + proxyApp.use('/token', tokenHandler(options)); + + const response = await supertest(proxyApp).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'any_verifier', + redirect_uri: 'https://example.com/callback' + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('mock_access_token'); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining('code_verifier=any_verifier') + }) + ); + } finally { + global.fetch = originalFetch; + } + }); + + it('passes through redirect_uri when using proxy provider', async () => { + const originalFetch = global.fetch; + + try { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTokens) + }); + + const proxyProvider = new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: 'https://example.com/authorize', + tokenUrl: 'https://example.com/token' + }, + verifyAccessToken: async token => ({ + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }), + getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined) + }); + + const proxyApp = express(); + const options: TokenHandlerOptions = { provider: proxyProvider }; + proxyApp.use('/token', tokenHandler(options)); + + const redirectUri = 'https://example.com/callback'; + const response = await supertest(proxyApp).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'any_verifier', + redirect_uri: redirectUri + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('mock_access_token'); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) + }) + ); + } finally { + global.fetch = originalFetch; + } + }); + }); + + describe('Refresh token grant', () => { + it('requires refresh_token parameter', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token' + // Missing refresh_token + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('rejects invalid refresh tokens', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'invalid_refresh_token' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_grant'); + }); + + it('returns new tokens for valid refresh token', async () => { + const mockExchangeRefresh = vi.spyOn(mockProvider, 'exchangeRefreshToken'); + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token' + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('new_mock_access_token'); + expect(response.body.token_type).toBe('bearer'); + expect(response.body.expires_in).toBe(3600); + expect(response.body.refresh_token).toBe('new_mock_refresh_token'); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + new URL('https://api.example.com/resource') // resource parameter + ); + }); + + it('respects requested scopes on refresh', async () => { + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + scope: 'profile email' + }); + + expect(response.status).toBe(200); + expect(response.body.scope).toBe('profile email'); + }); + }); + + describe('CORS support', () => { + it('includes CORS headers in response', async () => { + const response = await supertest(app).post('/token').type('form').set('Origin', 'https://example.com').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + }); +}); diff --git a/packages/server-auth-legacy/test/helpers/http.ts b/packages/server-auth-legacy/test/helpers/http.ts new file mode 100644 index 000000000..98846621a --- /dev/null +++ b/packages/server-auth-legacy/test/helpers/http.ts @@ -0,0 +1,56 @@ +import type { Response } from 'express'; +import { vi } from 'vitest'; + +/** + * Create a minimal Express-like Response mock for tests. + * + * The mock supports: + * - redirect() + * - status().json().send() chaining + * - set()/header() + * - optional getRedirectUrl() helper used in some tests + */ +export function createExpressResponseMock(options: { trackRedirectUrl?: boolean } = {}): Response & { + getRedirectUrl?: () => string; +} { + let capturedRedirectUrl: string | undefined; + + const res: Partial & { getRedirectUrl?: () => string } = { + redirect: vi.fn((urlOrStatus: string | number, maybeUrl?: string | number) => { + if (options.trackRedirectUrl) { + if (typeof urlOrStatus === 'string') { + capturedRedirectUrl = urlOrStatus; + } else if (typeof maybeUrl === 'string') { + capturedRedirectUrl = maybeUrl; + } + } + return res as Response; + }) as unknown as Response['redirect'], + status: vi.fn().mockImplementation((_code: number) => { + return res as Response; + }), + json: vi.fn().mockImplementation((_body: unknown) => { + return res as Response; + }), + send: vi.fn().mockImplementation((_body?: unknown) => { + return res as Response; + }), + set: vi.fn().mockImplementation((_field: string, _value?: string | string[]) => { + return res as Response; + }), + header: vi.fn().mockImplementation((_field: string, _value?: string | string[]) => { + return res as Response; + }) + }; + + if (options.trackRedirectUrl) { + res.getRedirectUrl = () => { + if (capturedRedirectUrl === undefined) { + throw new Error('No redirect URL was captured. Ensure redirect() was called first.'); + } + return capturedRedirectUrl; + }; + } + + return res as Response & { getRedirectUrl?: () => string }; +} diff --git a/packages/server-auth-legacy/test/index.test.ts b/packages/server-auth-legacy/test/index.test.ts new file mode 100644 index 000000000..16276eec6 --- /dev/null +++ b/packages/server-auth-legacy/test/index.test.ts @@ -0,0 +1,70 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { + type AuthInfo, + type AuthRouterOptions, + InvalidTokenError, + mcpAuthRouter, + OAuthError, + type OAuthRegisteredClientsStore, + type OAuthServerProvider, + ProxyOAuthServerProvider, + ServerError +} from '../src/index.js'; + +describe('@modelcontextprotocol/server-auth-legacy (frozen v1 compat)', () => { + it('exports the v1 OAuthError subclass hierarchy', () => { + const err = new InvalidTokenError('bad token'); + expect(err).toBeInstanceOf(OAuthError); + expect(err.errorCode).toBe('invalid_token'); + expect(err.toResponseObject()).toEqual({ + error: 'invalid_token', + error_description: 'bad token' + }); + }); + + it('exports ProxyOAuthServerProvider', () => { + const provider = new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: 'https://upstream.example/authorize', + tokenUrl: 'https://upstream.example/token' + }, + verifyAccessToken: async token => ({ token, clientId: 'c', scopes: [] }) satisfies AuthInfo, + getClient: async () => undefined + }); + expect(provider.skipLocalPkceValidation).toBe(true); + expect(provider.clientsStore.getClient).toBeTypeOf('function'); + }); + + it('mcpAuthRouter wires up /authorize, /token and AS metadata', async () => { + const clientsStore: OAuthRegisteredClientsStore = { + getClient: () => undefined + }; + const provider: OAuthServerProvider = { + clientsStore, + authorize: async () => { + throw new ServerError('not implemented'); + }, + challengeForAuthorizationCode: async () => 'challenge', + exchangeAuthorizationCode: async () => ({ access_token: 't', token_type: 'Bearer' }), + exchangeRefreshToken: async () => ({ access_token: 't', token_type: 'Bearer' }), + verifyAccessToken: async token => ({ token, clientId: 'c', scopes: [] }) + }; + + const options: AuthRouterOptions = { + provider, + issuerUrl: new URL('http://localhost/') + }; + + const app = express(); + app.use(mcpAuthRouter(options)); + + const res = await request(app).get('/.well-known/oauth-authorization-server'); + expect(res.status).toBe(200); + expect(res.body.issuer).toBe('http://localhost/'); + expect(res.body.authorization_endpoint).toBe('http://localhost/authorize'); + expect(res.body.token_endpoint).toBe('http://localhost/token'); + }); +}); diff --git a/packages/server-auth-legacy/test/middleware/allowedMethods.test.ts b/packages/server-auth-legacy/test/middleware/allowedMethods.test.ts new file mode 100644 index 000000000..d8d0fa63d --- /dev/null +++ b/packages/server-auth-legacy/test/middleware/allowedMethods.test.ts @@ -0,0 +1,75 @@ +import { allowedMethods } from '../../src/middleware/allowedMethods.js'; +import express, { Request, Response } from 'express'; +import request from 'supertest'; + +describe('allowedMethods', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + + // Set up a test router with a GET handler and 405 middleware + const router = express.Router(); + + router.get('/test', (req, res) => { + res.status(200).send('GET success'); + }); + + // Add method not allowed middleware for all other methods + router.all('/test', allowedMethods(['GET'])); + + app.use(router); + }); + + test('allows specified HTTP method', async () => { + const response = await request(app).get('/test'); + expect(response.status).toBe(200); + expect(response.text).toBe('GET success'); + }); + + test('returns 405 for unspecified HTTP methods', async () => { + const methods = ['post', 'put', 'delete', 'patch']; + + for (const method of methods) { + // @ts-expect-error - dynamic method call + const response = await request(app)[method]('/test'); + expect(response.status).toBe(405); + expect(response.body).toEqual({ + error: 'method_not_allowed', + error_description: `The method ${method.toUpperCase()} is not allowed for this endpoint` + }); + } + }); + + test('includes Allow header with specified methods', async () => { + const response = await request(app).post('/test'); + expect(response.headers.allow).toBe('GET'); + }); + + test('works with multiple allowed methods', async () => { + const multiMethodApp = express(); + const router = express.Router(); + + router.get('/multi', (req: Request, res: Response) => { + res.status(200).send('GET'); + }); + router.post('/multi', (req: Request, res: Response) => { + res.status(200).send('POST'); + }); + router.all('/multi', allowedMethods(['GET', 'POST'])); + + multiMethodApp.use(router); + + // Allowed methods should work + const getResponse = await request(multiMethodApp).get('/multi'); + expect(getResponse.status).toBe(200); + + const postResponse = await request(multiMethodApp).post('/multi'); + expect(postResponse.status).toBe(200); + + // Unallowed methods should return 405 + const putResponse = await request(multiMethodApp).put('/multi'); + expect(putResponse.status).toBe(405); + expect(putResponse.headers.allow).toBe('GET, POST'); + }); +}); diff --git a/packages/server-auth-legacy/test/middleware/bearerAuth.test.ts b/packages/server-auth-legacy/test/middleware/bearerAuth.test.ts new file mode 100644 index 000000000..451f49112 --- /dev/null +++ b/packages/server-auth-legacy/test/middleware/bearerAuth.test.ts @@ -0,0 +1,501 @@ +import { Request, Response } from 'express'; +import { Mock } from 'vitest'; +import { requireBearerAuth } from '../../src/middleware/bearerAuth.js'; +import { AuthInfo } from '../../src/types.js'; +import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from '../../src/errors.js'; +import { OAuthTokenVerifier } from '../../src/provider.js'; +import { createExpressResponseMock } from '../helpers/http.js'; + +// Mock verifier +const mockVerifyAccessToken = vi.fn(); +const mockVerifier: OAuthTokenVerifier = { + verifyAccessToken: mockVerifyAccessToken +}; + +describe('requireBearerAuth middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: Mock; + + beforeEach(() => { + mockRequest = { + headers: {} + }; + mockResponse = createExpressResponseMock(); + nextFunction = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should call next when token is valid', async () => { + const validAuthInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read', 'write'], + expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour + }; + mockVerifyAccessToken.mockResolvedValue(validAuthInfo); + + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockRequest.auth).toEqual(validAuthInfo); + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it.each([ + [100], // Token expired 100 seconds ago + [0] // Token expires at the same time as now + ])('should reject expired tokens (expired %s seconds ago)', async (expiredSecondsAgo: number) => { + const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo; + const expiredAuthInfo: AuthInfo = { + token: 'expired-token', + clientId: 'client-123', + scopes: ['read', 'write'], + expiresAt + }; + mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); + + mockRequest.headers = { + authorization: 'Bearer expired-token' + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token'); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it.each([ + [undefined], // Token has no expiration time + [NaN] // Token has no expiration time + ])('should reject tokens with no expiration time (expiresAt: %s)', async (expiresAt: number | undefined) => { + const noExpirationAuthInfo: AuthInfo = { + token: 'no-expiration-token', + clientId: 'client-123', + scopes: ['read', 'write'], + expiresAt + }; + mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo); + + mockRequest.headers = { + authorization: 'Bearer expired-token' + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token'); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'invalid_token', error_description: 'Token has no expiration time' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should accept non-expired tokens', async () => { + const nonExpiredAuthInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read', 'write'], + expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour + }; + mockVerifyAccessToken.mockResolvedValue(nonExpiredAuthInfo); + + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockRequest.auth).toEqual(nonExpiredAuthInfo); + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('should require specific scopes when configured', async () => { + const authInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read'] + }; + mockVerifyAccessToken.mockResolvedValue(authInfo); + + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['read', 'write'] + }); + + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"')); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'insufficient_scope', error_description: 'Insufficient scope' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should accept token with all required scopes', async () => { + const authInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read', 'write', 'admin'], + expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour + }; + mockVerifyAccessToken.mockResolvedValue(authInfo); + + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['read', 'write'] + }); + + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockRequest.auth).toEqual(authInfo); + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('should return 401 when no Authorization header is present', async () => { + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'invalid_token', error_description: 'Missing Authorization header' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should return 401 when Authorization header format is invalid', async () => { + mockRequest.headers = { + authorization: 'InvalidFormat' + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'invalid_token', + error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'" + }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should return 401 when token verification fails with InvalidTokenError', async () => { + mockRequest.headers = { + authorization: 'Bearer invalid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired')); + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('invalid-token'); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'invalid_token', error_description: 'Token expired' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should return 403 when access token has insufficient scopes', async () => { + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: read, write')); + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"')); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'insufficient_scope', error_description: 'Required scopes: read, write' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should return 500 when a ServerError occurs', async () => { + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue')); + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'server_error', error_description: 'Internal server issue' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should return 400 for generic OAuthError', async () => { + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new CustomOAuthError('custom_error', 'Some OAuth error')); + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'custom_error', error_description: 'Some OAuth error' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should return 500 when unexpected error occurs', async () => { + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new Error('Unexpected error')); + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'server_error', error_description: 'Internal Server Error' }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + describe('with requiredScopes in WWW-Authenticate header', () => { + it('should include scope in WWW-Authenticate header for 401 responses when requiredScopes is provided', async () => { + mockRequest.headers = {}; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['read', 'write'] + }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Bearer error="invalid_token", error_description="Missing Authorization header", scope="read write"' + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include scope in WWW-Authenticate header for 403 insufficient scope responses', async () => { + const authInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read'] + }; + mockVerifyAccessToken.mockResolvedValue(authInfo); + + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['read', 'write'] + }); + + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write"' + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include both scope and resource_metadata in WWW-Authenticate header when both are provided', async () => { + mockRequest.headers = {}; + + const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource'; + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['admin'], + resourceMetadataUrl + }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="Missing Authorization header", scope="admin", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + }); + + describe('with resourceMetadataUrl', () => { + const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource'; + + it('should include resource_metadata in WWW-Authenticate header for 401 responses', async () => { + mockRequest.headers = {}; + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include resource_metadata in WWW-Authenticate header when token verification fails', async () => { + mockRequest.headers = { + authorization: 'Bearer invalid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired')); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include resource_metadata in WWW-Authenticate header for insufficient scope errors', async () => { + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: admin')); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include resource_metadata when token is expired', async () => { + const expiredAuthInfo: AuthInfo = { + token: 'expired-token', + clientId: 'client-123', + scopes: ['read', 'write'], + expiresAt: Math.floor(Date.now() / 1000) - 100 + }; + mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); + + mockRequest.headers = { + authorization: 'Bearer expired-token' + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include resource_metadata when scope check fails', async () => { + const authInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read'] + }; + mockVerifyAccessToken.mockResolvedValue(authInfo); + + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['read', 'write'], + resourceMetadataUrl + }); + + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should not affect server errors (no WWW-Authenticate header)', async () => { + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue')); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.set).not.toHaveBeenCalledWith('WWW-Authenticate', expect.anything()); + expect(nextFunction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/server-auth-legacy/test/middleware/clientAuth.test.ts b/packages/server-auth-legacy/test/middleware/clientAuth.test.ts new file mode 100644 index 000000000..265216810 --- /dev/null +++ b/packages/server-auth-legacy/test/middleware/clientAuth.test.ts @@ -0,0 +1,132 @@ +import { authenticateClient, ClientAuthenticationMiddlewareOptions } from '../../src/middleware/clientAuth.js'; +import { OAuthRegisteredClientsStore } from '../../src/clients.js'; +import { OAuthClientInformationFull } from '@modelcontextprotocol/core'; +import express from 'express'; +import supertest from 'supertest'; + +describe('clientAuth middleware', () => { + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + } else if (clientId === 'expired-client') { + // Client with no secret + return { + client_id: 'expired-client', + redirect_uris: ['https://example.com/callback'] + }; + } else if (clientId === 'client-with-expired-secret') { + // Client with an expired secret + return { + client_id: 'client-with-expired-secret', + client_secret: 'expired-secret', + client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago + redirect_uris: ['https://example.com/callback'] + }; + } + return undefined; + } + }; + + // Setup Express app with middleware + let app: express.Express; + let options: ClientAuthenticationMiddlewareOptions; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + options = { + clientsStore: mockClientStore + }; + + // Setup route with client auth + app.post('/protected', authenticateClient(options), (req, res) => { + res.status(200).json({ success: true, client: req.client }); + }); + }); + + it('authenticates valid client credentials', async () => { + const response = await supertest(app).post('/protected').send({ + client_id: 'valid-client', + client_secret: 'valid-secret' + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.client.client_id).toBe('valid-client'); + }); + + it('rejects invalid client_id', async () => { + const response = await supertest(app).post('/protected').send({ + client_id: 'non-existent-client', + client_secret: 'some-secret' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(response.body.error_description).toBe('Invalid client_id'); + }); + + it('rejects invalid client_secret', async () => { + const response = await supertest(app).post('/protected').send({ + client_id: 'valid-client', + client_secret: 'wrong-secret' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(response.body.error_description).toBe('Invalid client_secret'); + }); + + it('rejects missing client_id', async () => { + const response = await supertest(app).post('/protected').send({ + client_secret: 'valid-secret' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('allows missing client_secret if client has none', async () => { + const response = await supertest(app).post('/protected').send({ + client_id: 'expired-client' + }); + + // Since the client has no secret, this should pass without providing one + expect(response.status).toBe(200); + }); + + it('rejects request when client secret has expired', async () => { + const response = await supertest(app).post('/protected').send({ + client_id: 'client-with-expired-secret', + client_secret: 'expired-secret' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(response.body.error_description).toBe('Client secret has expired'); + }); + + it('handles malformed request body', async () => { + const response = await supertest(app).post('/protected').send('not-json-format'); + + expect(response.status).toBe(400); + }); + + // Testing request with extra fields to ensure they're ignored + it('ignores extra fields in request', async () => { + const response = await supertest(app).post('/protected').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + extra_field: 'should be ignored' + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/packages/server-auth-legacy/test/providers/proxyProvider.test.ts b/packages/server-auth-legacy/test/providers/proxyProvider.test.ts new file mode 100644 index 000000000..2646f49a0 --- /dev/null +++ b/packages/server-auth-legacy/test/providers/proxyProvider.test.ts @@ -0,0 +1,344 @@ +import { Response } from 'express'; +import { ProxyOAuthServerProvider, ProxyOptions } from '../../src/providers/proxyProvider.js'; +import { AuthInfo } from '../../src/types.js'; +import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/core'; +import { ServerError } from '../../src/errors.js'; +import { InvalidTokenError } from '../../src/errors.js'; +import { InsufficientScopeError } from '../../src/errors.js'; +import { type Mock } from 'vitest'; + +describe('Proxy OAuth Server Provider', () => { + // Mock client data + const validClient: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback'] + }; + + // Mock response object + const mockResponse = { + redirect: vi.fn() + } as unknown as Response; + + // Mock provider functions + const mockVerifyToken = vi.fn(); + const mockGetClient = vi.fn(); + + // Base provider options + const baseOptions: ProxyOptions = { + endpoints: { + authorizationUrl: 'https://auth.example.com/authorize', + tokenUrl: 'https://auth.example.com/token', + revocationUrl: 'https://auth.example.com/revoke', + registrationUrl: 'https://auth.example.com/register' + }, + verifyAccessToken: mockVerifyToken, + getClient: mockGetClient + }; + + let provider: ProxyOAuthServerProvider; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + provider = new ProxyOAuthServerProvider(baseOptions); + originalFetch = global.fetch; + global.fetch = vi.fn(); + + // Setup mock implementations + mockVerifyToken.mockImplementation(async (token: string) => { + if (token === 'valid-token') { + return { + token, + clientId: 'test-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + } as AuthInfo; + } + throw new InvalidTokenError('Invalid token'); + }); + + mockGetClient.mockImplementation(async (clientId: string) => { + if (clientId === 'test-client') { + return validClient; + } + return undefined; + }); + }); + + // Add helper function for failed responses + const mockFailedResponse = () => { + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: false, + status: 400 + }) + ); + }; + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + describe('authorization', () => { + it('redirects to authorization endpoint with correct parameters', async () => { + await provider.authorize( + validClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + scopes: ['read', 'write'], + resource: new URL('https://api.example.com/resource') + }, + mockResponse + ); + + const expectedUrl = new URL('https://auth.example.com/authorize'); + expectedUrl.searchParams.set('client_id', 'test-client'); + expectedUrl.searchParams.set('response_type', 'code'); + expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback'); + expectedUrl.searchParams.set('code_challenge', 'test-challenge'); + expectedUrl.searchParams.set('code_challenge_method', 'S256'); + expectedUrl.searchParams.set('state', 'test-state'); + expectedUrl.searchParams.set('scope', 'read write'); + expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); + + expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); + }); + }); + + describe('token exchange', () => { + const mockTokenResponse: OAuthTokens = { + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }; + + beforeEach(() => { + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockTokenResponse) + }) + ); + }); + + it('exchanges authorization code for tokens', async () => { + const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining('grant_type=authorization_code') + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('includes redirect_uri in token request when provided', async () => { + const redirectUri = 'https://example.com/callback'; + const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier', redirectUri); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('includes resource parameter in authorization code exchange', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier', + 'https://example.com/callback', + new URL('https://api.example.com/resource') + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('handles authorization code exchange without resource parameter', async () => { + const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); + + const fetchCall = (global.fetch as Mock).mock.calls[0]; + const body = fetchCall![1].body as string; + expect(body).not.toContain('resource='); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('exchanges refresh token for new tokens', async () => { + const tokens = await provider.exchangeRefreshToken(validClient, 'test-refresh-token', ['read', 'write']); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining('grant_type=refresh_token') + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('includes resource parameter in refresh token exchange', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['read', 'write'], + new URL('https://api.example.com/resource') + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + }); + + describe('client registration', () => { + it('registers new client', async () => { + const newClient: OAuthClientInformationFull = { + client_id: 'new-client', + redirect_uris: ['https://new-client.com/callback'] + }; + + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(newClient) + }) + ); + + const result = await provider.clientsStore.registerClient!(newClient); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/register', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newClient) + }) + ); + expect(result).toEqual(newClient); + }); + + it('handles registration failure', async () => { + mockFailedResponse(); + const newClient: OAuthClientInformationFull = { + client_id: 'new-client', + redirect_uris: ['https://new-client.com/callback'] + }; + + await expect(provider.clientsStore.registerClient!(newClient)).rejects.toThrow(ServerError); + }); + }); + + describe('token revocation', () => { + it('revokes token', async () => { + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: true + }) + ); + + await provider.revokeToken!(validClient, { + token: 'token-to-revoke', + token_type_hint: 'access_token' + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/revoke', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining('token=token-to-revoke') + }) + ); + }); + + it('handles revocation failure', async () => { + mockFailedResponse(); + await expect( + provider.revokeToken!(validClient, { + token: 'invalid-token' + }) + ).rejects.toThrow(ServerError); + }); + }); + + describe('token verification', () => { + it('verifies valid token', async () => { + const validAuthInfo: AuthInfo = { + token: 'valid-token', + clientId: 'test-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + mockVerifyToken.mockResolvedValue(validAuthInfo); + + const authInfo = await provider.verifyAccessToken('valid-token'); + expect(authInfo).toEqual(validAuthInfo); + expect(mockVerifyToken).toHaveBeenCalledWith('valid-token'); + }); + + it('passes through InvalidTokenError', async () => { + const error = new InvalidTokenError('Token expired'); + mockVerifyToken.mockRejectedValue(error); + + await expect(provider.verifyAccessToken('invalid-token')).rejects.toBe(error); + expect(mockVerifyToken).toHaveBeenCalledWith('invalid-token'); + }); + + it('passes through InsufficientScopeError', async () => { + const error = new InsufficientScopeError('Required scopes: read, write'); + mockVerifyToken.mockRejectedValue(error); + + await expect(provider.verifyAccessToken('token-with-insufficient-scope')).rejects.toBe(error); + expect(mockVerifyToken).toHaveBeenCalledWith('token-with-insufficient-scope'); + }); + + it('passes through unexpected errors', async () => { + const error = new Error('Unexpected error'); + mockVerifyToken.mockRejectedValue(error); + + await expect(provider.verifyAccessToken('valid-token')).rejects.toBe(error); + expect(mockVerifyToken).toHaveBeenCalledWith('valid-token'); + }); + }); +}); diff --git a/packages/server-auth-legacy/test/router.test.ts b/packages/server-auth-legacy/test/router.test.ts new file mode 100644 index 000000000..4b44ef571 --- /dev/null +++ b/packages/server-auth-legacy/test/router.test.ts @@ -0,0 +1,463 @@ +import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from '../src/router.js'; +import { OAuthServerProvider, AuthorizationParams } from '../src/provider.js'; +import { OAuthRegisteredClientsStore } from '../src/clients.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; +import express, { Response } from 'express'; +import supertest from 'supertest'; +import { AuthInfo } from '../src/types.js'; +import { InvalidTokenError } from '../src/errors.js'; + +describe('MCP Auth Router', () => { + // Setup mock provider with full capabilities + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + } + return undefined; + }, + + async registerClient(client: OAuthClientInformationFull): Promise { + return client; + } + }; + + const mockProvider: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); + }, + + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Success - do nothing in mock + } + }; + + // Provider without registration and revocation + const mockProviderMinimal: OAuthServerProvider = { + clientsStore: { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + } + return undefined; + } + }, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); + } + }; + + describe('Router creation', () => { + it('throws error for non-HTTPS issuer URL', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('http://auth.example.com') + }; + + expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must be HTTPS'); + }); + + it('allows localhost HTTP for development', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('http://localhost:3000') + }; + + expect(() => mcpAuthRouter(options)).not.toThrow(); + }); + + it('throws error for issuer URL with fragment', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://auth.example.com#fragment') + }; + + expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a fragment'); + }); + + it('throws error for issuer URL with query string', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://auth.example.com?param=value') + }; + + expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a query string'); + }); + + it('successfully creates router with valid options', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://auth.example.com') + }; + + expect(() => mcpAuthRouter(options)).not.toThrow(); + }); + }); + + describe('Metadata endpoint', () => { + let app: express.Express; + + beforeEach(() => { + // Setup full-featured router + app = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://auth.example.com'), + serviceDocumentationUrl: new URL('https://docs.example.com') + }; + app.use(mcpAuthRouter(options)); + }); + + it('returns complete metadata for full-featured router', async () => { + const response = await supertest(app).get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + + // Verify essential fields + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); + expect(response.body.registration_endpoint).toBe('https://auth.example.com/register'); + expect(response.body.revocation_endpoint).toBe('https://auth.example.com/revoke'); + + // Verify supported features + expect(response.body.response_types_supported).toEqual(['code']); + expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + expect(response.body.code_challenge_methods_supported).toEqual(['S256']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post', 'none']); + expect(response.body.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_post']); + + // Verify optional fields + expect(response.body.service_documentation).toBe('https://docs.example.com/'); + }); + + it('returns minimal metadata for minimal router', async () => { + // Setup minimal router + const minimalApp = express(); + const options: AuthRouterOptions = { + provider: mockProviderMinimal, + issuerUrl: new URL('https://auth.example.com') + }; + minimalApp.use(mcpAuthRouter(options)); + + const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + + // Verify essential endpoints + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); + + // Verify missing optional endpoints + expect(response.body.registration_endpoint).toBeUndefined(); + expect(response.body.revocation_endpoint).toBeUndefined(); + expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined(); + expect(response.body.service_documentation).toBeUndefined(); + }); + + it('provides protected resource metadata', async () => { + // Setup router with draft protocol version + const draftApp = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://mcp.example.com'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API' + }; + draftApp.use(mcpAuthRouter(options)); + + const response = await supertest(draftApp).get('/.well-known/oauth-protected-resource'); + + expect(response.status).toBe(200); + + // Verify protected resource metadata + expect(response.body.resource).toBe('https://mcp.example.com/'); + expect(response.body.authorization_servers).toContain('https://mcp.example.com/'); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); + }); + }); + + describe('Endpoint routing', () => { + let app: express.Express; + + beforeEach(() => { + // Setup full-featured router + app = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://auth.example.com') + }; + app.use(mcpAuthRouter(options)); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('routes to authorization endpoint', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.has('code')).toBe(true); + }); + + it('routes to token endpoint', async () => { + // Setup verifyChallenge mock for token handler + vi.mock('pkce-challenge', () => ({ + verifyChallenge: vi.fn().mockResolvedValue(true) + })); + + const response = await supertest(app).post('/token').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + // The request will fail in testing due to mocking limitations, + // but we can verify the route was matched + expect(response.status).not.toBe(404); + }); + + it('routes to registration endpoint', async () => { + const response = await supertest(app) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + // The request will fail in testing due to mocking limitations, + // but we can verify the route was matched + expect(response.status).not.toBe(404); + }); + + it('routes to revocation endpoint', async () => { + const response = await supertest(app).post('/revoke').type('form').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + // The request will fail in testing due to mocking limitations, + // but we can verify the route was matched + expect(response.status).not.toBe(404); + }); + + it('excludes endpoints for unsupported features', async () => { + // Setup minimal router + const minimalApp = express(); + const options: AuthRouterOptions = { + provider: mockProviderMinimal, + issuerUrl: new URL('https://auth.example.com') + }; + minimalApp.use(mcpAuthRouter(options)); + + // Registration should not be available + const regResponse = await supertest(minimalApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + expect(regResponse.status).toBe(404); + + // Revocation should not be available + const revokeResponse = await supertest(minimalApp).post('/revoke').send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + expect(revokeResponse.status).toBe(404); + }); + }); +}); + +describe('MCP Auth Metadata Router', () => { + const mockOAuthMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com/', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_post'] + }; + + describe('Router creation', () => { + it('successfully creates router with valid options', () => { + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com') + }; + + expect(() => mcpAuthMetadataRouter(options)).not.toThrow(); + }); + }); + + describe('Metadata endpoints', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com'), + serviceDocumentationUrl: new URL('https://docs.example.com'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API' + }; + app.use(mcpAuthMetadataRouter(options)); + }); + + it('returns OAuth authorization server metadata', async () => { + const response = await supertest(app).get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + + // Verify metadata points to authorization server + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); + expect(response.body.response_types_supported).toEqual(['code']); + expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + expect(response.body.code_challenge_methods_supported).toEqual(['S256']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']); + }); + + it('returns OAuth protected resource metadata', async () => { + const response = await supertest(app).get('/.well-known/oauth-protected-resource'); + + expect(response.status).toBe(200); + + // Verify protected resource metadata + expect(response.body.resource).toBe('https://api.example.com/'); + expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); + expect(response.body.resource_documentation).toBe('https://docs.example.com/'); + }); + + it('works with minimal configuration', async () => { + const minimalApp = express(); + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com') + }; + minimalApp.use(mcpAuthMetadataRouter(options)); + + const authResponse = await supertest(minimalApp).get('/.well-known/oauth-authorization-server'); + + expect(authResponse.status).toBe(200); + expect(authResponse.body.issuer).toBe('https://auth.example.com/'); + expect(authResponse.body.service_documentation).toBeUndefined(); + expect(authResponse.body.scopes_supported).toBeUndefined(); + + const resourceResponse = await supertest(minimalApp).get('/.well-known/oauth-protected-resource'); + + expect(resourceResponse.status).toBe(200); + expect(resourceResponse.body.resource).toBe('https://api.example.com/'); + expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']); + expect(resourceResponse.body.scopes_supported).toBeUndefined(); + expect(resourceResponse.body.resource_name).toBeUndefined(); + expect(resourceResponse.body.resource_documentation).toBeUndefined(); + }); + }); +}); diff --git a/packages/server-auth-legacy/tsconfig.json b/packages/server-auth-legacy/tsconfig.json new file mode 100644 index 000000000..18c1327cb --- /dev/null +++ b/packages/server-auth-legacy/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"] + } + } +} diff --git a/packages/server-auth-legacy/tsdown.config.ts b/packages/server-auth-legacy/tsdown.config.ts new file mode 100644 index 000000000..bc0ef8329 --- /dev/null +++ b/packages/server-auth-legacy/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc', + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/core': ['../core/src/index.ts'] + } + } + } +}); diff --git a/packages/server-auth-legacy/typedoc.json b/packages/server-auth-legacy/typedoc.json new file mode 100644 index 000000000..a9fd090d0 --- /dev/null +++ b/packages/server-auth-legacy/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts", "**/__*__/**"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/server-auth-legacy/vitest.config.js b/packages/server-auth-legacy/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/server-auth-legacy/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index cf0b99152..3c7d5d252 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -36,6 +36,7 @@ import type { ServerCapabilities, ServerContext, ServerResult, + StandardSchemaV1, StandardSchemaWithJSON, StreamDriverOptions, TaskManagerHost, @@ -445,9 +446,12 @@ export class McpServer extends Dispatcher implements RegistriesHo handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise ): void; /** @deprecated Pass a method string instead of a Zod request schema. */ - public override setRequestHandler( - schema: { shape: { method: unknown } }, - handler: (request: JSONRPCRequest, ctx: ServerContext) => Result | Promise + public override setRequestHandler( + schema: S, + handler: ( + request: S extends StandardSchemaV1 ? O : JSONRPCRequest, + ctx: ServerContext + ) => Result | Promise ): void; public override setRequestHandler( methodOrSchema: RequestMethod | { shape: { method: unknown } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f6c62e7c..935269802 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,7 +251,7 @@ importers: version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) eslint-plugin-n: specifier: catalog:devTools version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) @@ -873,6 +873,9 @@ importers: '@modelcontextprotocol/server': specifier: workspace:^ version: link:../server + '@modelcontextprotocol/server-auth-legacy': + specifier: workspace:^ + version: link:../server-auth-legacy express: specifier: ^4.18.0 || ^5.0.0 version: 5.2.1 @@ -984,6 +987,82 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/server-auth-legacy: + dependencies: + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express-rate-limit: + specifier: ^8.2.1 + version: 8.3.1(express@5.2.1) + pkce-challenge: + specifier: catalog:runtimeShared + version: 5.0.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + '@types/express-serve-static-core': + specifier: catalog:devTools + version: 5.1.1 + '@types/supertest': + specifier: catalog:devTools + version: 6.0.3 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + prettier: + specifier: catalog:devTools + version: 3.6.2 + supertest: + specifier: catalog:devTools + version: 7.2.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + test/conformance: devDependencies: '@modelcontextprotocol/client': @@ -7129,15 +7208,14 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) @@ -7151,7 +7229,7 @@ snapshots: eslint: 9.39.4 eslint-compat-utils: 0.5.1(eslint@9.39.4) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7162,7 +7240,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7173,8 +7251,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack From dea6da2f98e4cd20c03934e46c603c35112e9070 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 17 Apr 2026 17:14:05 +0000 Subject: [PATCH 16/55] =?UTF-8?q?fix:=20Node10=20resolution=20compat=20?= =?UTF-8?q?=E2=80=94=20top-level=20types=20field=20+=20typesVersions;=20se?= =?UTF-8?q?tRequestHandler(ZodSchema)=20overload=20on=20Client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds types: ./dist/index.d.mts and typesVersions to all packages so consumers using moduleResolution: node (Node10) resolve subpath types. Fixes the StandardSchemaV1 constraint on the setRequestHandler ZodSchema overload. --- packages/client/package.json | 193 +++---- packages/client/src/client/client.ts | 21 + packages/core/package.json | 176 +++--- packages/middleware/express/package.json | 129 ++--- packages/middleware/fastify/package.json | 125 ++--- packages/middleware/hono/package.json | 125 ++--- packages/middleware/node/package.json | 137 ++--- packages/sdk/package.json | 658 +++++++++++------------ packages/sdk/tsdown.config.ts | 2 +- packages/server-auth-legacy/package.json | 160 +++--- packages/server/package.json | 192 ++++--- packages/server/src/server/mcpServer.ts | 2 +- 12 files changed, 992 insertions(+), 928 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index cf9dbff6b..e6a8b3440 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,96 +1,107 @@ { - "name": "@modelcontextprotocol/client", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Client package", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + "name": "@modelcontextprotocol/client", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Client package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "client" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" }, - "engines": { - "node": ">=20" + "./validators/cf-worker": { + "types": "./dist/validators/cfWorker.d.mts", + "import": "./dist/validators/cfWorker.mjs" }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "client" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./validators/cf-worker": { - "types": "./dist/validators/cfWorker.d.mts", - "import": "./dist/validators/cfWorker.mjs" - }, - "./_shims": { - "workerd": { - "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" - }, - "browser": { - "types": "./dist/shimsBrowser.d.mts", - "import": "./dist/shimsBrowser.mjs" - }, - "node": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - }, - "default": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - } - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "cross-spawn": "catalog:runtimeClientOnly", - "eventsource": "catalog:runtimeClientOnly", - "eventsource-parser": "catalog:runtimeClientOnly", - "jose": "catalog:runtimeClientOnly", - "pkce-challenge": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@cfworker/json-schema": "catalog:runtimeShared", - "@types/content-type": "catalog:devTools", - "@types/cross-spawn": "catalog:devTools", - "@types/eventsource": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "@eslint/js": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools", - "tsdown": "catalog:devTools" + "./_shims": { + "workerd": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "browser": { + "types": "./dist/shimsBrowser.d.mts", + "import": "./dist/shimsBrowser.mjs" + }, + "node": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + }, + "default": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "cross-spawn": "catalog:runtimeClientOnly", + "eventsource": "catalog:runtimeClientOnly", + "eventsource-parser": "catalog:runtimeClientOnly", + "jose": "catalog:runtimeClientOnly", + "pkce-challenge": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@cfworker/json-schema": "catalog:runtimeShared", + "@types/content-type": "catalog:devTools", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "@eslint/js": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools", + "tsdown": "catalog:devTools" + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "stdio": [ + "./dist/stdio.d.mts" + ], + "*": [ + "./dist/*.d.mts" + ] } + } } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index fe84650f7..a702c91f5 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -37,9 +37,11 @@ import type { RequestMethod, RequestOptions, RequestTypeMap, + Result, ResultTypeMap, SchemaOutput, ServerCapabilities, + StandardSchemaV1, StreamDriverOptions, SubscribeRequest, TaskManager, @@ -339,7 +341,26 @@ export class Client { setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise + ): void; + /** @deprecated Pass a method string instead of a Zod request schema. */ + setRequestHandler( + schema: S, + handler: ( + request: S extends StandardSchemaV1 ? O : JSONRPCRequest, + ctx: ClientContext + ) => Result | Promise + ): void; + setRequestHandler( + methodOrSchema: RequestMethod | { shape: { method: unknown } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: (request: any, ctx: ClientContext) => Result | Promise ): void { + const method = ( + typeof methodOrSchema === 'string' + ? methodOrSchema + : ((methodOrSchema.shape.method as { value?: string })?.value ?? + (methodOrSchema.shape.method as { _def?: { value?: string } })?._def?.value) + ) as RequestMethod; this._assertRequestHandlerCapability(method); if (method === 'elicitation/create') { diff --git a/packages/core/package.json b/packages/core/package.json index b5858ff00..7b55e91c1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,87 +1,101 @@ { - "name": "@modelcontextprotocol/core", - "private": true, - "version": "2.0.0-alpha.1", - "description": "Model Context Protocol implementation for TypeScript - Core package", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + "name": "@modelcontextprotocol/core", + "private": true, + "version": "2.0.0-alpha.1", + "description": "Model Context Protocol implementation for TypeScript - Core package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "core" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs" }, - "engines": { - "node": ">=20" + "./types": { + "types": "./src/exports/types/index.ts", + "import": "./src/exports/types/index.ts" }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "core" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs" - }, - "./types": { - "types": "./src/exports/types/index.ts", - "import": "./src/exports/types/index.ts" - }, - "./public": { - "types": "./src/exports/public/index.ts", - "import": "./src/exports/public/index.ts" - } - }, - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "ajv": "catalog:runtimeShared", - "ajv-formats": "catalog:runtimeShared", - "json-schema-typed": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "peerDependencies": { - "@cfworker/json-schema": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "./public": { + "types": "./src/exports/public/index.ts", + "import": "./src/exports/public/index.ts" + } + }, + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "ajv": "catalog:runtimeShared", + "ajv-formats": "catalog:runtimeShared", + "json-schema-typed": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true }, - "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@cfworker/json-schema": "catalog:runtimeShared", - "@eslint/js": "catalog:devTools", - "@types/content-type": "catalog:devTools", - "@types/cors": "catalog:devTools", - "@types/cross-spawn": "catalog:devTools", - "@types/eventsource": "catalog:devTools", - "@types/express": "catalog:devTools", - "@types/express-serve-static-core": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "zod": { + "optional": false + } + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@cfworker/json-schema": "catalog:runtimeShared", + "@eslint/js": "catalog:devTools", + "@types/content-type": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "public": [ + "./dist/exports/public/index.d.mts" + ], + "types": [ + "./dist/types/index.d.mts" + ], + "*": [ + "./dist/*.d.mts" + ] } + } } diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 39d671b81..ebdc75f56 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -1,67 +1,68 @@ { - "name": "@modelcontextprotocol/express", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "express", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "npm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": {}, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "express": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@types/express": "catalog:devTools", - "@types/express-serve-static-core": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "name": "@modelcontextprotocol/express", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "express", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "express": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index d3d4c352b..40ded8c11 100644 --- a/packages/middleware/fastify/package.json +++ b/packages/middleware/fastify/package.json @@ -1,65 +1,66 @@ { - "name": "@modelcontextprotocol/fastify", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "fastify", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "npm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "npm run typecheck && npm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": {}, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "fastify": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "name": "@modelcontextprotocol/fastify", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "fastify", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "fastify": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index f23c9ccb6..b386e9fba 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -1,65 +1,66 @@ { - "name": "@modelcontextprotocol/hono", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "hono", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": {}, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "hono": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "name": "@modelcontextprotocol/hono", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "hono", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "hono": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index 7fcaf9106..f32e0cb7b 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -1,71 +1,72 @@ { - "name": "@modelcontextprotocol/node", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "node.js", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "@hono/node-server": "catalog:runtimeServerOnly" - }, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "hono": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "name": "@modelcontextprotocol/node", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "node.js", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "hono": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fc1aec119..a3fd75c86 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,332 +1,332 @@ { - "name": "@modelcontextprotocol/sdk", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Full SDK (re-exports client, server, and node middleware)", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "types": "./dist/index.d.ts", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs" - }, - "./stdio": { - "types": "./dist/stdio.d.ts", - "import": "./dist/stdio.mjs" - }, - "./types.js": { - "types": "./dist/types.d.ts", - "import": "./dist/types.mjs" - }, - "./types": { - "types": "./dist/types.d.ts", - "import": "./dist/types.mjs" - }, - "./server/index.js": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.mjs" - }, - "./server/index": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.mjs" - }, - "./server/mcp.js": { - "types": "./dist/server/mcp.d.ts", - "import": "./dist/server/mcp.mjs" - }, - "./server/mcp": { - "types": "./dist/server/mcp.d.ts", - "import": "./dist/server/mcp.mjs" - }, - "./server/zod-compat.js": { - "types": "./dist/server/zod-compat.d.ts", - "import": "./dist/server/zod-compat.mjs" - }, - "./server/zod-compat": { - "types": "./dist/server/zod-compat.d.ts", - "import": "./dist/server/zod-compat.mjs" - }, - "./server/stdio.js": { - "types": "./dist/server/stdio.d.ts", - "import": "./dist/server/stdio.mjs" - }, - "./server/stdio": { - "types": "./dist/server/stdio.d.ts", - "import": "./dist/server/stdio.mjs" - }, - "./server/streamableHttp.js": { - "types": "./dist/server/streamableHttp.d.ts", - "import": "./dist/server/streamableHttp.mjs" - }, - "./server/streamableHttp": { - "types": "./dist/server/streamableHttp.d.ts", - "import": "./dist/server/streamableHttp.mjs" - }, - "./server/auth/types.js": { - "types": "./dist/server/auth/types.d.ts", - "import": "./dist/server/auth/types.mjs" - }, - "./server/auth/types": { - "types": "./dist/server/auth/types.d.ts", - "import": "./dist/server/auth/types.mjs" - }, - "./server/auth/errors.js": { - "types": "./dist/server/auth/errors.d.ts", - "import": "./dist/server/auth/errors.mjs" - }, - "./server/auth/errors": { - "types": "./dist/server/auth/errors.d.ts", - "import": "./dist/server/auth/errors.mjs" - }, - "./client": { - "types": "./dist/client/index.d.ts", - "import": "./dist/client/index.mjs" - }, - "./client/index.js": { - "types": "./dist/client/index.d.ts", - "import": "./dist/client/index.mjs" - }, - "./client/index": { - "types": "./dist/client/index.d.ts", - "import": "./dist/client/index.mjs" - }, - "./client/stdio.js": { - "types": "./dist/client/stdio.d.ts", - "import": "./dist/client/stdio.mjs" - }, - "./client/stdio": { - "types": "./dist/client/stdio.d.ts", - "import": "./dist/client/stdio.mjs" - }, - "./client/streamableHttp.js": { - "types": "./dist/client/streamableHttp.d.ts", - "import": "./dist/client/streamableHttp.mjs" - }, - "./client/streamableHttp": { - "types": "./dist/client/streamableHttp.d.ts", - "import": "./dist/client/streamableHttp.mjs" - }, - "./client/sse.js": { - "types": "./dist/client/sse.d.ts", - "import": "./dist/client/sse.mjs" - }, - "./client/sse": { - "types": "./dist/client/sse.d.ts", - "import": "./dist/client/sse.mjs" - }, - "./client/auth.js": { - "types": "./dist/client/auth.d.ts", - "import": "./dist/client/auth.mjs" - }, - "./client/auth": { - "types": "./dist/client/auth.d.ts", - "import": "./dist/client/auth.mjs" - }, - "./shared/protocol.js": { - "types": "./dist/shared/protocol.d.ts", - "import": "./dist/shared/protocol.mjs" - }, - "./shared/protocol": { - "types": "./dist/shared/protocol.d.ts", - "import": "./dist/shared/protocol.mjs" - }, - "./shared/transport.js": { - "types": "./dist/shared/transport.d.ts", - "import": "./dist/shared/transport.mjs" - }, - "./shared/transport": { - "types": "./dist/shared/transport.d.ts", - "import": "./dist/shared/transport.mjs" - }, - "./shared/auth.js": { - "types": "./dist/shared/auth.d.ts", - "import": "./dist/shared/auth.mjs" - }, - "./shared/auth": { - "types": "./dist/shared/auth.d.ts", - "import": "./dist/shared/auth.mjs" - }, - "./server/auth/middleware/bearerAuth.js": { - "types": "./dist/server/auth/middleware/bearerAuth.d.ts", - "import": "./dist/server/auth/middleware/bearerAuth.mjs" - }, - "./server/auth/middleware/bearerAuth": { - "types": "./dist/server/auth/middleware/bearerAuth.d.ts", - "import": "./dist/server/auth/middleware/bearerAuth.mjs" - }, - "./server/auth/router.js": { - "types": "./dist/server/auth/router.d.ts", - "import": "./dist/server/auth/router.mjs" - }, - "./server/auth/router": { - "types": "./dist/server/auth/router.d.ts", - "import": "./dist/server/auth/router.mjs" - }, - "./server/auth/provider.js": { - "types": "./dist/server/auth/provider.d.ts", - "import": "./dist/server/auth/provider.mjs" - }, - "./server/auth/provider": { - "types": "./dist/server/auth/provider.d.ts", - "import": "./dist/server/auth/provider.mjs" - }, - "./server/auth/clients.js": { - "types": "./dist/server/auth/clients.d.ts", - "import": "./dist/server/auth/clients.mjs" - }, - "./server/auth/clients": { - "types": "./dist/server/auth/clients.d.ts", - "import": "./dist/server/auth/clients.mjs" - }, - "./inMemory.js": { - "types": "./dist/inMemory.d.ts", - "import": "./dist/inMemory.mjs" - }, - "./inMemory": { - "types": "./dist/inMemory.d.ts", - "import": "./dist/inMemory.mjs" - }, - "./server/completable.js": { - "types": "./dist/server/completable.d.ts", - "import": "./dist/server/completable.mjs" - }, - "./server/completable": { - "types": "./dist/server/completable.d.ts", - "import": "./dist/server/completable.mjs" - }, - "./server/sse.js": { - "types": "./dist/server/sse.d.ts", - "import": "./dist/server/sse.mjs" - }, - "./server/sse": { - "types": "./dist/server/sse.d.ts", - "import": "./dist/server/sse.mjs" - }, - "./experimental/tasks": { - "types": "./dist/experimental/tasks.d.ts", - "import": "./dist/experimental/tasks.mjs" - }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.mjs" - }, - "./server.js": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.mjs" - }, - "./client.js": { - "types": "./dist/client/index.d.ts", - "import": "./dist/client/index.mjs" - }, - "./server/webStandardStreamableHttp.js": { - "types": "./dist/server/webStandardStreamableHttp.d.ts", - "import": "./dist/server/webStandardStreamableHttp.mjs" - }, - "./server/webStandardStreamableHttp": { - "types": "./dist/server/webStandardStreamableHttp.d.ts", - "import": "./dist/server/webStandardStreamableHttp.mjs" - }, - "./shared/stdio.js": { - "types": "./dist/shared/stdio.d.ts", - "import": "./dist/shared/stdio.mjs" - }, - "./shared/stdio": { - "types": "./dist/shared/stdio.d.ts", - "import": "./dist/shared/stdio.mjs" - }, - "./validation/types.js": { - "types": "./dist/validation/types.d.ts", - "import": "./dist/validation/types.mjs" - }, - "./validation/types": { - "types": "./dist/validation/types.d.ts", - "import": "./dist/validation/types.mjs" - }, - "./validation/cfworker-provider.js": { - "types": "./dist/validation/cfworker-provider.d.ts", - "import": "./dist/validation/cfworker-provider.mjs" - }, - "./validation/cfworker-provider": { - "types": "./dist/validation/cfworker-provider.d.ts", - "import": "./dist/validation/cfworker-provider.mjs" - }, - "./validation/ajv-provider.js": { - "types": "./dist/validation/ajv-provider.d.ts", - "import": "./dist/validation/ajv-provider.mjs" - }, - "./validation/ajv-provider": { - "types": "./dist/validation/ajv-provider.d.ts", - "import": "./dist/validation/ajv-provider.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown && tsc -p tsconfig.build.json", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "prepack": "pnpm run build" - }, - "dependencies": { - "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/node": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/server-auth-legacy": "workspace:^" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0", - "hono": "*" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - }, - "hono": { - "optional": true - } - }, - "devDependencies": { - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "vitest": "catalog:devTools", - "zod": "catalog:runtimeShared" - }, - "typesVersions": { - "*": { - "*.js": [ - "dist/*.d.ts" - ], - "*": [ - "dist/*.d.ts", - "dist/*/index.d.ts" - ] - } + "name": "@modelcontextprotocol/sdk", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Full SDK (re-exports client, server, and node middleware)", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./stdio": { + "types": "./dist/stdio.d.mts", + "import": "./dist/stdio.mjs" + }, + "./types.js": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + }, + "./server/index.js": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./server/index": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./server/mcp.js": { + "types": "./dist/server/mcp.d.mts", + "import": "./dist/server/mcp.mjs" + }, + "./server/mcp": { + "types": "./dist/server/mcp.d.mts", + "import": "./dist/server/mcp.mjs" + }, + "./server/zod-compat.js": { + "types": "./dist/server/zod-compat.d.mts", + "import": "./dist/server/zod-compat.mjs" + }, + "./server/zod-compat": { + "types": "./dist/server/zod-compat.d.mts", + "import": "./dist/server/zod-compat.mjs" + }, + "./server/stdio.js": { + "types": "./dist/server/stdio.d.mts", + "import": "./dist/server/stdio.mjs" + }, + "./server/stdio": { + "types": "./dist/server/stdio.d.mts", + "import": "./dist/server/stdio.mjs" + }, + "./server/streamableHttp.js": { + "types": "./dist/server/streamableHttp.d.mts", + "import": "./dist/server/streamableHttp.mjs" + }, + "./server/streamableHttp": { + "types": "./dist/server/streamableHttp.d.mts", + "import": "./dist/server/streamableHttp.mjs" + }, + "./server/auth/types.js": { + "types": "./dist/server/auth/types.d.mts", + "import": "./dist/server/auth/types.mjs" + }, + "./server/auth/types": { + "types": "./dist/server/auth/types.d.mts", + "import": "./dist/server/auth/types.mjs" + }, + "./server/auth/errors.js": { + "types": "./dist/server/auth/errors.d.mts", + "import": "./dist/server/auth/errors.mjs" + }, + "./server/auth/errors": { + "types": "./dist/server/auth/errors.d.mts", + "import": "./dist/server/auth/errors.mjs" + }, + "./client": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./client/index.js": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./client/index": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./client/stdio.js": { + "types": "./dist/client/stdio.d.mts", + "import": "./dist/client/stdio.mjs" + }, + "./client/stdio": { + "types": "./dist/client/stdio.d.mts", + "import": "./dist/client/stdio.mjs" + }, + "./client/streamableHttp.js": { + "types": "./dist/client/streamableHttp.d.mts", + "import": "./dist/client/streamableHttp.mjs" + }, + "./client/streamableHttp": { + "types": "./dist/client/streamableHttp.d.mts", + "import": "./dist/client/streamableHttp.mjs" + }, + "./client/sse.js": { + "types": "./dist/client/sse.d.mts", + "import": "./dist/client/sse.mjs" + }, + "./client/sse": { + "types": "./dist/client/sse.d.mts", + "import": "./dist/client/sse.mjs" + }, + "./client/auth.js": { + "types": "./dist/client/auth.d.mts", + "import": "./dist/client/auth.mjs" + }, + "./client/auth": { + "types": "./dist/client/auth.d.mts", + "import": "./dist/client/auth.mjs" + }, + "./shared/protocol.js": { + "types": "./dist/shared/protocol.d.mts", + "import": "./dist/shared/protocol.mjs" + }, + "./shared/protocol": { + "types": "./dist/shared/protocol.d.mts", + "import": "./dist/shared/protocol.mjs" + }, + "./shared/transport.js": { + "types": "./dist/shared/transport.d.mts", + "import": "./dist/shared/transport.mjs" + }, + "./shared/transport": { + "types": "./dist/shared/transport.d.mts", + "import": "./dist/shared/transport.mjs" + }, + "./shared/auth.js": { + "types": "./dist/shared/auth.d.mts", + "import": "./dist/shared/auth.mjs" + }, + "./shared/auth": { + "types": "./dist/shared/auth.d.mts", + "import": "./dist/shared/auth.mjs" + }, + "./server/auth/middleware/bearerAuth.js": { + "types": "./dist/server/auth/middleware/bearerAuth.d.mts", + "import": "./dist/server/auth/middleware/bearerAuth.mjs" + }, + "./server/auth/middleware/bearerAuth": { + "types": "./dist/server/auth/middleware/bearerAuth.d.mts", + "import": "./dist/server/auth/middleware/bearerAuth.mjs" + }, + "./server/auth/router.js": { + "types": "./dist/server/auth/router.d.mts", + "import": "./dist/server/auth/router.mjs" + }, + "./server/auth/router": { + "types": "./dist/server/auth/router.d.mts", + "import": "./dist/server/auth/router.mjs" + }, + "./server/auth/provider.js": { + "types": "./dist/server/auth/provider.d.mts", + "import": "./dist/server/auth/provider.mjs" + }, + "./server/auth/provider": { + "types": "./dist/server/auth/provider.d.mts", + "import": "./dist/server/auth/provider.mjs" + }, + "./server/auth/clients.js": { + "types": "./dist/server/auth/clients.d.mts", + "import": "./dist/server/auth/clients.mjs" + }, + "./server/auth/clients": { + "types": "./dist/server/auth/clients.d.mts", + "import": "./dist/server/auth/clients.mjs" + }, + "./inMemory.js": { + "types": "./dist/inMemory.d.mts", + "import": "./dist/inMemory.mjs" + }, + "./inMemory": { + "types": "./dist/inMemory.d.mts", + "import": "./dist/inMemory.mjs" + }, + "./server/completable.js": { + "types": "./dist/server/completable.d.mts", + "import": "./dist/server/completable.mjs" + }, + "./server/completable": { + "types": "./dist/server/completable.d.mts", + "import": "./dist/server/completable.mjs" + }, + "./server/sse.js": { + "types": "./dist/server/sse.d.mts", + "import": "./dist/server/sse.mjs" + }, + "./server/sse": { + "types": "./dist/server/sse.d.mts", + "import": "./dist/server/sse.mjs" + }, + "./experimental/tasks": { + "types": "./dist/experimental/tasks.d.mts", + "import": "./dist/experimental/tasks.mjs" + }, + "./server": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./server.js": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./client.js": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./server/webStandardStreamableHttp.js": { + "types": "./dist/server/webStandardStreamableHttp.d.mts", + "import": "./dist/server/webStandardStreamableHttp.mjs" + }, + "./server/webStandardStreamableHttp": { + "types": "./dist/server/webStandardStreamableHttp.d.mts", + "import": "./dist/server/webStandardStreamableHttp.mjs" + }, + "./shared/stdio.js": { + "types": "./dist/shared/stdio.d.mts", + "import": "./dist/shared/stdio.mjs" + }, + "./shared/stdio": { + "types": "./dist/shared/stdio.d.mts", + "import": "./dist/shared/stdio.mjs" + }, + "./validation/types.js": { + "types": "./dist/validation/types.d.mts", + "import": "./dist/validation/types.mjs" + }, + "./validation/types": { + "types": "./dist/validation/types.d.mts", + "import": "./dist/validation/types.mjs" + }, + "./validation/cfworker-provider.js": { + "types": "./dist/validation/cfworker-provider.d.mts", + "import": "./dist/validation/cfworker-provider.mjs" + }, + "./validation/cfworker-provider": { + "types": "./dist/validation/cfworker-provider.d.mts", + "import": "./dist/validation/cfworker-provider.mjs" + }, + "./validation/ajv-provider.js": { + "types": "./dist/validation/ajv-provider.d.mts", + "import": "./dist/validation/ajv-provider.mjs" + }, + "./validation/ajv-provider": { + "types": "./dist/validation/ajv-provider.d.mts", + "import": "./dist/validation/ajv-provider.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "prepack": "pnpm run build" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/server-auth-legacy": "workspace:^" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0", + "hono": "*" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "hono": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "vitest": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "typesVersions": { + "*": { + "*.js": [ + "dist/*.d.mts" + ], + "*": [ + "dist/*.d.mts", + "dist/*/index.d.mts" + ] } + } } diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index 8c6595654..b6ee521f8 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -41,6 +41,6 @@ export default defineConfig({ sourcemap: true, target: 'esnext', platform: 'node', - dts: false, + dts: true, external: [/^@modelcontextprotocol\//] }); diff --git a/packages/server-auth-legacy/package.json b/packages/server-auth-legacy/package.json index 0329a06ca..fdbb97cb4 100644 --- a/packages/server-auth-legacy/package.json +++ b/packages/server-auth-legacy/package.json @@ -1,83 +1,83 @@ { - "name": "@modelcontextprotocol/server-auth-legacy", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Frozen v1 OAuth Authorization Server helpers (mcpAuthRouter, ProxyOAuthServerProvider) for the Model Context Protocol TypeScript SDK. Deprecated; use a dedicated OAuth server in production.", - "deprecated": "The MCP SDK no longer ships an Authorization Server implementation. This package is a frozen copy of the v1 src/server/auth helpers for migration purposes only and will not receive new features. Use a dedicated OAuth Authorization Server (e.g. an IdP) and the Resource Server helpers in @modelcontextprotocol/express instead.", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "oauth", - "express", - "legacy" - ], - "types": "./dist/index.d.mts", - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "npm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "cors": "catalog:runtimeServerOnly", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "peerDependencies": { - "express": "catalog:runtimeServerOnly" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - }, - "devDependencies": { - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@types/cors": "catalog:devTools", - "@types/express": "catalog:devTools", - "@types/express-serve-static-core": "catalog:devTools", - "@types/supertest": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "express": "catalog:runtimeServerOnly", - "prettier": "catalog:devTools", - "supertest": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "name": "@modelcontextprotocol/server-auth-legacy", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Frozen v1 OAuth Authorization Server helpers (mcpAuthRouter, ProxyOAuthServerProvider) for the Model Context Protocol TypeScript SDK. Deprecated; use a dedicated OAuth server in production.", + "deprecated": "The MCP SDK no longer ships an Authorization Server implementation. This package is a frozen copy of the v1 src/server/auth helpers for migration purposes only and will not receive new features. Use a dedicated OAuth Authorization Server (e.g. an IdP) and the Resource Server helpers in @modelcontextprotocol/express instead.", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "oauth", + "express", + "legacy" + ], + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "cors": "catalog:runtimeServerOnly", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependencies": { + "express": "catalog:runtimeServerOnly" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@types/supertest": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "express": "catalog:runtimeServerOnly", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } } diff --git a/packages/server/package.json b/packages/server/package.json index b2f0dc6ee..226b54efc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,95 +1,109 @@ { - "name": "@modelcontextprotocol/server", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Server package", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + "name": "@modelcontextprotocol/server", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Server package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "server" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" }, - "engines": { - "node": ">=20" + "./zod-schemas": { + "types": "./dist/zodSchemas.d.mts", + "import": "./dist/zodSchemas.mjs" }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "server" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./zod-schemas": { - "types": "./dist/zodSchemas.d.mts", - "import": "./dist/zodSchemas.mjs" - }, - "./validators/cf-worker": { - "types": "./dist/validators/cfWorker.d.mts", - "import": "./dist/validators/cfWorker.mjs" - }, - "./_shims": { - "workerd": { - "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" - }, - "browser": { - "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" - }, - "node": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - }, - "default": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - } - } + "./validators/cf-worker": { + "types": "./dist/validators/cfWorker.d.mts", + "import": "./dist/validators/cfWorker.mjs" }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@cfworker/json-schema": "catalog:runtimeShared", - "@eslint/js": "catalog:devTools", - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@types/cross-spawn": "catalog:devTools", - "@types/eventsource": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "supertest": "catalog:devTools", - "tsdown": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "./_shims": { + "workerd": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "browser": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "node": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + }, + "default": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "zod-schemas": [ + "./dist/zodSchemas.d.mts" + ], + "validators/cf-worker": [ + "./dist/validators/cfWorker.d.mts" + ], + "*": [ + "./dist/*.d.mts" + ] } + } } diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 3c7d5d252..4ee7d28c3 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -449,7 +449,7 @@ export class McpServer extends Dispatcher implements RegistriesHo public override setRequestHandler( schema: S, handler: ( - request: S extends StandardSchemaV1 ? O : JSONRPCRequest, + request: S extends StandardSchemaV1 ? O : JSONRPCRequest, ctx: ServerContext ) => Result | Promise ): void; From 679d5477b46c53bf7be05b07260b6179859a64df Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 10:40:41 +0000 Subject: [PATCH 17/55] refactor: gut old protocol.ts/mcp.ts/server.ts to thin compat wrappers - New core/shared/context.ts holds BaseContext/ServerContext/ClientContext/ RequestOptions/NotificationOptions/ProtocolOptions/ProgressCallback/ DEFAULT_REQUEST_TIMEOUT_MSEC/mergeCapabilities (extracted from protocol.ts) - protocol.ts: re-exports context.ts + thin Protocol class composing Dispatcher + StreamDriver. v1-signature buildContext preserved; assert* methods are now concrete no-ops. _taskManager/_responseHandlers proxied to driver for test-compat. - streamDriver.ts: add _closed flag so debounced notifications scheduled before close() do not send after it (matches old Protocol behavior) - server/mcp.ts and server/server.ts: now pure re-exports of mcpServer.ts/ compat.ts - protocol.test.ts: 6 toHaveBeenCalledWith assertions accept the relatedRequestId second arg StreamDriver passes; 2 progress-stop-on- terminal tests use ctx.task.store path (matching the (completed) variant); override modifiers added now that Protocol's hooks are concrete. LOC: protocol.ts 1105->249, mcp.ts 1329->5, server.ts 677->7. context.ts +297. 1685/1685 tests green. --- packages/core/src/shared/context.ts | 296 ++++ packages/core/src/shared/dispatcher.ts | 2 +- packages/core/src/shared/protocol.ts | 1130 ++------------ packages/core/src/shared/streamDriver.ts | 11 +- packages/core/src/shared/taskManager.ts | 2 +- packages/core/test/shared/protocol.test.ts | 113 +- .../shared/protocolTransportHandling.test.ts | 12 +- packages/server/src/server/mcp.ts | 1330 +---------------- packages/server/src/server/server.ts | 680 +-------- 9 files changed, 503 insertions(+), 3073 deletions(-) create mode 100644 packages/core/src/shared/context.ts diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts new file mode 100644 index 000000000..09b0cae12 --- /dev/null +++ b/packages/core/src/shared/context.ts @@ -0,0 +1,296 @@ +import type { + AuthInfo, + ClientCapabilities, + CreateMessageRequest, + CreateMessageResult, + CreateMessageResultWithTools, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + LoggingLevel, + Notification, + Progress, + RelatedTaskMetadata, + RequestId, + RequestMeta, + RequestMethod, + ResultTypeMap, + ServerCapabilities, + TaskCreationParams +} from '../types/index.js'; +import type { TaskContext, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; +import type { TransportSendOptions } from './transport.js'; + +/** + * Callback for progress notifications. + */ +export type ProgressCallback = (progress: Progress) => void; + +/** + * Additional initialization options. + */ +export type ProtocolOptions = { + /** + * Protocol versions supported. First version is preferred (sent by client, + * used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}. + * + * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} + */ + supportedProtocolVersions?: string[]; + + /** + * Whether to restrict emitted requests to only those that the remote side has indicated that they can handle, through their advertised capabilities. + * + * Note that this DOES NOT affect checking of _local_ side capabilities, as it is considered a logic error to mis-specify those. + * + * Currently this defaults to `false`, for backwards compatibility with SDK versions that did not advertise capabilities correctly. In future, this will default to `true`. + */ + enforceStrictCapabilities?: boolean; + /** + * An array of notification method names that should be automatically debounced. + * Any notifications with a method in this list will be coalesced if they + * occur in the same tick of the event loop. + * e.g., `['notifications/tools/list_changed']` + */ + debouncedNotificationMethods?: string[]; + + /** + * Runtime configuration for task management. + * If provided, creates a TaskManager with the given options; otherwise a NullTaskManager is used. + * + * Capability assertions are wired automatically from the protocol's + * `assertTaskCapability()` and `assertTaskHandlerCapability()` methods, + * so they should NOT be included here. + */ + tasks?: TaskManagerOptions; +}; + +/** + * The default request timeout, in milliseconds. + */ +export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000; + +/** + * Options that can be given per request. + */ +export type RequestOptions = { + /** + * If set, requests progress notifications from the remote end (if supported). When progress notifications are received, this callback will be invoked. + * + * For task-augmented requests: progress notifications continue after {@linkcode CreateTaskResult} is returned and stop automatically when the task reaches a terminal status. + */ + onprogress?: ProgressCallback; + + /** + * Can be used to cancel an in-flight request. This will cause an `AbortError` to be raised from {@linkcode Protocol.request | request()}. + */ + signal?: AbortSignal; + + /** + * A timeout (in milliseconds) for this request. If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised from {@linkcode Protocol.request | request()}. + * + * If not specified, {@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC} will be used as the timeout. + */ + timeout?: number; + + /** + * If `true`, receiving a progress notification will reset the request timeout. + * This is useful for long-running operations that send periodic progress updates. + * Default: `false` + */ + resetTimeoutOnProgress?: boolean; + + /** + * Maximum total time (in milliseconds) to wait for a response. + * If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised, regardless of progress notifications. + * If not specified, there is no maximum total timeout. + */ + maxTotalTimeout?: number; + + /** + * If provided, augments the request with task creation parameters to enable call-now, fetch-later execution patterns. + */ + task?: TaskCreationParams; + + /** + * If provided, associates this request with a related task. + */ + relatedTask?: RelatedTaskMetadata; +} & TransportSendOptions; + +/** + * Options that can be given per notification. + */ +export type NotificationOptions = { + /** + * May be used to indicate to the transport which incoming request to associate this outgoing notification with. + */ + relatedRequestId?: RequestId; + + /** + * If provided, associates this notification with a related task. + */ + relatedTask?: RelatedTaskMetadata; +}; + +/** + * Base context provided to all request handlers. + */ +export type BaseContext = { + /** + * The session ID from the transport, if available. + */ + sessionId?: string; + + /** + * Information about the MCP request being handled. + */ + mcpReq: { + /** + * The JSON-RPC ID of the request being handled. + */ + id: RequestId; + + /** + * The method name of the request (e.g., 'tools/call', 'ping'). + */ + method: string; + + /** + * Metadata from the original request. + */ + _meta?: RequestMeta; + + /** + * An abort signal used to communicate if the request was cancelled from the sender's side. + */ + signal: AbortSignal; + + /** + * Sends a request that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + */ + send: ( + request: { method: M; params?: Record }, + options?: TaskRequestOptions + ) => Promise; + + /** + * Sends a notification that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + */ + notify: (notification: Notification) => Promise; + }; + + /** + * HTTP transport information, only available when using an HTTP-based transport. + */ + http?: { + /** + * Information about a validated access token, provided to request handlers. + */ + authInfo?: AuthInfo; + }; + + /** + * Task context, available when task storage is configured. + */ + task?: TaskContext; + + // ─── v1 flat aliases (deprecated) ──────────────────────────────────── + // v1's RequestHandlerExtra exposed these at the top level. v2 nests them + // under {@linkcode mcpReq} / {@linkcode http}. The flat forms are kept + // typed (and populated at runtime by McpServer.buildContext) so v1 handler + // code keeps compiling. Prefer the nested paths for new code. + + /** @deprecated Use {@linkcode mcpReq.signal}. */ + signal?: AbortSignal; + /** @deprecated Use {@linkcode mcpReq.id}. */ + requestId?: RequestId; + /** @deprecated Use {@linkcode mcpReq._meta}. */ + _meta?: RequestMeta; + /** @deprecated Use {@linkcode mcpReq.notify}. */ + sendNotification?: (notification: Notification) => Promise; + /** @deprecated Use {@linkcode mcpReq.send}. */ + sendRequest?: ( + request: { method: M; params?: Record }, + options?: TaskRequestOptions + ) => Promise; + /** @deprecated Use {@linkcode http.authInfo}. */ + authInfo?: AuthInfo; + /** @deprecated v1 carried raw request info here. v2 surfaces the web `Request` via {@linkcode ServerContext.http}. */ + requestInfo?: globalThis.Request; +}; + +/** + * Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields. + */ +export type ServerContext = BaseContext & { + mcpReq: { + /** + * Send a log message notification to the client. + * Respects the client's log level filter set via logging/setLevel. + */ + log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; + + /** + * Send an elicitation request to the client, requesting user input. + */ + elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; + + /** + * Request LLM sampling from the client. + */ + requestSampling: ( + params: CreateMessageRequest['params'], + options?: RequestOptions + ) => Promise; + }; + + http?: { + /** + * The original HTTP request. + */ + req?: globalThis.Request; + + /** + * Closes the SSE stream for this request, triggering client reconnection. + * Only available when using a StreamableHTTPServerTransport with eventStore configured. + */ + closeSSE?: () => void; + + /** + * Closes the standalone GET SSE stream, triggering client reconnection. + * Only available when using a StreamableHTTPServerTransport with eventStore configured. + */ + closeStandaloneSSE?: () => void; + }; +}; + +/** + * Context provided to client-side request handlers. + */ +export type ClientContext = BaseContext; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function mergeCapabilities(base: ServerCapabilities, additional: Partial): ServerCapabilities; +export function mergeCapabilities(base: ClientCapabilities, additional: Partial): ClientCapabilities; +export function mergeCapabilities(base: T, additional: Partial): T { + const result: T = { ...base }; + for (const key in additional) { + const k = key as keyof T; + const addValue = additional[k]; + if (addValue === undefined) continue; + const baseValue = result[k]; + result[k] = + isPlainObject(baseValue) && isPlainObject(addValue) + ? ({ ...(baseValue as Record), ...(addValue as Record) } as T[typeof k]) + : (addValue as T[typeof k]); + } + return result; +} diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 1a452f192..f1d01494c 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -16,7 +16,7 @@ import type { ResultTypeMap } from '../types/index.js'; import { getNotificationSchema, getRequestSchema, ProtocolErrorCode } from '../types/index.js'; -import type { BaseContext, RequestOptions } from './protocol.js'; +import type { BaseContext, RequestOptions } from './context.js'; import type { TaskContext } from './taskManager.js'; /** diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index ed43aaf44..5c9f21afc 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1,336 +1,43 @@ +/** + * v1-compat module. The types live in {@link ./context.ts}; the runtime lives in + * {@link Dispatcher} + {@link StreamDriver}. The {@link Protocol} class here is + * a thin wrapper that composes those two so that v1 code subclassing `Protocol` + * keeps working. + */ + import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import type { - AuthInfo, - CancelledNotification, - ClientCapabilities, - CreateMessageRequest, - CreateMessageResult, - CreateMessageResultWithTools, - ElicitRequestFormParams, - ElicitRequestURLParams, - ElicitResult, - JSONRPCErrorResponse, - JSONRPCNotification, JSONRPCRequest, - JSONRPCResponse, - JSONRPCResultResponse, - LoggingLevel, MessageExtraInfo, Notification, NotificationMethod, NotificationTypeMap, - Progress, - ProgressNotification, - RelatedTaskMetadata, Request, - RequestId, - RequestMeta, RequestMethod, RequestTypeMap, Result, - ResultTypeMap, - ServerCapabilities, - TaskCreationParams -} from '../types/index.js'; -import { - getNotificationSchema, - getRequestSchema, - getResultSchema, - isJSONRPCErrorResponse, - isJSONRPCNotification, - isJSONRPCRequest, - isJSONRPCResultResponse, - ProtocolError, - ProtocolErrorCode, - SUPPORTED_PROTOCOL_VERSIONS + ResultTypeMap } from '../types/index.js'; +import { getResultSchema, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; -import { parseSchema } from '../util/schema.js'; -import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; +import type { BaseContext, NotificationOptions, ProtocolOptions, RequestOptions } from './context.js'; +import type { DispatchEnv } from './dispatcher.js'; +import { Dispatcher } from './dispatcher.js'; +import { StreamDriver } from './streamDriver.js'; import { NullTaskManager, TaskManager } from './taskManager.js'; -import type { Transport, TransportSendOptions } from './transport.js'; - -/** - * Callback for progress notifications. - */ -export type ProgressCallback = (progress: Progress) => void; - -/** - * Additional initialization options. - */ -export type ProtocolOptions = { - /** - * Protocol versions supported. First version is preferred (sent by client, - * used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}. - * - * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} - */ - supportedProtocolVersions?: string[]; - - /** - * Whether to restrict emitted requests to only those that the remote side has indicated that they can handle, through their advertised capabilities. - * - * Note that this DOES NOT affect checking of _local_ side capabilities, as it is considered a logic error to mis-specify those. - * - * Currently this defaults to `false`, for backwards compatibility with SDK versions that did not advertise capabilities correctly. In future, this will default to `true`. - */ - enforceStrictCapabilities?: boolean; - /** - * An array of notification method names that should be automatically debounced. - * Any notifications with a method in this list will be coalesced if they - * occur in the same tick of the event loop. - * e.g., `['notifications/tools/list_changed']` - */ - debouncedNotificationMethods?: string[]; - - /** - * Runtime configuration for task management. - * If provided, creates a TaskManager with the given options; otherwise a NullTaskManager is used. - * - * Capability assertions are wired automatically from the protocol's - * `assertTaskCapability()` and `assertTaskHandlerCapability()` methods, - * so they should NOT be included here. - */ - tasks?: TaskManagerOptions; -}; - -/** - * The default request timeout, in milliseconds. - */ -export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000; - -/** - * Options that can be given per request. - */ -export type RequestOptions = { - /** - * If set, requests progress notifications from the remote end (if supported). When progress notifications are received, this callback will be invoked. - * - * For task-augmented requests: progress notifications continue after {@linkcode CreateTaskResult} is returned and stop automatically when the task reaches a terminal status. - */ - onprogress?: ProgressCallback; - - /** - * Can be used to cancel an in-flight request. This will cause an `AbortError` to be raised from {@linkcode Protocol.request | request()}. - */ - signal?: AbortSignal; - - /** - * A timeout (in milliseconds) for this request. If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised from {@linkcode Protocol.request | request()}. - * - * If not specified, {@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC} will be used as the timeout. - */ - timeout?: number; - - /** - * If `true`, receiving a progress notification will reset the request timeout. - * This is useful for long-running operations that send periodic progress updates. - * Default: `false` - */ - resetTimeoutOnProgress?: boolean; - - /** - * Maximum total time (in milliseconds) to wait for a response. - * If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised, regardless of progress notifications. - * If not specified, there is no maximum total timeout. - */ - maxTotalTimeout?: number; - - /** - * If provided, augments the request with task creation parameters to enable call-now, fetch-later execution patterns. - */ - task?: TaskCreationParams; - - /** - * If provided, associates this request with a related task. - */ - relatedTask?: RelatedTaskMetadata; -} & TransportSendOptions; - -/** - * Options that can be given per notification. - */ -export type NotificationOptions = { - /** - * May be used to indicate to the transport which incoming request to associate this outgoing notification with. - */ - relatedRequestId?: RequestId; - - /** - * If provided, associates this notification with a related task. - */ - relatedTask?: RelatedTaskMetadata; -}; - -/** - * Base context provided to all request handlers. - */ -export type BaseContext = { - /** - * The session ID from the transport, if available. - */ - sessionId?: string; - - /** - * Information about the MCP request being handled. - */ - mcpReq: { - /** - * The JSON-RPC ID of the request being handled. - */ - id: RequestId; - - /** - * The method name of the request (e.g., 'tools/call', 'ping'). - */ - method: string; - - /** - * Metadata from the original request. - */ - _meta?: RequestMeta; - - /** - * An abort signal used to communicate if the request was cancelled from the sender's side. - */ - signal: AbortSignal; - - /** - * Sends a request that relates to the current request being handled. - * - * This is used by certain transports to correctly associate related messages. - */ - send: ( - request: { method: M; params?: Record }, - options?: TaskRequestOptions - ) => Promise; - - /** - * Sends a notification that relates to the current request being handled. - * - * This is used by certain transports to correctly associate related messages. - */ - notify: (notification: Notification) => Promise; - }; - - /** - * HTTP transport information, only available when using an HTTP-based transport. - */ - http?: { - /** - * Information about a validated access token, provided to request handlers. - */ - authInfo?: AuthInfo; - }; +import type { Transport } from './transport.js'; - /** - * Task context, available when task storage is configured. - */ - task?: TaskContext; - - // ─── v1 flat aliases (deprecated) ──────────────────────────────────── - // v1's RequestHandlerExtra exposed these at the top level. v2 nests them - // under {@linkcode mcpReq} / {@linkcode http}. The flat forms are kept - // typed (and populated at runtime by McpServer.buildContext) so v1 handler - // code keeps compiling. Prefer the nested paths for new code. - - /** @deprecated Use {@linkcode mcpReq.signal}. */ - signal?: AbortSignal; - /** @deprecated Use {@linkcode mcpReq.id}. */ - requestId?: RequestId; - /** @deprecated Use {@linkcode mcpReq._meta}. */ - _meta?: RequestMeta; - /** @deprecated Use {@linkcode mcpReq.notify}. */ - sendNotification?: (notification: Notification) => Promise; - /** @deprecated Use {@linkcode mcpReq.send}. */ - sendRequest?: ( - request: { method: M; params?: Record }, - options?: TaskRequestOptions - ) => Promise; - /** @deprecated Use {@linkcode http.authInfo}. */ - authInfo?: AuthInfo; - /** @deprecated v1 carried raw request info here. v2 surfaces the web `Request` via {@linkcode ServerContext.http}. */ - requestInfo?: globalThis.Request; -}; - -/** - * Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields. - */ -export type ServerContext = BaseContext & { - mcpReq: { - /** - * Send a log message notification to the client. - * Respects the client's log level filter set via logging/setLevel. - */ - log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; - - /** - * Send an elicitation request to the client, requesting user input. - */ - elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; - - /** - * Request LLM sampling from the client. - */ - requestSampling: ( - params: CreateMessageRequest['params'], - options?: RequestOptions - ) => Promise; - }; - - http?: { - /** - * The original HTTP request. - */ - req?: globalThis.Request; - - /** - * Closes the SSE stream for this request, triggering client reconnection. - * Only available when using a StreamableHTTPServerTransport with eventStore configured. - */ - closeSSE?: () => void; - - /** - * Closes the standalone GET SSE stream, triggering client reconnection. - * Only available when using a StreamableHTTPServerTransport with eventStore configured. - */ - closeStandaloneSSE?: () => void; - }; -}; - -/** - * Context provided to client-side request handlers. - */ -export type ClientContext = BaseContext; - -/** - * Information about a request's timeout state - */ -type TimeoutInfo = { - timeoutId: ReturnType; - startTime: number; - timeout: number; - maxTotalTimeout?: number; - resetTimeoutOnProgress: boolean; - onTimeout: () => void; -}; +export * from './context.js'; /** - * Implements MCP protocol framing on top of a pluggable transport, including - * features like request/response linking, notifications, and progress. + * v1-compat MCP protocol base. New code should use {@linkcode McpServer} (which + * extends {@linkcode Dispatcher}) or {@linkcode Client}. This class composes a + * {@linkcode Dispatcher} (handler registry + dispatch) and a + * {@linkcode StreamDriver} (per-connection state) to preserve the v1 surface. */ export abstract class Protocol { - private _transport?: Transport; - private _requestMessageId = 0; - private _requestHandlers: Map Promise> = new Map(); - private _requestHandlerAbortControllers: Map = new Map(); - private _notificationHandlers: Map Promise> = new Map(); - private _responseHandlers: Map void> = new Map(); - private _progressHandlers: Map = new Map(); - private _timeoutInfo: Map = new Map(); - private _pendingDebouncedNotifications = new Set(); - - private _taskManager: TaskManager; + private _driver?: StreamDriver; + private readonly _dispatcher: Dispatcher; protected _supportedProtocolVersions: string[]; @@ -348,459 +55,150 @@ export abstract class Protocol { */ onerror?: (error: Error) => void; - /** - * A handler to invoke for any request types that do not have their own handler installed. - */ - fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; - - /** - * A handler to invoke for any notification types that do not have their own handler installed. - */ - fallbackNotificationHandler?: (notification: Notification) => Promise; - constructor(private _options?: ProtocolOptions) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + this._dispatcher = new (class extends Dispatcher { + protected override buildContext( + base: BaseContext, + env: DispatchEnv & { _transportExtra?: MessageExtraInfo } + ): ContextT { + return self.buildContext(base, env._transportExtra); + } + })(); this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - - // Create TaskManager from protocol options - this._taskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); + this._ownTaskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); this._bindTaskManager(); - - this.setNotificationHandler('notifications/cancelled', notification => { - this._oncancel(notification); - }); - - this.setNotificationHandler('notifications/progress', notification => { - this._onprogress(notification); - }); - - this.setRequestHandler( - 'ping', - // Automatic pong by default. - _request => ({}) as Result - ); } - /** - * Access the TaskManager for task orchestration. - * Always available; returns a NullTaskManager when no task store is configured. - */ - get taskManager(): TaskManager { - return this._taskManager; - } + private readonly _ownTaskManager: TaskManager; private _bindTaskManager(): void { - const taskManager = this._taskManager; - const host: TaskManagerHost = { - request: (request, resultSchema, options) => this._requestWithSchema(request, resultSchema, options), - notification: (notification, options) => this.notification(notification, options), - reportError: error => this._onerror(error), - removeProgressHandler: token => this._progressHandlers.delete(token), - registerHandler: (method, handler) => { - const schema = getRequestSchema(method as RequestMethod); - this._requestHandlers.set(method, (request, ctx) => { - // Validate request params via Zod (strips jsonrpc/id, so we pass original to handler) - schema.parse(request); - return handler(request, ctx); - }); - }, + this._ownTaskManager.bind({ + request: (r, schema, opts) => this._requestWithSchema(r, schema, opts), + notification: (n, opts) => this.notification(n, opts), + reportError: e => this.onerror?.(e), + removeProgressHandler: t => this._driver?.removeProgressHandler(t), + registerHandler: (method, handler) => this._dispatcher.setRawRequestHandler(method, handler), sendOnResponseStream: async (message, relatedRequestId) => { - await this._transport?.send(message, { relatedRequestId }); + await this._driver?.pipe.send(message, { relatedRequestId }); }, enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, - assertTaskCapability: method => this.assertTaskCapability(method), - assertTaskHandlerCapability: method => this.assertTaskHandlerCapability(method) - }; - taskManager.bind(host); - } - - /** - * Builds the context object for request handlers. Subclasses must override - * to return the appropriate context type (e.g., ServerContext adds HTTP request info). - */ - protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; - - private async _oncancel(notification: CancelledNotification): Promise { - if (!notification.params.requestId) { - return; - } - // Handle request cancellation - const controller = this._requestHandlerAbortControllers.get(notification.params.requestId); - controller?.abort(notification.params.reason); - } - - private _setupTimeout( - messageId: number, - timeout: number, - maxTotalTimeout: number | undefined, - onTimeout: () => void, - resetTimeoutOnProgress: boolean = false - ) { - this._timeoutInfo.set(messageId, { - timeoutId: setTimeout(onTimeout, timeout), - startTime: Date.now(), - timeout, - maxTotalTimeout, - resetTimeoutOnProgress, - onTimeout + assertTaskCapability: m => this.assertTaskCapability(m), + assertTaskHandlerCapability: m => this.assertTaskHandlerCapability(m) }); } - private _resetTimeout(messageId: number): boolean { - const info = this._timeoutInfo.get(messageId); - if (!info) return false; - - const totalElapsed = Date.now() - info.startTime; - if (info.maxTotalTimeout && totalElapsed >= info.maxTotalTimeout) { - this._timeoutInfo.delete(messageId); - throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { - maxTotalTimeout: info.maxTotalTimeout, - totalElapsed - }); - } - - clearTimeout(info.timeoutId); - info.timeoutId = setTimeout(info.onTimeout, info.timeout); - return true; - } - - private _cleanupTimeout(messageId: number) { - const info = this._timeoutInfo.get(messageId); - if (info) { - clearTimeout(info.timeoutId); - this._timeoutInfo.delete(messageId); - } - } + // ─────────────────────────────────────────────────────────────────────── + // Subclass hooks (v1 signatures) + // ─────────────────────────────────────────────────────────────────────── /** - * Attaches to the given transport, starts it, and starts listening for messages. - * - * The caller assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward. + * Subclasses override to enrich the handler context. v1 signature; the + * {@linkcode MessageExtraInfo} is forwarded from the transport. */ - async connect(transport: Transport): Promise { - this._transport = transport; - const _onclose = this.transport?.onclose; - this._transport.onclose = () => { - try { - _onclose?.(); - } finally { - this._onclose(); - } - }; - - const _onerror = this.transport?.onerror; - this._transport.onerror = (error: Error) => { - _onerror?.(error); - this._onerror(error); - }; - - const _onmessage = this._transport?.onmessage; - this._transport.onmessage = (message, extra) => { - _onmessage?.(message, extra); - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - this._onresponse(message); - } else if (isJSONRPCRequest(message)) { - this._onrequest(message, extra); - } else if (isJSONRPCNotification(message)) { - this._onnotification(message); - } else { - this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); - } - }; - - // Pass supported protocol versions to transport for header validation - transport.setSupportedProtocolVersions?.(this._supportedProtocolVersions); - - await this._transport.start(); + protected buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ContextT { + return ctx as ContextT; } - private _onclose(): void { - const responseHandlers = this._responseHandlers; - this._responseHandlers = new Map(); - this._progressHandlers.clear(); - this._taskManager.onClose(); - this._pendingDebouncedNotifications.clear(); - - for (const info of this._timeoutInfo.values()) { - clearTimeout(info.timeoutId); - } - this._timeoutInfo.clear(); + /** Override to enforce capabilities. Default is a no-op. */ + protected assertCapabilityForMethod(_method: RequestMethod): void {} + /** Override to enforce capabilities. Default is a no-op. */ + protected assertNotificationCapability(_method: NotificationMethod): void {} + /** Override to enforce capabilities. Default is a no-op. */ + protected assertRequestHandlerCapability(_method: string): void {} + /** Override to enforce capabilities. Default is a no-op. */ + protected assertTaskCapability(_method: string): void {} + /** Override to enforce capabilities. Default is a no-op. */ + protected assertTaskHandlerCapability(_method: string): void {} - const requestHandlerAbortControllers = this._requestHandlerAbortControllers; - this._requestHandlerAbortControllers = new Map(); + // ─────────────────────────────────────────────────────────────────────── + // Handler registration (delegates to Dispatcher) + // ─────────────────────────────────────────────────────────────────────── - const error = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'); - - this._transport = undefined; - - try { - this.onclose?.(); - } finally { - for (const handler of responseHandlers.values()) { - handler(error); - } - - for (const controller of requestHandlerAbortControllers.values()) { - controller.abort(error); - } - } + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise + ): void { + this.assertRequestHandlerCapability(method); + this._dispatcher.setRequestHandler(method, handler); } - private _onerror(error: Error): void { - this.onerror?.(error); + removeRequestHandler(method: string): void { + this._dispatcher.removeRequestHandler(method); } - private _onnotification(notification: JSONRPCNotification): void { - const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; - - // Ignore notifications not being subscribed to. - if (handler === undefined) { - return; - } - - // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => handler(notification)) - .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); + assertCanSetRequestHandler(method: string): void { + this._dispatcher.assertCanSetRequestHandler(method); } - private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; - - // Capture the current transport at request time to ensure responses go to the correct client - const capturedTransport = this._transport; - - // Delegate context extraction to module (if registered) - const inboundCtx = { - sessionId: capturedTransport?.sessionId, - sendNotification: (notification: Notification, options?: NotificationOptions) => - this.notification(notification, { ...options, relatedRequestId: request.id }), - sendRequest: (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }) - }; - - // Delegate to TaskManager for task context, wrapped send/notify, and response routing - const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); - const sendNotification = taskResult.sendNotification; - const sendRequest = taskResult.sendRequest; - const taskContext = taskResult.taskContext; - const routeResponse = taskResult.routeResponse; - const validators: Array<() => void> = []; - if (taskResult.validateInbound) validators.push(taskResult.validateInbound); - - if (handler === undefined) { - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: ProtocolErrorCode.MethodNotFound, - message: 'Method not found' - } - }; - - // Queue or send the error response based on whether this is a task-related request - routeResponse(errorResponse) - .then(routed => { - if (!routed) { - capturedTransport - ?.send(errorResponse) - .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); - } - }) - .catch(error => this._onerror(new Error(`Failed to enqueue error response: ${error}`))); - return; - } - - const abortController = new AbortController(); - this._requestHandlerAbortControllers.set(request.id, abortController); - - const baseCtx: BaseContext = { - sessionId: capturedTransport?.sessionId, - mcpReq: { - id: request.id, - method: request.method, - _meta: request.params?._meta, - signal: abortController.signal, - send: (r: { method: M; params?: Record }, options?: TaskRequestOptions) => { - const resultSchema = getResultSchema(r.method); - return sendRequest(r as Request, resultSchema, options) as Promise; - }, - notify: sendNotification - }, - http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined, - task: taskContext - }; - const ctx = this.buildContext(baseCtx, extra); - - // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => { - for (const validate of validators) { - validate(); - } - }) - .then(() => handler(request, ctx)) - .then( - async result => { - if (abortController.signal.aborted) { - // Request was cancelled - return; - } - - const response: JSONRPCResponse = { - result, - jsonrpc: '2.0', - id: request.id - }; - - // Queue or send the response based on whether this is a task-related request - const routed = await routeResponse(response); - if (!routed) { - await capturedTransport?.send(response); - } - }, - async error => { - if (abortController.signal.aborted) { - // Request was cancelled - return; - } - - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(error['code']) ? error['code'] : ProtocolErrorCode.InternalError, - message: error.message ?? 'Internal error', - ...(error['data'] !== undefined && { data: error['data'] }) - } - }; - - // Queue or send the error response based on whether this is a task-related request - const routed = await routeResponse(errorResponse); - if (!routed) { - await capturedTransport?.send(errorResponse); - } - } - ) - .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) - .finally(() => { - if (this._requestHandlerAbortControllers.get(request.id) === abortController) { - this._requestHandlerAbortControllers.delete(request.id); - } - }); + setNotificationHandler( + method: M, + handler: (notification: NotificationTypeMap[M]) => void | Promise + ): void { + this._dispatcher.setNotificationHandler(method, handler); } - private _onprogress(notification: ProgressNotification): void { - const { progressToken, ...params } = notification.params; - const messageId = Number(progressToken); - - const handler = this._progressHandlers.get(messageId); - if (!handler) { - this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`)); - return; - } - - const responseHandler = this._responseHandlers.get(messageId); - const timeoutInfo = this._timeoutInfo.get(messageId); - - if (timeoutInfo && responseHandler && timeoutInfo.resetTimeoutOnProgress) { - try { - this._resetTimeout(messageId); - } catch (error) { - // Clean up if maxTotalTimeout was exceeded - this._responseHandlers.delete(messageId); - this._progressHandlers.delete(messageId); - this._cleanupTimeout(messageId); - responseHandler(error as Error); - return; - } - } - - handler(params); + removeNotificationHandler(method: string): void { + this._dispatcher.removeNotificationHandler(method); } - private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { - const messageId = Number(response.id); - - // Delegate to TaskManager for task-related response handling - const taskResult = this._taskManager.processInboundResponse(response, messageId); - if (taskResult.consumed) return; - const preserveProgress = taskResult.preserveProgress; - - const handler = this._responseHandlers.get(messageId); - if (handler === undefined) { - this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); - return; - } - - this._responseHandlers.delete(messageId); - this._cleanupTimeout(messageId); - - // Keep progress handler alive for CreateTaskResult responses - if (!preserveProgress) { - this._progressHandlers.delete(messageId); - } - - if (isJSONRPCResultResponse(response)) { - handler(response); - } else { - const error = ProtocolError.fromError(response.error.code, response.error.message, response.error.data); - handler(error); - } + get fallbackRequestHandler(): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { + return this._dispatcher.fallbackRequestHandler; } - - get transport(): Transport | undefined { - return this._transport; + set fallbackRequestHandler(h) { + this._dispatcher.fallbackRequestHandler = h; } - /** - * Closes the connection. - */ - async close(): Promise { - await this._transport?.close(); + get fallbackNotificationHandler(): ((notification: Notification) => Promise) | undefined { + return this._dispatcher.fallbackNotificationHandler; + } + set fallbackNotificationHandler(h) { + this._dispatcher.fallbackNotificationHandler = h; } - /** - * A method to check if a capability is supported by the remote side, for the given method to be called. - * - * This should be implemented by subclasses. - */ - protected abstract assertCapabilityForMethod(method: RequestMethod): void; + // ─────────────────────────────────────────────────────────────────────── + // Connection (delegates to StreamDriver) + // ─────────────────────────────────────────────────────────────────────── /** - * A method to check if a notification is supported by the local side, for the given method to be sent. - * - * This should be implemented by subclasses. + * Connects to a transport. Creates a fresh {@linkcode StreamDriver} per call, + * so re-connecting (the v1 stateful-SHTTP pattern) is supported. */ - protected abstract assertNotificationCapability(method: NotificationMethod): void; + async connect(transport: Transport): Promise { + const driver = new StreamDriver(this._dispatcher, transport, { + supportedProtocolVersions: this._supportedProtocolVersions, + debouncedNotificationMethods: this._options?.debouncedNotificationMethods, + taskManager: this._ownTaskManager, + enforceStrictCapabilities: this._options?.enforceStrictCapabilities, + buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) + }); + this._driver = driver; + driver.onclose = () => { + if (this._driver === driver) this._driver = undefined; + this.onclose?.(); + }; + driver.onerror = error => this.onerror?.(error); + await driver.start(); + } /** - * A method to check if a request handler is supported by the local side, for the given method to be handled. - * - * This should be implemented by subclasses. + * Closes the connection. */ - protected abstract assertRequestHandlerCapability(method: string): void; + async close(): Promise { + await this._driver?.close(); + } - /** - * A method to check if the remote side supports task creation for the given method. - * - * Called when sending a task-augmented outbound request (only when enforceStrictCapabilities is true). - * This should be implemented by subclasses. - */ - protected abstract assertTaskCapability(method: string): void; + get transport(): Transport | undefined { + return this._driver?.pipe; + } - /** - * A method to check if this side supports handling task creation for the given method. - * - * Called when receiving a task-augmented inbound request. - * This should be implemented by subclasses. - */ - protected abstract assertTaskHandlerCapability(method: string): void; + get taskManager(): TaskManager { + return this._ownTaskManager; + } /** - * Sends a request and waits for a response, resolving the result schema - * automatically from the method name. - * - * Do not use this method to emit notifications! Use {@linkcode Protocol.notification | notification()} instead. + * Sends a request and waits for a response. */ request( request: { method: M; params?: Record }, @@ -812,294 +210,44 @@ export abstract class Protocol { /** * Sends a request and waits for a response, using the provided schema for validation. - * - * This is the internal implementation used by SDK methods that need to specify - * a particular result schema (e.g., for compatibility or task-specific schemas). */ protected _requestWithSchema( request: Request, resultSchema: T, options?: RequestOptions ): Promise> { - const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; - - let onAbort: (() => void) | undefined; - let cleanupMessageId: number | undefined; - - // Send the request - return new Promise>((resolve, reject) => { - const earlyReject = (error: unknown) => { - reject(error); - }; - - if (!this._transport) { - earlyReject(new Error('Not connected')); - return; - } - - if (this._options?.enforceStrictCapabilities === true) { - try { - this.assertCapabilityForMethod(request.method as RequestMethod); - } catch (error) { - earlyReject(error); - return; - } - } - - options?.signal?.throwIfAborted(); - - const messageId = this._requestMessageId++; - cleanupMessageId = messageId; - const jsonrpcRequest: JSONRPCRequest = { - ...request, - jsonrpc: '2.0', - id: messageId - }; - - if (options?.onprogress) { - this._progressHandlers.set(messageId, options.onprogress); - jsonrpcRequest.params = { - ...request.params, - _meta: { - ...request.params?._meta, - progressToken: messageId - } - }; - } - - const cancel = (reason: unknown) => { - this._progressHandlers.delete(messageId); - - this._transport - ?.send( - { - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: messageId, - reason: String(reason) - } - }, - { relatedRequestId, resumptionToken, onresumptiontoken } - ) - .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); - - // Wrap the reason in an SdkError if it isn't already - const error = reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); - reject(error); - }; - - this._responseHandlers.set(messageId, response => { - if (options?.signal?.aborted) { - return; - } - - if (response instanceof Error) { - return reject(response); - } - - try { - const parseResult = parseSchema(resultSchema, response.result); - if (parseResult.success) { - resolve(parseResult.data as SchemaOutput); - } else { - reject(parseResult.error); - } - } catch (error) { - reject(error); - } - }); - - onAbort = () => cancel(options?.signal?.reason); - options?.signal?.addEventListener('abort', onAbort, { once: true }); - - const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout })); - - this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - - // Delegate task augmentation and routing to module (if registered) - const responseHandler = (response: JSONRPCResultResponse | Error) => { - const handler = this._responseHandlers.get(messageId); - if (handler) { - handler(response); - } else { - this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); - } - }; - - let outboundQueued = false; - try { - const taskResult = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, error => { - this._progressHandlers.delete(messageId); - reject(error); - }); - if (taskResult.queued) { - outboundQueued = true; - } - } catch (error) { - this._progressHandlers.delete(messageId); - reject(error); - return; - } - - if (!outboundQueued) { - // No related task or no module - send through transport normally - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { - this._progressHandlers.delete(messageId); - reject(error); - }); - } - }).finally(() => { - // Per-request cleanup that must run on every exit path. Consolidated - // here so new exit paths added to the promise body can't forget it. - // _progressHandlers is NOT cleaned up here: _onresponse deletes it - // conditionally (preserveProgress for task flows), and error paths - // above delete it inline since no task exists in those cases. - if (onAbort) { - options?.signal?.removeEventListener('abort', onAbort); - } - if (cleanupMessageId !== undefined) { - this._responseHandlers.delete(cleanupMessageId); - this._cleanupTimeout(cleanupMessageId); - } - }); + if (!this._driver) { + return Promise.reject(new SdkError(SdkErrorCode.NotConnected, 'Not connected')); + } + if (this._options?.enforceStrictCapabilities === true) { + this.assertCapabilityForMethod(request.method as RequestMethod); + } + return this._driver.request(request, resultSchema, options); } /** * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - if (!this._transport) { + if (!this._driver) { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } - this.assertNotificationCapability(notification.method as NotificationMethod); - - // Delegate task-related notification routing and JSONRPC building to TaskManager - const taskResult = await this._taskManager.processOutboundNotification(notification, options); - const queued = taskResult.queued; - const jsonrpcNotification = taskResult.queued ? undefined : taskResult.jsonrpcNotification; - - if (queued) { - // Don't send through transport - queued messages are delivered via tasks/result only - return; - } - - const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; - // A notification can only be debounced if it's in the list AND it's "simple" - // (i.e., has no parameters and no related request ID or related task that could be lost). - const canDebounce = - debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId && !options?.relatedTask; - - if (canDebounce) { - // If a notification of this type is already scheduled, do nothing. - if (this._pendingDebouncedNotifications.has(notification.method)) { - return; - } - - // Mark this notification type as pending. - this._pendingDebouncedNotifications.add(notification.method); - - // Schedule the actual send to happen in the next microtask. - // This allows all synchronous calls in the current event loop tick to be coalesced. - Promise.resolve().then(() => { - // Un-mark the notification so the next one can be scheduled. - this._pendingDebouncedNotifications.delete(notification.method); - - // SAFETY CHECK: If the connection was closed while this was pending, abort. - if (!this._transport) { - return; - } - - // Send the notification, but don't await it here to avoid blocking. - // Handle potential errors with a .catch(). - this._transport?.send(jsonrpcNotification!, options).catch(error => this._onerror(error)); - }); - - // Return immediately. - return; - } - - await this._transport.send(jsonrpcNotification!, options); - } - - /** - * Registers a handler to invoke when this protocol object receives a request with the given method. - * - * Note that this will replace any previous request handler for the same method. - */ - setRequestHandler( - method: M, - handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise - ): void { - this.assertRequestHandlerCapability(method); - const schema = getRequestSchema(method); - - this._requestHandlers.set(method, (request, ctx) => { - const parsed = schema.parse(request) as RequestTypeMap[M]; - return Promise.resolve(handler(parsed, ctx)); - }); - } - - /** - * Removes the request handler for the given method. - */ - removeRequestHandler(method: RequestMethod): void { - this._requestHandlers.delete(method); - } - - /** - * Asserts that a request handler has not already been set for the given method, in preparation for a new one being automatically installed. - */ - assertCanSetRequestHandler(method: RequestMethod): void { - if (this._requestHandlers.has(method)) { - throw new Error(`A request handler for ${method} already exists, which would be overridden`); - } + return this._driver.notification(notification, options); } - /** - * Registers a handler to invoke when this protocol object receives a notification with the given method. - * - * Note that this will replace any previous notification handler for the same method. - */ - setNotificationHandler( - method: M, - handler: (notification: NotificationTypeMap[M]) => void | Promise - ): void { - const schema = getNotificationSchema(method); + // ─────────────────────────────────────────────────────────────────────── + // Test-compat accessors. v1 tests reach into these privates; proxy them to + // the driver so the test corpus keeps passing without rewrites. + // ─────────────────────────────────────────────────────────────────────── - this._notificationHandlers.set(method, notification => { - const parsed = schema.parse(notification); - return Promise.resolve(handler(parsed)); - }); + /** @internal v1 tests reach into this. */ + protected get _taskManager(): TaskManager { + return this._ownTaskManager; } - /** - * Removes the notification handler for the given method. - */ - removeNotificationHandler(method: NotificationMethod): void { - this._notificationHandlers.delete(method); - } -} - -function isPlainObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -export function mergeCapabilities(base: ServerCapabilities, additional: Partial): ServerCapabilities; -export function mergeCapabilities(base: ClientCapabilities, additional: Partial): ClientCapabilities; -export function mergeCapabilities(base: T, additional: Partial): T { - const result: T = { ...base }; - for (const key in additional) { - const k = key as keyof T; - const addValue = additional[k]; - if (addValue === undefined) continue; - const baseValue = result[k]; - result[k] = - isPlainObject(baseValue) && isPlainObject(addValue) - ? ({ ...(baseValue as Record), ...(addValue as Record) } as T[typeof k]) - : (addValue as T[typeof k]); + /** @internal v1 tests reach into this. */ + protected get _responseHandlers(): Map void> | undefined { + return (this._driver as unknown as { _responseHandlers?: Map void> })?._responseHandlers; } - return result; } diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index c75f4618f..f26d7e97f 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -26,8 +26,8 @@ import { import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; import type { DispatchEnv, Dispatcher } from './dispatcher.js'; -import type { NotificationOptions, ProgressCallback, RequestOptions } from './protocol.js'; -import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './protocol.js'; +import type { NotificationOptions, ProgressCallback, RequestOptions } from './context.js'; +import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; import type { InboundContext, TaskManagerHost, TaskManagerOptions } from './taskManager.js'; import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport } from './transport.js'; @@ -73,6 +73,7 @@ export class StreamDriver { private _timeoutInfo: Map = new Map(); private _requestHandlerAbortControllers: Map = new Map(); private _pendingDebouncedNotifications = new Set(); + private _closed = false; private _supportedProtocolVersions: string[]; private _taskManager: TaskManager; @@ -263,7 +264,7 @@ export class StreamDriver { */ async notification(notification: Notification, options?: NotificationOptions): Promise { const taskResult = await this._taskManager.processOutboundNotification(notification, options); - if (taskResult.queued) return; + if (taskResult.queued || this._closed) return; const jsonrpc: JSONRPCNotification = taskResult.jsonrpcNotification ?? { jsonrpc: '2.0', method: notification.method, @@ -277,7 +278,8 @@ export class StreamDriver { if (this._pendingDebouncedNotifications.has(notification.method)) return; this._pendingDebouncedNotifications.add(notification.method); Promise.resolve().then(() => { - this._pendingDebouncedNotifications.delete(notification.method); + // If the entry was already removed (by _onclose), skip the send. + if (!this._pendingDebouncedNotifications.delete(notification.method)) return; this.pipe.send(jsonrpc, options).catch(error => this._onerror(error)); }); return; @@ -408,6 +410,7 @@ export class StreamDriver { } private _onclose(): void { + this._closed = true; const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts index d7d40c550..ace92bbee 100644 --- a/packages/core/src/shared/taskManager.ts +++ b/packages/core/src/shared/taskManager.ts @@ -32,7 +32,7 @@ import { TaskStatusNotificationSchema } from '../types/index.js'; import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; -import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js'; +import type { BaseContext, NotificationOptions, RequestOptions } from './context.js'; import type { ResponseMessage } from './responseMessage.js'; /** diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376..64378e27b 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -39,12 +39,12 @@ import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; // Test Protocol subclass for testing class TestProtocolImpl extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - protected buildContext(ctx: BaseContext): BaseContext { + protected override assertCapabilityForMethod(): void {} + protected override assertNotificationCapability(): void {} + protected override assertRequestHandlerCapability(): void {} + protected override assertTaskCapability(): void {} + protected override assertTaskHandlerCapability(): void {} + protected override buildContext(ctx: BaseContext): BaseContext { return ctx; } } @@ -2069,7 +2069,8 @@ describe('Task-based execution', () => { taskId: task.taskId, status: 'working' }) - }) + }), + expect.anything() ); // Verify _meta is not present or doesn't contain RELATED_TASK_META_KEY @@ -2186,7 +2187,8 @@ describe('Task-based execution', () => { } }) }) - }) + }), + expect.anything() ); }); @@ -2419,7 +2421,8 @@ describe('Request Cancellation vs Task Cancellation', () => { code: ProtocolErrorCode.InvalidParams, message: expect.stringContaining('Cannot cancel task in terminal status') }) - }) + }), + expect.anything() ); }); @@ -2451,7 +2454,8 @@ describe('Request Cancellation vs Task Cancellation', () => { code: ProtocolErrorCode.InvalidParams, message: expect.stringContaining('Task not found') }) - }) + }), + expect.anything() ); }); }); @@ -2804,48 +2808,32 @@ describe('Progress notification support for tasks', () => { const messageId = sentRequest.id; const progressToken = sentRequest.params._meta.progressToken; - // Simulate CreateTaskResult response - const taskId = 'test-task-456'; + // Create the task in the store so the ctx.task.store path can find it. + const createdTask = await taskStore.createTask({ ttl: 60_000 }, messageId, request); + const taskId = createdTask.taskId; if (transport.onmessage) { transport.onmessage({ jsonrpc: '2.0', id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } + result: { task: createdTask } }); } await new Promise(resolve => setTimeout(resolve, 10)); - // Simulate task failure via storeTaskResult - await taskStore.storeTaskResult(taskId, 'failed', { - content: [], - isError: true + // Simulate task failure via the public ctx.task.store path (same as the + // (completed) variant), which is what triggers progress-handler cleanup. + protocol.setRequestHandler('ping', async (_request, ctx) => { + if (ctx.task?.store) { + await ctx.task.store.storeTaskResult(taskId, 'failed', { content: [], isError: true }); + } + return {}; }); - - // Manually trigger the status notification if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/tasks/status', - params: { - taskId, - status: 'failed', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - statusMessage: 'Task failed' - } - }); + transport.onmessage({ jsonrpc: '2.0', id: 998, method: 'ping', params: {} }); } - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => setTimeout(resolve, 50)); // Try to send progress notification after task failure - should be ignored progressCallback.mockClear(); @@ -2896,45 +2884,32 @@ describe('Progress notification support for tasks', () => { const messageId = sentRequest.id; const progressToken = sentRequest.params._meta.progressToken; - // Simulate CreateTaskResult response - const taskId = 'test-task-789'; + // Create the task in the store so the ctx.task.store path can find it. + const createdTask = await taskStore.createTask({ ttl: 60_000 }, messageId, request); + const taskId = createdTask.taskId; if (transport.onmessage) { transport.onmessage({ jsonrpc: '2.0', id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } + result: { task: createdTask } }); } await new Promise(resolve => setTimeout(resolve, 10)); - // Simulate task cancellation via updateTaskStatus - await taskStore.updateTaskStatus(taskId, 'cancelled', 'User cancelled'); - - // Manually trigger the status notification + // Simulate task cancellation via the public ctx.task.store path (same as the + // (completed) variant), which is what triggers progress-handler cleanup. + protocol.setRequestHandler('ping', async (_request, ctx) => { + if (ctx.task?.store) { + await ctx.task.store.updateTaskStatus(taskId, 'cancelled', 'User cancelled'); + } + return {}; + }); if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/tasks/status', - params: { - taskId, - status: 'cancelled', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - statusMessage: 'User cancelled' - } - }); + transport.onmessage({ jsonrpc: '2.0', id: 997, method: 'ping', params: {} }); } - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => setTimeout(resolve, 50)); // Try to send progress notification after cancellation - should be ignored progressCallback.mockClear(); @@ -3899,7 +3874,8 @@ describe('Message Interception', () => { jsonrpc: '2.0', id: requestId, result: { content: [{ type: 'text', text: 'done' }] } - }) + }), + expect.anything() ); }); }); @@ -5633,7 +5609,8 @@ describe('Protocol without task configuration', () => { jsonrpc: '2.0', id: 1, result: { content: 'ok' } - }) + }), + expect.anything() ); }); }); diff --git a/packages/core/test/shared/protocolTransportHandling.test.ts b/packages/core/test/shared/protocolTransportHandling.test.ts index 4e9c33e67..f6f162441 100644 --- a/packages/core/test/shared/protocolTransportHandling.test.ts +++ b/packages/core/test/shared/protocolTransportHandling.test.ts @@ -35,12 +35,12 @@ describe('Protocol transport handling bug', () => { beforeEach(() => { protocol = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - protected buildContext(ctx: BaseContext): BaseContext { + protected override assertCapabilityForMethod(): void {} + protected override assertNotificationCapability(): void {} + protected override assertRequestHandlerCapability(): void {} + protected override assertTaskCapability(): void {} + protected override assertTaskHandlerCapability(): void {} + protected override buildContext(ctx: BaseContext): BaseContext { return ctx; } })(); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 512d1119a..aa25709e8 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,1329 +1,5 @@ -import type { - BaseMetadata, - CallToolRequest, - CallToolResult, - CompleteRequestPrompt, - CompleteRequestResourceTemplate, - CompleteResult, - CreateTaskResult, - CreateTaskServerContext, - GetPromptResult, - Implementation, - ListPromptsResult, - ListResourcesResult, - ListToolsResult, - LoggingMessageNotification, - Prompt, - PromptReference, - ReadResourceResult, - Resource, - ResourceTemplateReference, - Result, - ServerContext, - StandardSchemaWithJSON, - Tool, - ToolAnnotations, - ToolExecution, - Transport, - Variables -} from '@modelcontextprotocol/core'; -import { - assertCompleteRequestPrompt, - assertCompleteRequestResourceTemplate, - promptArgumentsFromStandardSchema, - ProtocolError, - ProtocolErrorCode, - standardSchemaToJsonSchema, - UriTemplate, - validateAndWarnToolName, - validateStandardSchema -} from '@modelcontextprotocol/core'; - -import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; -import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; -import { getCompleter, isCompletable } from './completable.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. - * For advanced usage (like sending notifications or setting custom request handlers), use the underlying - * {@linkcode Server} instance available via the {@linkcode McpServer.server | server} property. - * - * @example - * ```ts source="./mcp.examples.ts#McpServer_basicUsage" - * const server = new McpServer({ - * name: 'my-server', - * version: '1.0.0' - * }); - * ``` + * v1-compat module path. The implementation moved to {@link ./mcpServer.ts}. + * @deprecated Import from `@modelcontextprotocol/server` directly. */ -export class McpServer { - /** - * The underlying {@linkcode Server} instance, useful for advanced operations like sending notifications. - */ - public readonly server: Server; - - private _registeredResources: { [uri: string]: RegisteredResource } = {}; - private _registeredResourceTemplates: { - [name: string]: RegisteredResourceTemplate; - } = {}; - private _registeredTools: { [name: string]: RegisteredTool } = {}; - private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; - private _experimental?: { tasks: ExperimentalMcpServerTasks }; - - constructor(serverInfo: Implementation, options?: ServerOptions) { - this.server = new Server(serverInfo, options); - } - - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalMcpServerTasks } { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalMcpServerTasks(this as never) - }; - } - return this._experimental; - } - - /** - * Attaches to the given transport, starts it, and starts listening for messages. - * - * The `server` object assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward. - * - * @example - * ```ts source="./mcp.examples.ts#McpServer_connect_stdio" - * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - * const transport = new StdioServerTransport(); - * await server.connect(transport); - * ``` - */ - async connect(transport: Transport): Promise { - return await this.server.connect(transport); - } - - /** - * Closes the connection. - */ - async close(): Promise { - await this.server.close(); - } - - private _toolHandlersInitialized = false; - - private setToolRequestHandlers() { - if (this._toolHandlersInitialized) { - return; - } - - this.server.assertCanSetRequestHandler('tools/list'); - this.server.assertCanSetRequestHandler('tools/call'); - - this.server.registerCapabilities({ - tools: { - listChanged: this.server.getCapabilities().tools?.listChanged ?? true - } - }); - - this.server.setRequestHandler( - 'tools/list', - (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools) - .filter(([, tool]) => tool.enabled) - .map(([name, tool]): Tool => { - const toolDefinition: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema - ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA, - annotations: tool.annotations, - execution: tool.execution, - _meta: tool._meta - }; - - if (tool.outputSchema) { - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; - } - - return toolDefinition; - }) - }) - ); - - this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { - const tool = this._registeredTools[request.params.name]; - if (!tool) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); - } - if (!tool.enabled) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); - } - - try { - const isTaskRequest = !!request.params.task; - const taskSupport = tool.execution?.taskSupport; - const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); - - // Validate task hint configuration - if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) { - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask` - ); - } - - // Handle taskSupport 'required' without task augmentation - if (taskSupport === 'required' && !isTaskRequest) { - throw new ProtocolError( - ProtocolErrorCode.MethodNotFound, - `Tool ${request.params.name} requires task augmentation (taskSupport: 'required')` - ); - } - - // Handle taskSupport 'optional' without task augmentation - automatic polling - if (taskSupport === 'optional' && !isTaskRequest && isTaskHandler) { - return await this.handleAutomaticTaskPolling(tool, request, ctx); - } - - // Normal execution path - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const result = await this.executeToolHandler(tool, args, ctx); - - // Return CreateTaskResult immediately for task requests - if (isTaskRequest) { - return result; - } - - // Validate output schema for non-task requests - await this.validateToolOutput(tool, result, request.params.name); - return result; - } catch (error) { - if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { - throw error; // Return the error to the caller without wrapping in CallToolResult - } - return this.createToolError(error instanceof Error ? error.message : String(error)); - } - }); - - this._toolHandlersInitialized = true; - } - - /** - * Creates a tool error result. - * - * @param errorMessage - The error message. - * @returns The tool error result. - */ - private createToolError(errorMessage: string): CallToolResult { - return { - content: [ - { - type: 'text', - text: errorMessage - } - ], - isError: true - }; - } - - /** - * Validates tool input arguments against the tool's input schema. - */ - private async validateToolInput< - ToolType extends RegisteredTool, - Args extends ToolType['inputSchema'] extends infer InputSchema - ? InputSchema extends StandardSchemaWithJSON - ? StandardSchemaWithJSON.InferOutput - : undefined - : undefined - >(tool: ToolType, args: Args, toolName: string): Promise { - if (!tool.inputSchema) { - return undefined as Args; - } - - const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {}); - if (!parseResult.success) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Input validation error: Invalid arguments for tool ${toolName}: ${parseResult.error}` - ); - } - - return parseResult.data as unknown as Args; - } - - /** - * Validates tool output against the tool's output schema. - */ - private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { - if (!tool.outputSchema) { - return; - } - - // Only validate CallToolResult, not CreateTaskResult - if (!('content' in result)) { - return; - } - - if (result.isError) { - return; - } - - if (!result.structuredContent) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` - ); - } - - // if the tool has an output schema, validate structured content - const parseResult = await validateStandardSchema(tool.outputSchema, result.structuredContent); - if (!parseResult.success) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Output validation error: Invalid structured content for tool ${toolName}: ${parseResult.error}` - ); - } - } - - /** - * Executes a tool handler (either regular or task-based). - */ - private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { - // Executor encapsulates handler invocation with proper types - return tool.executor(args, ctx); - } - - /** - * Handles automatic task polling for tools with `taskSupport` `'optional'`. - */ - private async handleAutomaticTaskPolling( - tool: RegisteredTool, - request: RequestT, - ctx: ServerContext - ): Promise { - if (!ctx.task?.store) { - throw new Error('No task store provided for task-capable tool.'); - } - - // Validate input and create task using the executor - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const createTaskResult = (await tool.executor(args, ctx)) as CreateTaskResult; - - // Poll until completion - const taskId = createTaskResult.task.taskId; - let task = createTaskResult.task; - const pollInterval = task.pollInterval ?? 5000; - - while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') { - await new Promise(resolve => setTimeout(resolve, pollInterval)); - const updatedTask = await ctx.task.store.getTask(taskId); - if (!updatedTask) { - throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`); - } - task = updatedTask; - } - - // Return the final result - return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult; - } - - private _completionHandlerInitialized = false; - - private setCompletionRequestHandler() { - if (this._completionHandlerInitialized) { - return; - } - - this.server.assertCanSetRequestHandler('completion/complete'); - - this.server.registerCapabilities({ - completions: {} - }); - - this.server.setRequestHandler('completion/complete', async (request): Promise => { - switch (request.params.ref.type) { - case 'ref/prompt': { - assertCompleteRequestPrompt(request); - return this.handlePromptCompletion(request, request.params.ref); - } - - case 'ref/resource': { - assertCompleteRequestResourceTemplate(request); - return this.handleResourceCompletion(request, request.params.ref); - } - - default: { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`); - } - } - }); - - this._completionHandlerInitialized = true; - } - - private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise { - const prompt = this._registeredPrompts[ref.name]; - if (!prompt) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`); - } - - if (!prompt.enabled) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); - } - - if (!prompt.argsSchema) { - return EMPTY_COMPLETION_RESULT; - } - - const promptShape = getSchemaShape(prompt.argsSchema); - const field = unwrapOptionalSchema(promptShape?.[request.params.argument.name]); - if (!isCompletable(field)) { - return EMPTY_COMPLETION_RESULT; - } - - const completer = getCompleter(field); - if (!completer) { - return EMPTY_COMPLETION_RESULT; - } - - const suggestions = await completer(request.params.argument.value, request.params.context); - return createCompletionResult(suggestions); - } - - private async handleResourceCompletion( - request: CompleteRequestResourceTemplate, - ref: ResourceTemplateReference - ): Promise { - const template = Object.values(this._registeredResourceTemplates).find(t => t.resourceTemplate.uriTemplate.toString() === ref.uri); - - if (!template) { - if (this._registeredResources[ref.uri]) { - // Attempting to autocomplete a fixed resource URI is not an error in the spec (but probably should be). - return EMPTY_COMPLETION_RESULT; - } - - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`); - } - - const completer = template.resourceTemplate.completeCallback(request.params.argument.name); - if (!completer) { - return EMPTY_COMPLETION_RESULT; - } - - const suggestions = await completer(request.params.argument.value, request.params.context); - return createCompletionResult(suggestions); - } - - private _resourceHandlersInitialized = false; - - private setResourceRequestHandlers() { - if (this._resourceHandlersInitialized) { - return; - } - - this.server.assertCanSetRequestHandler('resources/list'); - this.server.assertCanSetRequestHandler('resources/templates/list'); - this.server.assertCanSetRequestHandler('resources/read'); - - this.server.registerCapabilities({ - resources: { - listChanged: this.server.getCapabilities().resources?.listChanged ?? true - } - }); - - this.server.setRequestHandler('resources/list', async (_request, ctx) => { - const resources = Object.entries(this._registeredResources) - .filter(([_, resource]) => resource.enabled) - .map(([uri, resource]) => ({ - uri, - name: resource.name, - ...resource.metadata - })); - - const templateResources: Resource[] = []; - for (const template of Object.values(this._registeredResourceTemplates)) { - if (!template.resourceTemplate.listCallback) { - continue; - } - - const result = await template.resourceTemplate.listCallback(ctx); - for (const resource of result.resources) { - templateResources.push({ - ...template.metadata, - // the defined resource metadata should override the template metadata if present - ...resource - }); - } - } - - return { resources: [...resources, ...templateResources] }; - }); - - this.server.setRequestHandler('resources/templates/list', async () => { - const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ - name, - uriTemplate: template.resourceTemplate.uriTemplate.toString(), - ...template.metadata - })); - - return { resourceTemplates }; - }); - - this.server.setRequestHandler('resources/read', async (request, ctx) => { - const uri = new URL(request.params.uri); - - // First check for exact resource match - const resource = this._registeredResources[uri.toString()]; - if (resource) { - if (!resource.enabled) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); - } - return resource.readCallback(uri, ctx); - } - - // Then check templates - for (const template of Object.values(this._registeredResourceTemplates)) { - const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); - if (variables) { - return template.readCallback(uri, variables, ctx); - } - } - - throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); - }); - - this._resourceHandlersInitialized = true; - } - - private _promptHandlersInitialized = false; - - private setPromptRequestHandlers() { - if (this._promptHandlersInitialized) { - return; - } - - this.server.assertCanSetRequestHandler('prompts/list'); - this.server.assertCanSetRequestHandler('prompts/get'); - - this.server.registerCapabilities({ - prompts: { - listChanged: this.server.getCapabilities().prompts?.listChanged ?? true - } - }); - - this.server.setRequestHandler( - 'prompts/list', - (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts) - .filter(([, prompt]) => prompt.enabled) - .map(([name, prompt]): Prompt => { - return { - name, - title: prompt.title, - description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, - _meta: prompt._meta - }; - }) - }) - ); - - this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { - const prompt = this._registeredPrompts[request.params.name]; - if (!prompt) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); - } - - if (!prompt.enabled) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); - } - - // Handler encapsulates parsing and callback invocation with proper types - return prompt.handler(request.params.arguments, ctx); - }); - - this._promptHandlersInitialized = true; - } - - /** - * Registers a resource with a config object and callback. - * For static resources, use a URI string. For dynamic resources, use a {@linkcode ResourceTemplate}. - * - * @example - * ```ts source="./mcp.examples.ts#McpServer_registerResource_static" - * server.registerResource( - * 'config', - * 'config://app', - * { - * title: 'Application Config', - * mimeType: 'text/plain' - * }, - * async uri => ({ - * contents: [{ uri: uri.href, text: 'App configuration here' }] - * }) - * ); - * ``` - */ - registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; - registerResource( - name: string, - uriOrTemplate: ResourceTemplate, - config: ResourceMetadata, - readCallback: ReadResourceTemplateCallback - ): RegisteredResourceTemplate; - registerResource( - name: string, - uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata, - readCallback: ReadResourceCallback | ReadResourceTemplateCallback - ): RegisteredResource | RegisteredResourceTemplate { - if (typeof uriOrTemplate === 'string') { - if (this._registeredResources[uriOrTemplate]) { - throw new Error(`Resource ${uriOrTemplate} is already registered`); - } - - const registeredResource = this._createRegisteredResource( - name, - (config as BaseMetadata).title, - uriOrTemplate, - config, - readCallback as ReadResourceCallback - ); - - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return registeredResource; - } else { - if (this._registeredResourceTemplates[name]) { - throw new Error(`Resource template ${name} is already registered`); - } - - const registeredResourceTemplate = this._createRegisteredResourceTemplate( - name, - (config as BaseMetadata).title, - uriOrTemplate, - config, - readCallback as ReadResourceTemplateCallback - ); - - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return registeredResourceTemplate; - } - } - - private _createRegisteredResource( - name: string, - title: string | undefined, - uri: string, - metadata: ResourceMetadata | undefined, - readCallback: ReadResourceCallback - ): RegisteredResource { - const registeredResource: RegisteredResource = { - name, - title, - metadata, - readCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: updates => { - if (updates.uri !== undefined && updates.uri !== uri) { - delete this._registeredResources[uri]; - if (updates.uri) this._registeredResources[updates.uri] = registeredResource; - } - if (updates.name !== undefined) registeredResource.name = updates.name; - if (updates.title !== undefined) registeredResource.title = updates.title; - if (updates.metadata !== undefined) registeredResource.metadata = updates.metadata; - if (updates.callback !== undefined) registeredResource.readCallback = updates.callback; - if (updates.enabled !== undefined) registeredResource.enabled = updates.enabled; - this.sendResourceListChanged(); - } - }; - this._registeredResources[uri] = registeredResource; - return registeredResource; - } - - private _createRegisteredResourceTemplate( - name: string, - title: string | undefined, - template: ResourceTemplate, - metadata: ResourceMetadata | undefined, - readCallback: ReadResourceTemplateCallback - ): RegisteredResourceTemplate { - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: template, - title, - metadata, - readCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - delete this._registeredResourceTemplates[name]; - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; - } - if (updates.title !== undefined) registeredResourceTemplate.title = updates.title; - if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template; - if (updates.metadata !== undefined) registeredResourceTemplate.metadata = updates.metadata; - if (updates.callback !== undefined) registeredResourceTemplate.readCallback = updates.callback; - if (updates.enabled !== undefined) registeredResourceTemplate.enabled = updates.enabled; - this.sendResourceListChanged(); - } - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; - - // If the resource template has any completion callbacks, enable completions capability - const variableNames = template.uriTemplate.variableNames; - const hasCompleter = Array.isArray(variableNames) && variableNames.some(v => !!template.completeCallback(v)); - if (hasCompleter) { - this.setCompletionRequestHandler(); - } - - return registeredResourceTemplate; - } - - private _createRegisteredPrompt( - name: string, - title: string | undefined, - description: string | undefined, - argsSchema: StandardSchemaWithJSON | undefined, - callback: PromptCallback, - _meta: Record | undefined - ): RegisteredPrompt { - // Track current schema and callback for handler regeneration - let currentArgsSchema = argsSchema; - let currentCallback = callback; - - const registeredPrompt: RegisteredPrompt = { - title, - description, - argsSchema, - _meta, - handler: createPromptHandler(name, argsSchema, callback), - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - delete this._registeredPrompts[name]; - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt; - } - if (updates.title !== undefined) registeredPrompt.title = updates.title; - if (updates.description !== undefined) registeredPrompt.description = updates.description; - if (updates._meta !== undefined) registeredPrompt._meta = updates._meta; - - // Track if we need to regenerate the handler - let needsHandlerRegen = false; - if (updates.argsSchema !== undefined) { - registeredPrompt.argsSchema = updates.argsSchema; - currentArgsSchema = updates.argsSchema; - needsHandlerRegen = true; - } - if (updates.callback !== undefined) { - currentCallback = updates.callback as PromptCallback; - needsHandlerRegen = true; - } - if (needsHandlerRegen) { - registeredPrompt.handler = createPromptHandler(name, currentArgsSchema, currentCallback); - } - - if (updates.enabled !== undefined) registeredPrompt.enabled = updates.enabled; - this.sendPromptListChanged(); - } - }; - this._registeredPrompts[name] = registeredPrompt; - - // If any argument uses a Completable schema, enable completions capability - if (argsSchema) { - const shape = getSchemaShape(argsSchema); - if (shape) { - const hasCompletable = Object.values(shape).some(field => { - const inner = unwrapOptionalSchema(field); - return isCompletable(inner); - }); - if (hasCompletable) { - this.setCompletionRequestHandler(); - } - } - } - - return registeredPrompt; - } - - private _createRegisteredTool( - name: string, - title: string | undefined, - description: string | undefined, - inputSchema: StandardSchemaWithJSON | undefined, - outputSchema: StandardSchemaWithJSON | undefined, - annotations: ToolAnnotations | undefined, - execution: ToolExecution | undefined, - _meta: Record | undefined, - handler: AnyToolHandler - ): RegisteredTool { - // Validate tool name according to SEP specification - validateAndWarnToolName(name); - - // Track current handler for executor regeneration - let currentHandler = handler; - - const registeredTool: RegisteredTool = { - title, - description, - inputSchema, - outputSchema, - annotations, - execution, - _meta, - handler: handler, - executor: createToolExecutor(inputSchema, handler), - enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - if (typeof updates.name === 'string') { - validateAndWarnToolName(updates.name); - } - delete this._registeredTools[name]; - if (updates.name) this._registeredTools[updates.name] = registeredTool; - } - if (updates.title !== undefined) registeredTool.title = updates.title; - if (updates.description !== undefined) registeredTool.description = updates.description; - - // Track if we need to regenerate the executor - let needsExecutorRegen = false; - if (updates.paramsSchema !== undefined) { - registeredTool.inputSchema = updates.paramsSchema; - needsExecutorRegen = true; - } - if (updates.callback !== undefined) { - registeredTool.handler = updates.callback; - currentHandler = updates.callback as AnyToolHandler; - needsExecutorRegen = true; - } - if (needsExecutorRegen) { - registeredTool.executor = createToolExecutor(registeredTool.inputSchema, currentHandler); - } - - if (updates.outputSchema !== undefined) registeredTool.outputSchema = updates.outputSchema; - if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; - if (updates._meta !== undefined) registeredTool._meta = updates._meta; - if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled; - this.sendToolListChanged(); - } - }; - this._registeredTools[name] = registeredTool; - - this.setToolRequestHandlers(); - this.sendToolListChanged(); - - return registeredTool; - } - - /** - * Registers a tool with a config object and callback. - * - * @example - * ```ts source="./mcp.examples.ts#McpServer_registerTool_basic" - * server.registerTool( - * 'calculate-bmi', - * { - * title: 'BMI Calculator', - * description: 'Calculate Body Mass Index', - * inputSchema: z.object({ - * weightKg: z.number(), - * heightM: z.number() - * }), - * outputSchema: z.object({ bmi: z.number() }) - * }, - * async ({ weightKg, heightM }) => { - * const output = { bmi: weightKg / (heightM * heightM) }; - * return { - * content: [{ type: 'text', text: JSON.stringify(output) }], - * structuredContent: output - * }; - * } - * ); - * ``` - */ - registerTool( - name: string, - config: { - title?: string; - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - _meta?: Record; - }, - cb: ToolCallback - ): RegisteredTool { - if (this._registeredTools[name]) { - throw new Error(`Tool ${name} is already registered`); - } - - const { title, description, inputSchema, outputSchema, annotations, _meta } = config; - - return this._createRegisteredTool( - name, - title, - description, - inputSchema, - outputSchema, - annotations, - { taskSupport: 'forbidden' }, - _meta, - cb as ToolCallback - ); - } - - /** - * Registers a prompt with a config object and callback. - * - * @example - * ```ts source="./mcp.examples.ts#McpServer_registerPrompt_basic" - * server.registerPrompt( - * 'review-code', - * { - * title: 'Code Review', - * description: 'Review code for best practices', - * argsSchema: z.object({ code: z.string() }) - * }, - * ({ code }) => ({ - * messages: [ - * { - * role: 'user' as const, - * content: { - * type: 'text' as const, - * text: `Please review this code:\n\n${code}` - * } - * } - * ] - * }) - * ); - * ``` - */ - registerPrompt( - name: string, - config: { - title?: string; - description?: string; - argsSchema?: Args; - _meta?: Record; - }, - cb: PromptCallback - ): RegisteredPrompt { - if (this._registeredPrompts[name]) { - throw new Error(`Prompt ${name} is already registered`); - } - - const { title, description, argsSchema, _meta } = config; - - const registeredPrompt = this._createRegisteredPrompt( - name, - title, - description, - argsSchema, - cb as PromptCallback, - _meta - ); - - this.setPromptRequestHandlers(); - this.sendPromptListChanged(); - - return registeredPrompt; - } - - /** - * Checks if the server is connected to a transport. - * @returns `true` if the server is connected - */ - isConnected() { - return this.server.transport !== undefined; - } - - /** - * Sends a logging message to the client, if connected. - * Note: You only need to send the parameters object, not the entire JSON-RPC message. - * @see {@linkcode LoggingMessageNotification} - * @param params - * @param sessionId Optional for stateless transports and backward compatibility. - * - * @example - * ```ts source="./mcp.examples.ts#McpServer_sendLoggingMessage_basic" - * await server.sendLoggingMessage({ - * level: 'info', - * data: 'Processing complete' - * }); - * ``` - */ - async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { - return this.server.sendLoggingMessage(params, sessionId); - } - /** - * Sends a resource list changed event to the client, if connected. - */ - sendResourceListChanged() { - if (this.isConnected()) { - this.server.sendResourceListChanged(); - } - } - - /** - * Sends a tool list changed event to the client, if connected. - */ - sendToolListChanged() { - if (this.isConnected()) { - this.server.sendToolListChanged(); - } - } - - /** - * Sends a prompt list changed event to the client, if connected. - */ - sendPromptListChanged() { - if (this.isConnected()) { - this.server.sendPromptListChanged(); - } - } -} - -/** - * A callback to complete one variable within a resource template's URI template. - */ -export type CompleteResourceTemplateCallback = ( - value: string, - context?: { - arguments?: Record; - } -) => string[] | Promise; - -/** - * A resource template combines a URI pattern with optional functionality to enumerate - * all resources matching that pattern. - */ -export class ResourceTemplate { - private _uriTemplate: UriTemplate; - - constructor( - uriTemplate: string | UriTemplate, - private _callbacks: { - /** - * A callback to list all resources matching this template. This is required to be specified, even if `undefined`, to avoid accidentally forgetting resource listing. - */ - list: ListResourcesCallback | undefined; - - /** - * An optional callback to autocomplete variables within the URI template. Useful for clients and users to discover possible values. - */ - complete?: { - [variable: string]: CompleteResourceTemplateCallback; - }; - } - ) { - this._uriTemplate = typeof uriTemplate === 'string' ? new UriTemplate(uriTemplate) : uriTemplate; - } - - /** - * Gets the URI template pattern. - */ - get uriTemplate(): UriTemplate { - return this._uriTemplate; - } - - /** - * Gets the list callback, if one was provided. - */ - get listCallback(): ListResourcesCallback | undefined { - return this._callbacks.list; - } - - /** - * Gets the callback for completing a specific URI template variable, if one was provided. - */ - completeCallback(variable: string): CompleteResourceTemplateCallback | undefined { - return this._callbacks.complete?.[variable]; - } -} - -export type BaseToolCallback< - SendResultT extends Result, - Ctx extends ServerContext, - Args extends StandardSchemaWithJSON | undefined -> = Args extends StandardSchemaWithJSON - ? (args: StandardSchemaWithJSON.InferOutput, ctx: Ctx) => SendResultT | Promise - : (ctx: Ctx) => SendResultT | Promise; - -/** - * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. - */ -export type ToolCallback = BaseToolCallback< - CallToolResult, - ServerContext, - Args ->; - -/** - * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). - */ -export type AnyToolHandler = ToolCallback | ToolTaskHandler; - -/** - * Internal executor type that encapsulates handler invocation with proper types. - */ -type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; - -export type RegisteredTool = { - title?: string; - description?: string; - inputSchema?: StandardSchemaWithJSON; - outputSchema?: StandardSchemaWithJSON; - annotations?: ToolAnnotations; - execution?: ToolExecution; - _meta?: Record; - handler: AnyToolHandler; - /** @hidden */ - executor: ToolExecutor; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - paramsSchema?: StandardSchemaWithJSON; - outputSchema?: StandardSchemaWithJSON; - annotations?: ToolAnnotations; - _meta?: Record; - callback?: ToolCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -/** - * Creates an executor that invokes the handler with the appropriate arguments. - * When `inputSchema` is defined, the handler is called with `(args, ctx)`. - * When `inputSchema` is undefined, the handler is called with just `(ctx)`. - */ -function createToolExecutor( - inputSchema: StandardSchemaWithJSON | undefined, - handler: AnyToolHandler -): ToolExecutor { - const isTaskHandler = 'createTask' in handler; - - if (isTaskHandler) { - const taskHandler = handler as TaskHandlerInternal; - return async (args, ctx) => { - if (!ctx.task?.store) { - throw new Error('No task store provided.'); - } - const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; - if (inputSchema) { - return taskHandler.createTask(args, taskCtx); - } - // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - return (taskHandler.createTask as (ctx: CreateTaskServerContext) => CreateTaskResult | Promise)(taskCtx); - }; - } - - if (inputSchema) { - const callback = handler as ToolCallbackInternal; - return async (args, ctx) => callback(args, ctx); - } - - // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - const callback = handler as (ctx: ServerContext) => CallToolResult | Promise; - return async (_args, ctx) => callback(ctx); -} - -const EMPTY_OBJECT_JSON_SCHEMA = { - type: 'object' as const, - properties: {} -}; - -/** - * Additional, optional information for annotating a resource. - */ -export type ResourceMetadata = Omit; - -/** - * Callback to list all resources matching a given template. - */ -export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; - -/** - * Callback to read a resource at a given URI. - */ -export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; - -export type RegisteredResource = { - name: string; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string; - title?: string; - uri?: string | null; - metadata?: ResourceMetadata; - callback?: ReadResourceCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -/** - * Callback to read a resource at a given URI, following a filled-in URI template. - */ -export type ReadResourceTemplateCallback = ( - uri: URL, - variables: Variables, - ctx: ServerContext -) => ReadResourceResult | Promise; - -export type RegisteredResourceTemplate = { - resourceTemplate: ResourceTemplate; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceTemplateCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - template?: ResourceTemplate; - metadata?: ResourceMetadata; - callback?: ReadResourceTemplateCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -export type PromptCallback = Args extends StandardSchemaWithJSON - ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; - -/** - * Internal handler type that encapsulates parsing and callback invocation. - * This allows type-safe handling without runtime type assertions. - */ -type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; - -type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; - -type TaskHandlerInternal = { - createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; -}; - -export type RegisteredPrompt = { - title?: string; - description?: string; - argsSchema?: StandardSchemaWithJSON; - _meta?: Record; - /** @hidden */ - handler: PromptHandler; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - argsSchema?: Args; - _meta?: Record; - callback?: PromptCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -/** - * Creates a type-safe prompt handler that captures the schema and callback in a closure. - * This eliminates the need for type assertions at the call site. - */ -function createPromptHandler( - name: string, - argsSchema: StandardSchemaWithJSON | undefined, - callback: PromptCallback -): PromptHandler { - if (argsSchema) { - const typedCallback = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; - - return async (args, ctx) => { - const parseResult = await validateStandardSchema(argsSchema, args); - if (!parseResult.success) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parseResult.error}`); - } - return typedCallback(parseResult.data, ctx); - }; - } else { - const typedCallback = callback as (ctx: ServerContext) => GetPromptResult | Promise; - - return async (_args, ctx) => { - return typedCallback(ctx); - }; - } -} - -function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { - const values = suggestions.map(String).slice(0, 100); - return { - completion: { - values, - total: suggestions.length, - hasMore: suggestions.length > 100 - } - }; -} - -const EMPTY_COMPLETION_RESULT: CompleteResult = { - completion: { - values: [], - hasMore: false - } -}; - -/** @internal Gets the shape of a Zod object schema */ -function getSchemaShape(schema: unknown): Record | undefined { - const candidate = schema as { shape?: unknown }; - if (candidate.shape && typeof candidate.shape === 'object') { - return candidate.shape as Record; - } - return undefined; -} - -/** @internal Checks if a Zod schema is optional */ -function isOptionalSchema(schema: unknown): boolean { - const candidate = schema as { type?: string } | null | undefined; - return candidate?.type === 'optional'; -} - -/** @internal Unwraps an optional Zod schema */ -function unwrapOptionalSchema(schema: unknown): unknown { - if (!isOptionalSchema(schema)) { - return schema; - } - const candidate = schema as { def?: { innerType?: unknown } }; - return candidate.def?.innerType ?? schema; -} +export * from './mcpServer.js'; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 6a86c507e..1945af6ae 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,677 +1,7 @@ -import type { - BaseContext, - ClientCapabilities, - CreateMessageRequest, - CreateMessageRequestParamsBase, - CreateMessageRequestParamsWithTools, - CreateMessageResult, - CreateMessageResultWithTools, - ElicitRequestFormParams, - ElicitRequestURLParams, - ElicitResult, - Implementation, - InitializeRequest, - InitializeResult, - JsonSchemaType, - jsonSchemaValidator, - ListRootsRequest, - LoggingLevel, - LoggingMessageNotification, - MessageExtraInfo, - NotificationMethod, - NotificationOptions, - ProtocolOptions, - RequestMethod, - RequestOptions, - RequestTypeMap, - ResourceUpdatedNotification, - ResultTypeMap, - ServerCapabilities, - ServerContext, - ServerResult, - TaskManagerOptions, - ToolResultContent, - ToolUseContent -} from '@modelcontextprotocol/core'; -import { - assertClientRequestTaskCapability, - assertToolsCallTaskCapability, - CallToolRequestSchema, - CallToolResultSchema, - CreateMessageResultSchema, - CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, - ElicitResultSchema, - EmptyResultSchema, - extractTaskManagerOptions, - LATEST_PROTOCOL_VERSION, - ListRootsResultSchema, - LoggingLevelSchema, - mergeCapabilities, - parseSchema, - Protocol, - ProtocolError, - ProtocolErrorCode, - SdkError, - SdkErrorCode -} from '@modelcontextprotocol/core'; -import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; - -import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; - /** - * Extended tasks capability that includes runtime configuration (store, messageQueue). - * The runtime-only fields are stripped before advertising capabilities to clients. + * v1-compat module path. The low-level `Server` class is now an alias for + * {@linkcode McpServer}; see {@link ./compat.ts} and {@link ./mcpServer.ts}. + * @deprecated Import from `@modelcontextprotocol/server` directly. */ -export type ServerTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; - -export type ServerOptions = ProtocolOptions & { - /** - * Capabilities to advertise as being supported by this server. - */ - capabilities?: Omit & { - tasks?: ServerTasksCapabilityWithRuntime; - }; - - /** - * Optional instructions describing how to use the server and its features. - */ - instructions?: string; - - /** - * JSON Schema validator for elicitation response validation. - * - * The validator is used to validate user input returned from elicitation - * requests against the requested schema. - * - * @default {@linkcode DefaultJsonSchemaValidator} ({@linkcode index.AjvJsonSchemaValidator | AjvJsonSchemaValidator} on Node.js, `CfWorkerJsonSchemaValidator` on Cloudflare Workers) - */ - jsonSchemaValidator?: jsonSchemaValidator; -}; - -/** - * An MCP server on top of a pluggable transport. - * - * This server will automatically respond to the initialization flow as initiated from the client. - * - * @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 { - private _clientCapabilities?: ClientCapabilities; - private _clientVersion?: Implementation; - private _capabilities: ServerCapabilities; - private _instructions?: string; - private _jsonSchemaValidator: jsonSchemaValidator; - private _experimental?: { tasks: ExperimentalServerTasks }; - - /** - * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). - */ - oninitialized?: () => void; - - /** - * Initializes this server with the given name and version information. - */ - constructor( - private _serverInfo: Implementation, - options?: ServerOptions - ) { - super({ - ...options, - tasks: extractTaskManagerOptions(options?.capabilities?.tasks) - }); - this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; - this._instructions = options?.instructions; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - - // Strip runtime-only fields from advertised capabilities - if (options?.capabilities?.tasks) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = - options.capabilities.tasks; - this._capabilities.tasks = wireCapabilities; - } - - this.setRequestHandler('initialize', request => this._oninitialize(request)); - this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); - - if (this._capabilities.logging) { - this._registerLoggingHandler(); - } - } - - private _registerLoggingHandler(): void { - this.setRequestHandler('logging/setLevel', async (request, ctx) => { - const transportSessionId: string | undefined = - ctx.sessionId || (ctx.http?.req?.headers.get('mcp-session-id') as string) || undefined; - const { level } = request.params; - const parseResult = parseSchema(LoggingLevelSchema, level); - if (parseResult.success) { - this._loggingLevels.set(transportSessionId, parseResult.data); - } - return {}; - }); - } - - protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { - // Only create http when there's actual HTTP transport info or auth info - const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; - return { - ...ctx, - mcpReq: { - ...ctx.mcpReq, - log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), - elicitInput: (params, options) => this.elicitInput(params, options), - requestSampling: (params, options) => this.createMessage(params, options) - }, - http: hasHttpInfo - ? { - ...ctx.http, - req: transportInfo?.request, - closeSSE: transportInfo?.closeSSEStream, - closeStandaloneSSE: transportInfo?.closeStandaloneSSEStream - } - : undefined - }; - } - - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalServerTasks } { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalServerTasks(this as never) - }; - } - return this._experimental; - } - - // Map log levels by session id - private _loggingLevels = new Map(); - - // Map LogLevelSchema to severity index - private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); - - // Is a message with the given level ignored in the log level set for the given session id? - private isMessageIgnored = (level: LoggingLevel, sessionId?: string): boolean => { - const currentLevel = this._loggingLevels.get(sessionId); - return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! : false; - }; - - /** - * 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). - */ - public registerCapabilities(capabilities: ServerCapabilities): void { - if (this.transport) { - throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register capabilities after connecting to transport'); - } - const hadLogging = !!this._capabilities.logging; - this._capabilities = mergeCapabilities(this._capabilities, capabilities); - if (!hadLogging && this._capabilities.logging) { - this._registerLoggingHandler(); - } - } - - /** - * Override request handler registration to enforce server-side validation for `tools/call`. - */ - public override setRequestHandler( - method: M, - handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise - ): void { - if (method === 'tools/call') { - const wrappedHandler = async (request: RequestTypeMap[M], ctx: ServerContext): Promise => { - const validatedRequest = parseSchema(CallToolRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); - } - - const { params } = validatedRequest.data; - - const result = await Promise.resolve(handler(request, ctx)); - - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against CallToolResultSchema - const validationResult = parseSchema(CallToolResultSchema, result); - if (!validationResult.success) { - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); - } - - return validationResult.data; - }; - - // Install the wrapped handler - return super.setRequestHandler(method, wrappedHandler); - } - - // Other handlers use default behavior - return super.setRequestHandler(method, handler); - } - - protected assertCapabilityForMethod(method: RequestMethod): void { - switch (method) { - case 'sampling/createMessage': { - if (!this._clientCapabilities?.sampling) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support sampling (required for ${method})`); - } - break; - } - - case 'elicitation/create': { - if (!this._clientCapabilities?.elicitation) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation (required for ${method})`); - } - break; - } - - case 'roots/list': { - if (!this._clientCapabilities?.roots) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Client does not support listing roots (required for ${method})` - ); - } - break; - } - - case 'ping': { - // No specific capability required for ping - break; - } - } - } - - protected assertNotificationCapability(method: NotificationMethod): void { - switch (method) { - case 'notifications/message': { - if (!this._capabilities.logging) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); - } - break; - } - - case 'notifications/resources/updated': - case 'notifications/resources/list_changed': { - if (!this._capabilities.resources) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Server does not support notifying about resources (required for ${method})` - ); - } - break; - } - - case 'notifications/tools/list_changed': { - if (!this._capabilities.tools) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Server does not support notifying of tool list changes (required for ${method})` - ); - } - break; - } - - case 'notifications/prompts/list_changed': { - if (!this._capabilities.prompts) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Server does not support notifying of prompt list changes (required for ${method})` - ); - } - break; - } - - case 'notifications/elicitation/complete': { - if (!this._clientCapabilities?.elicitation?.url) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `Client does not support URL elicitation (required for ${method})` - ); - } - break; - } - - case 'notifications/cancelled': { - // Cancellation notifications are always allowed - break; - } - - case 'notifications/progress': { - // Progress notifications are always allowed - break; - } - } - } - - protected assertRequestHandlerCapability(method: string): void { - switch (method) { - case 'completion/complete': { - if (!this._capabilities.completions) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); - } - break; - } - - case 'logging/setLevel': { - if (!this._capabilities.logging) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); - } - break; - } - - case 'prompts/get': - case 'prompts/list': { - if (!this._capabilities.prompts) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); - } - break; - } - - case 'resources/list': - case 'resources/templates/list': - case 'resources/read': { - if (!this._capabilities.resources) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); - } - break; - } - - case 'tools/call': - case 'tools/list': { - if (!this._capabilities.tools) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); - } - break; - } - - case 'ping': - case 'initialize': { - // No specific capability required for these methods - break; - } - } - } - - protected assertTaskCapability(method: string): void { - assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, 'Client'); - } - - protected assertTaskHandlerCapability(method: string): void { - assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server'); - } - - private async _oninitialize(request: InitializeRequest): Promise { - const requestedVersion = request.params.protocolVersion; - - this._clientCapabilities = request.params.capabilities; - this._clientVersion = request.params.clientInfo; - - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) - ? requestedVersion - : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); - - this.transport?.setProtocolVersion?.(protocolVersion); - - return { - protocolVersion, - capabilities: this.getCapabilities(), - serverInfo: this._serverInfo, - ...(this._instructions && { instructions: this._instructions }) - }; - } - - /** - * After initialization has completed, this will be populated with the client's reported capabilities. - */ - getClientCapabilities(): ClientCapabilities | undefined { - return this._clientCapabilities; - } - - /** - * After initialization has completed, this will be populated with information about the client's name and version. - */ - getClientVersion(): Implementation | undefined { - return this._clientVersion; - } - - /** - * Returns the current server capabilities. - */ - public getCapabilities(): ServerCapabilities { - return this._capabilities; - } - - async ping() { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); - } - - /** - * Request LLM sampling from the client (without tools). - * Returns single content block for backwards compatibility. - */ - async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; - - /** - * Request LLM sampling from the client with tool support. - * Returns content that may be a single block or array (for parallel tool calls). - */ - async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; - - /** - * Request LLM sampling from the client. - * When tools may or may not be present, returns the union type. - */ - async createMessage( - params: CreateMessageRequest['params'], - options?: RequestOptions - ): Promise; - - // Implementation - async createMessage( - params: CreateMessageRequest['params'], - options?: RequestOptions - ): Promise { - // Capability check - only required when tools/toolChoice are provided - if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); - } - - // Message structure validation - always validate tool_use/tool_result pairs. - // These may appear even without tools/toolChoice in the current request when - // a previous sampling request returned tool_use and this is a follow-up with results. - if (params.messages.length > 0) { - const lastMessage = params.messages.at(-1)!; - const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; - const hasToolResults = lastContent.some(c => c.type === 'tool_result'); - - const previousMessage = params.messages.length > 1 ? params.messages.at(-2) : undefined; - const previousContent = previousMessage - ? Array.isArray(previousMessage.content) - ? previousMessage.content - : [previousMessage.content] - : []; - const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); - - if (hasToolResults) { - if (lastContent.some(c => c.type !== 'tool_result')) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - 'The last message must contain only tool_result content if any is present' - ); - } - if (!hasPreviousToolUse) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - 'tool_result blocks are not matching any tool_use from the previous message' - ); - } - } - if (hasPreviousToolUse) { - const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => (c as ToolUseContent).id)); - const toolResultIds = new Set( - lastContent.filter(c => c.type === 'tool_result').map(c => (c as ToolResultContent).toolUseId) - ); - if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - 'ids of tool_result blocks and tool_use blocks from previous message do not match' - ); - } - } - } - - // Use different schemas based on whether tools are provided - if (params.tools) { - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); - } - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); - } - - /** - * Creates an elicitation request for the given parameters. - * For backwards compatibility, `mode` may be omitted for form requests and will default to `"form"`. - * @param params The parameters for the elicitation request. - * @param options Optional request options. - * @returns The result of the elicitation request. - */ - async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { - const mode = (params.mode ?? 'form') as 'form' | 'url'; - - switch (mode) { - case 'url': { - if (!this._clientCapabilities?.elicitation?.url) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); - } - - const urlParams = params as ElicitRequestURLParams; - return this._requestWithSchema({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); - } - case 'form': { - if (!this._clientCapabilities?.elicitation?.form) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); - } - - const formParams: ElicitRequestFormParams = - params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; - - const result = await this._requestWithSchema( - { method: 'elicitation/create', params: formParams }, - ElicitResultSchema, - options - ); - - if (result.action === 'accept' && result.content && formParams.requestedSchema) { - try { - const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); - const validationResult = validator(result.content); - - if (!validationResult.valid) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` - ); - } - } catch (error) { - if (error instanceof ProtocolError) { - throw error; - } - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - return result; - } - } - } - - /** - * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` - * notification for the specified elicitation ID. - * - * @param elicitationId The ID of the elicitation to mark as complete. - * @param options Optional notification options. Useful when the completion notification should be related to a prior request. - * @returns A function that emits the completion notification when awaited. - */ - createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions): () => Promise { - if (!this._clientCapabilities?.elicitation?.url) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - 'Client does not support URL elicitation (required for notifications/elicitation/complete)' - ); - } - - return () => - this.notification( - { - method: 'notifications/elicitation/complete', - params: { - elicitationId - } - }, - options - ); - } - - async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); - } - - /** - * Sends a logging message to the client, if connected. - * Note: You only need to send the parameters object, not the entire JSON-RPC message. - * @see {@linkcode LoggingMessageNotification} - * @param params - * @param sessionId Optional for stateless transports and backward compatibility. - */ - async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { - if (this._capabilities.logging && !this.isMessageIgnored(params.level, sessionId)) { - return this.notification({ method: 'notifications/message', params }); - } - } - - async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { - return this.notification({ - method: 'notifications/resources/updated', - params - }); - } - - async sendResourceListChanged() { - return this.notification({ - method: 'notifications/resources/list_changed' - }); - } - - async sendToolListChanged() { - return this.notification({ method: 'notifications/tools/list_changed' }); - } - - async sendPromptListChanged() { - return this.notification({ method: 'notifications/prompts/list_changed' }); - } -} +export { Server } from './compat.js'; +export type { ServerOptions, ServerTasksCapabilityWithRuntime } from './mcpServer.js'; From 09ded7bc81738700abc85aa4370140d71ea6643c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 10:41:07 +0000 Subject: [PATCH 18/55] chore(sdk): remove dead tsconfig.build.json (tsdown emits dts now) --- packages/sdk/tsconfig.build.json | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 packages/sdk/tsconfig.build.json diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json deleted file mode 100644 index ad0d8e637..000000000 --- a/packages/sdk/tsconfig.build.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "rootDir": "src", - "incremental": false, - "paths": {} - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} From 641a822f67f20bd861a115e47cf549d5d97d944a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 10:41:40 +0000 Subject: [PATCH 19/55] docs: shttpHandler 2025-11 elicitation back-channel limitation + workaround env.send is not provided by shttpHandler; documented why (re-introduces per-session correlation state) and the two alternatives (connect() path for 2025-11, MRTR for 2026-06+). --- docs/shttp-handler-limitations.md | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/shttp-handler-limitations.md diff --git a/docs/shttp-handler-limitations.md b/docs/shttp-handler-limitations.md new file mode 100644 index 000000000..7d17b6b55 --- /dev/null +++ b/docs/shttp-handler-limitations.md @@ -0,0 +1,46 @@ +# `shttpHandler` limitations (2025-11 protocol) + +`shttpHandler` is the request/response entry point for the new architecture: it +calls `mcpServer.dispatch(req, env)` per HTTP POST and streams the result back +as SSE or JSON. It is intentionally stateless — no `_streamMapping`, no +`relatedRequestId` routing. + +## Elicitation / sampling over the new path + +`shttpHandler` does **not** supply `env.send`. If a tool handler calls +`ctx.mcpReq.elicitInput(...)` or `ctx.mcpReq.requestSampling(...)` while +running under `shttpHandler`, it throws `SdkError(NotConnected)` with a message +pointing at the MRTR-native form. + +This is because the 2025-11 mechanism for server→client requests is: + +1. Server writes the elicit request as an SSE event on the open POST response. +2. Client posts the answer back on a **separate** HTTP POST as a JSON-RPC response. +3. Server matches that response to the pending `env.send` promise by request id. + +Step 3 requires a per-session map of `{requestId → resolver}` that survives +across HTTP requests — exactly the `_requestToStreamMapping` / +`_responseHandlers` state that `WebStandardStreamableHTTPServerTransport` +carries and that this rebuild moved out of the request path. + +## What to use instead + +| Need | Use | +|---|---| +| Elicitation/sampling on a 2025-11 client | `mcpServer.connect(new WebStandardStreamableHTTPServerTransport(...))` — the old transport still works via `StreamDriver`, which provides `env.send`. | +| Elicitation/sampling on a 2026-06+ client | Handler returns `IncompleteResult` (MRTR, SEP-2322). `shttpHandler` returns it as the response; client re-calls with `inputResponses`. No back-channel needed. | +| Stateless server, no elicitation | `shttpHandler` directly. | + +## If we decide to implement it later + +Add `pendingServerRequests: Map void>` to +`SessionCompat`. `shttpHandler`: + +- Supply `env.send = (req) => { write req to SSE; return new Promise((res, rej) => session.pendingServerRequests.set(id, ...)) }` +- On inbound POST whose body is a JSON-RPC **response** (not request), look up + the resolver in `session.pendingServerRequests` and resolve it instead of + calling `dispatch`. + +Estimated ~120 LOC across `shttpHandler.ts` + `sessionCompat.ts`. Deferred +because it re-introduces the per-session correlation state the rebuild +removed, and MRTR (accepted-with-changes) makes it obsolete. From 00dfea9cea2204da58e871eeeb301e0aab3b7f44 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 10:44:29 +0000 Subject: [PATCH 20/55] chore: lint:fix:all (prettier + unicorn/no-this-assignment disable) --- packages/client/package.json | 204 +++---- packages/core/package.json | 190 +++--- packages/core/src/shared/protocol.ts | 7 +- packages/core/src/shared/streamDriver.ts | 2 +- packages/core/src/util/compatSchema.ts | 41 ++ packages/middleware/express/package.json | 132 ++--- packages/middleware/fastify/package.json | 128 ++-- packages/middleware/hono/package.json | 128 ++-- packages/middleware/node/package.json | 140 ++--- packages/sdk/package.json | 658 ++++++++++----------- packages/sdk/src/server/auth/errors.ts | 2 +- packages/sdk/src/types.ts | 2 +- packages/server-auth-legacy/package.json | 160 ++--- packages/server/package.json | 206 +++---- packages/server/src/server/serverLegacy.ts | 6 +- 15 files changed, 1023 insertions(+), 983 deletions(-) create mode 100644 packages/core/src/util/compatSchema.ts diff --git a/packages/client/package.json b/packages/client/package.json index e6a8b3440..5bb25629b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,107 +1,107 @@ { - "name": "@modelcontextprotocol/client", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Client package", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "client" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "name": "@modelcontextprotocol/client", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Client package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" }, - "./validators/cf-worker": { - "types": "./dist/validators/cfWorker.d.mts", - "import": "./dist/validators/cfWorker.mjs" + "engines": { + "node": ">=20" }, - "./_shims": { - "workerd": { - "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" - }, - "browser": { - "types": "./dist/shimsBrowser.d.mts", - "import": "./dist/shimsBrowser.mjs" - }, - "node": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - }, - "default": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - } - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "cross-spawn": "catalog:runtimeClientOnly", - "eventsource": "catalog:runtimeClientOnly", - "eventsource-parser": "catalog:runtimeClientOnly", - "jose": "catalog:runtimeClientOnly", - "pkce-challenge": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@cfworker/json-schema": "catalog:runtimeShared", - "@types/content-type": "catalog:devTools", - "@types/cross-spawn": "catalog:devTools", - "@types/eventsource": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "@eslint/js": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools", - "tsdown": "catalog:devTools" - }, - "types": "./dist/index.d.mts", - "typesVersions": { - "*": { - "stdio": [ - "./dist/stdio.d.mts" - ], - "*": [ - "./dist/*.d.mts" - ] + "keywords": [ + "modelcontextprotocol", + "mcp", + "client" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./validators/cf-worker": { + "types": "./dist/validators/cfWorker.d.mts", + "import": "./dist/validators/cfWorker.mjs" + }, + "./_shims": { + "workerd": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "browser": { + "types": "./dist/shimsBrowser.d.mts", + "import": "./dist/shimsBrowser.mjs" + }, + "node": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + }, + "default": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "cross-spawn": "catalog:runtimeClientOnly", + "eventsource": "catalog:runtimeClientOnly", + "eventsource-parser": "catalog:runtimeClientOnly", + "jose": "catalog:runtimeClientOnly", + "pkce-challenge": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@cfworker/json-schema": "catalog:runtimeShared", + "@types/content-type": "catalog:devTools", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "@eslint/js": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools", + "tsdown": "catalog:devTools" + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "stdio": [ + "./dist/stdio.d.mts" + ], + "*": [ + "./dist/*.d.mts" + ] + } } - } } diff --git a/packages/core/package.json b/packages/core/package.json index 7b55e91c1..855303983 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,101 +1,101 @@ { - "name": "@modelcontextprotocol/core", - "private": true, - "version": "2.0.0-alpha.1", - "description": "Model Context Protocol implementation for TypeScript - Core package", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "core" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs" + "name": "@modelcontextprotocol/core", + "private": true, + "version": "2.0.0-alpha.1", + "description": "Model Context Protocol implementation for TypeScript - Core package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" }, - "./types": { - "types": "./src/exports/types/index.ts", - "import": "./src/exports/types/index.ts" + "engines": { + "node": ">=20" }, - "./public": { - "types": "./src/exports/public/index.ts", - "import": "./src/exports/public/index.ts" - } - }, - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "ajv": "catalog:runtimeShared", - "ajv-formats": "catalog:runtimeShared", - "json-schema-typed": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "peerDependencies": { - "@cfworker/json-schema": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true + "keywords": [ + "modelcontextprotocol", + "mcp", + "core" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs" + }, + "./types": { + "types": "./src/exports/types/index.ts", + "import": "./src/exports/types/index.ts" + }, + "./public": { + "types": "./src/exports/public/index.ts", + "import": "./src/exports/public/index.ts" + } }, - "zod": { - "optional": false - } - }, - "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@cfworker/json-schema": "catalog:runtimeShared", - "@eslint/js": "catalog:devTools", - "@types/content-type": "catalog:devTools", - "@types/cors": "catalog:devTools", - "@types/cross-spawn": "catalog:devTools", - "@types/eventsource": "catalog:devTools", - "@types/express": "catalog:devTools", - "@types/express-serve-static-core": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - }, - "types": "./dist/index.d.mts", - "typesVersions": { - "*": { - "public": [ - "./dist/exports/public/index.d.mts" - ], - "types": [ - "./dist/types/index.d.mts" - ], - "*": [ - "./dist/*.d.mts" - ] + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "ajv": "catalog:runtimeShared", + "ajv-formats": "catalog:runtimeShared", + "json-schema-typed": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@cfworker/json-schema": "catalog:runtimeShared", + "@eslint/js": "catalog:devTools", + "@types/content-type": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "public": [ + "./dist/exports/public/index.d.mts" + ], + "types": [ + "./dist/types/index.d.mts" + ], + "*": [ + "./dist/*.d.mts" + ] + } } - } } diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 5c9f21afc..21e12ec15 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -56,13 +56,10 @@ export abstract class Protocol { onerror?: (error: Error) => void; constructor(private _options?: ProtocolOptions) { - // eslint-disable-next-line @typescript-eslint/no-this-alias + // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment const self = this; this._dispatcher = new (class extends Dispatcher { - protected override buildContext( - base: BaseContext, - env: DispatchEnv & { _transportExtra?: MessageExtraInfo } - ): ContextT { + protected override buildContext(base: BaseContext, env: DispatchEnv & { _transportExtra?: MessageExtraInfo }): ContextT { return self.buildContext(base, env._transportExtra); } })(); diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index f26d7e97f..79cd53cdc 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -25,9 +25,9 @@ import { } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; -import type { DispatchEnv, Dispatcher } from './dispatcher.js'; import type { NotificationOptions, ProgressCallback, RequestOptions } from './context.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; +import type { DispatchEnv, Dispatcher } from './dispatcher.js'; import type { InboundContext, TaskManagerHost, TaskManagerOptions } from './taskManager.js'; import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport } from './transport.js'; diff --git a/packages/core/src/util/compatSchema.ts b/packages/core/src/util/compatSchema.ts new file mode 100644 index 000000000..63956c97b --- /dev/null +++ b/packages/core/src/util/compatSchema.ts @@ -0,0 +1,41 @@ +/** + * Helpers for the Zod-schema form of `setRequestHandler` / `setNotificationHandler`. + * + * v1 accepted a Zod object whose `.shape.method` is `z.literal('')`. + * v2 also accepts the method string directly. These helpers detect the schema + * form and extract the literal so the dispatcher can route to the correct path. + * + * @internal + */ + +/** + * Minimal structural type for a Zod object schema. The `method` literal is + * checked at runtime by `extractMethodLiteral`; the type-level constraint + * is intentionally loose because zod v4's `ZodLiteral` doesn't surface `.value` + * in its declared type (only at runtime). + */ +export interface ZodLikeRequestSchema { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shape: any; + parse(input: unknown): unknown; +} + +/** True if `arg` looks like a Zod object schema (has `.shape` and `.parse`). */ +export function isZodLikeSchema(arg: unknown): arg is ZodLikeRequestSchema { + return typeof arg === 'object' && arg !== null && 'shape' in arg && typeof (arg as { parse?: unknown }).parse === 'function'; +} + +/** + * Extracts the string value from a Zod-like schema's `shape.method` literal. + * Throws if no string `method` literal is present. + */ +export function extractMethodLiteral(schema: ZodLikeRequestSchema): string { + const methodField = (schema.shape as Record | undefined)?.method as + | { value?: unknown; def?: { values?: unknown[] } } + | undefined; + const value = methodField?.value ?? methodField?.def?.values?.[0]; + if (typeof value !== 'string') { + throw new TypeError('Schema passed to setRequestHandler/setNotificationHandler is missing a string `method` literal'); + } + return value; +} diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index ebdc75f56..c7e5763ae 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -1,68 +1,68 @@ { - "name": "@modelcontextprotocol/express", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "express", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "npm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": {}, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "express": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@types/express": "catalog:devTools", - "@types/express-serve-static-core": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - }, - "types": "./dist/index.d.mts" + "name": "@modelcontextprotocol/express", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "express", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "express": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index 40ded8c11..0cb8eff24 100644 --- a/packages/middleware/fastify/package.json +++ b/packages/middleware/fastify/package.json @@ -1,66 +1,66 @@ { - "name": "@modelcontextprotocol/fastify", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "fastify", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "npm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "npm run typecheck && npm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": {}, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "fastify": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - }, - "types": "./dist/index.d.mts" + "name": "@modelcontextprotocol/fastify", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "fastify", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "fastify": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index b386e9fba..497f2b127 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -1,66 +1,66 @@ { - "name": "@modelcontextprotocol/hono", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "hono", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": {}, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "hono": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - }, - "types": "./dist/index.d.mts" + "name": "@modelcontextprotocol/hono", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "hono", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "hono": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index f32e0cb7b..a284ea597 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -1,72 +1,72 @@ { - "name": "@modelcontextprotocol/node", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "node.js", - "middleware" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "@hono/node-server": "catalog:runtimeServerOnly" - }, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "hono": "catalog:runtimeServerOnly" - }, - "devDependencies": { - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - }, - "types": "./dist/index.d.mts" + "name": "@modelcontextprotocol/node", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "node.js", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "hono": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts" } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a3fd75c86..635452d18 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,332 +1,332 @@ { - "name": "@modelcontextprotocol/sdk", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Full SDK (re-exports client, server, and node middleware)", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "types": "./dist/index.d.ts", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./stdio": { - "types": "./dist/stdio.d.mts", - "import": "./dist/stdio.mjs" - }, - "./types.js": { - "types": "./dist/types.d.mts", - "import": "./dist/types.mjs" - }, - "./types": { - "types": "./dist/types.d.mts", - "import": "./dist/types.mjs" - }, - "./server/index.js": { - "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" - }, - "./server/index": { - "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" - }, - "./server/mcp.js": { - "types": "./dist/server/mcp.d.mts", - "import": "./dist/server/mcp.mjs" - }, - "./server/mcp": { - "types": "./dist/server/mcp.d.mts", - "import": "./dist/server/mcp.mjs" - }, - "./server/zod-compat.js": { - "types": "./dist/server/zod-compat.d.mts", - "import": "./dist/server/zod-compat.mjs" - }, - "./server/zod-compat": { - "types": "./dist/server/zod-compat.d.mts", - "import": "./dist/server/zod-compat.mjs" - }, - "./server/stdio.js": { - "types": "./dist/server/stdio.d.mts", - "import": "./dist/server/stdio.mjs" - }, - "./server/stdio": { - "types": "./dist/server/stdio.d.mts", - "import": "./dist/server/stdio.mjs" - }, - "./server/streamableHttp.js": { - "types": "./dist/server/streamableHttp.d.mts", - "import": "./dist/server/streamableHttp.mjs" - }, - "./server/streamableHttp": { - "types": "./dist/server/streamableHttp.d.mts", - "import": "./dist/server/streamableHttp.mjs" - }, - "./server/auth/types.js": { - "types": "./dist/server/auth/types.d.mts", - "import": "./dist/server/auth/types.mjs" - }, - "./server/auth/types": { - "types": "./dist/server/auth/types.d.mts", - "import": "./dist/server/auth/types.mjs" - }, - "./server/auth/errors.js": { - "types": "./dist/server/auth/errors.d.mts", - "import": "./dist/server/auth/errors.mjs" - }, - "./server/auth/errors": { - "types": "./dist/server/auth/errors.d.mts", - "import": "./dist/server/auth/errors.mjs" - }, - "./client": { - "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" - }, - "./client/index.js": { - "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" - }, - "./client/index": { - "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" - }, - "./client/stdio.js": { - "types": "./dist/client/stdio.d.mts", - "import": "./dist/client/stdio.mjs" - }, - "./client/stdio": { - "types": "./dist/client/stdio.d.mts", - "import": "./dist/client/stdio.mjs" - }, - "./client/streamableHttp.js": { - "types": "./dist/client/streamableHttp.d.mts", - "import": "./dist/client/streamableHttp.mjs" - }, - "./client/streamableHttp": { - "types": "./dist/client/streamableHttp.d.mts", - "import": "./dist/client/streamableHttp.mjs" - }, - "./client/sse.js": { - "types": "./dist/client/sse.d.mts", - "import": "./dist/client/sse.mjs" - }, - "./client/sse": { - "types": "./dist/client/sse.d.mts", - "import": "./dist/client/sse.mjs" - }, - "./client/auth.js": { - "types": "./dist/client/auth.d.mts", - "import": "./dist/client/auth.mjs" - }, - "./client/auth": { - "types": "./dist/client/auth.d.mts", - "import": "./dist/client/auth.mjs" - }, - "./shared/protocol.js": { - "types": "./dist/shared/protocol.d.mts", - "import": "./dist/shared/protocol.mjs" - }, - "./shared/protocol": { - "types": "./dist/shared/protocol.d.mts", - "import": "./dist/shared/protocol.mjs" - }, - "./shared/transport.js": { - "types": "./dist/shared/transport.d.mts", - "import": "./dist/shared/transport.mjs" - }, - "./shared/transport": { - "types": "./dist/shared/transport.d.mts", - "import": "./dist/shared/transport.mjs" - }, - "./shared/auth.js": { - "types": "./dist/shared/auth.d.mts", - "import": "./dist/shared/auth.mjs" - }, - "./shared/auth": { - "types": "./dist/shared/auth.d.mts", - "import": "./dist/shared/auth.mjs" - }, - "./server/auth/middleware/bearerAuth.js": { - "types": "./dist/server/auth/middleware/bearerAuth.d.mts", - "import": "./dist/server/auth/middleware/bearerAuth.mjs" - }, - "./server/auth/middleware/bearerAuth": { - "types": "./dist/server/auth/middleware/bearerAuth.d.mts", - "import": "./dist/server/auth/middleware/bearerAuth.mjs" - }, - "./server/auth/router.js": { - "types": "./dist/server/auth/router.d.mts", - "import": "./dist/server/auth/router.mjs" - }, - "./server/auth/router": { - "types": "./dist/server/auth/router.d.mts", - "import": "./dist/server/auth/router.mjs" - }, - "./server/auth/provider.js": { - "types": "./dist/server/auth/provider.d.mts", - "import": "./dist/server/auth/provider.mjs" - }, - "./server/auth/provider": { - "types": "./dist/server/auth/provider.d.mts", - "import": "./dist/server/auth/provider.mjs" - }, - "./server/auth/clients.js": { - "types": "./dist/server/auth/clients.d.mts", - "import": "./dist/server/auth/clients.mjs" - }, - "./server/auth/clients": { - "types": "./dist/server/auth/clients.d.mts", - "import": "./dist/server/auth/clients.mjs" - }, - "./inMemory.js": { - "types": "./dist/inMemory.d.mts", - "import": "./dist/inMemory.mjs" - }, - "./inMemory": { - "types": "./dist/inMemory.d.mts", - "import": "./dist/inMemory.mjs" - }, - "./server/completable.js": { - "types": "./dist/server/completable.d.mts", - "import": "./dist/server/completable.mjs" - }, - "./server/completable": { - "types": "./dist/server/completable.d.mts", - "import": "./dist/server/completable.mjs" - }, - "./server/sse.js": { - "types": "./dist/server/sse.d.mts", - "import": "./dist/server/sse.mjs" - }, - "./server/sse": { - "types": "./dist/server/sse.d.mts", - "import": "./dist/server/sse.mjs" - }, - "./experimental/tasks": { - "types": "./dist/experimental/tasks.d.mts", - "import": "./dist/experimental/tasks.mjs" - }, - "./server": { - "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" - }, - "./server.js": { - "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" - }, - "./client.js": { - "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" - }, - "./server/webStandardStreamableHttp.js": { - "types": "./dist/server/webStandardStreamableHttp.d.mts", - "import": "./dist/server/webStandardStreamableHttp.mjs" - }, - "./server/webStandardStreamableHttp": { - "types": "./dist/server/webStandardStreamableHttp.d.mts", - "import": "./dist/server/webStandardStreamableHttp.mjs" - }, - "./shared/stdio.js": { - "types": "./dist/shared/stdio.d.mts", - "import": "./dist/shared/stdio.mjs" - }, - "./shared/stdio": { - "types": "./dist/shared/stdio.d.mts", - "import": "./dist/shared/stdio.mjs" - }, - "./validation/types.js": { - "types": "./dist/validation/types.d.mts", - "import": "./dist/validation/types.mjs" - }, - "./validation/types": { - "types": "./dist/validation/types.d.mts", - "import": "./dist/validation/types.mjs" - }, - "./validation/cfworker-provider.js": { - "types": "./dist/validation/cfworker-provider.d.mts", - "import": "./dist/validation/cfworker-provider.mjs" - }, - "./validation/cfworker-provider": { - "types": "./dist/validation/cfworker-provider.d.mts", - "import": "./dist/validation/cfworker-provider.mjs" - }, - "./validation/ajv-provider.js": { - "types": "./dist/validation/ajv-provider.d.mts", - "import": "./dist/validation/ajv-provider.mjs" - }, - "./validation/ajv-provider": { - "types": "./dist/validation/ajv-provider.d.mts", - "import": "./dist/validation/ajv-provider.mjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "prepack": "pnpm run build" - }, - "dependencies": { - "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/node": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/server-auth-legacy": "workspace:^" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0", - "hono": "*" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - }, - "hono": { - "optional": true - } - }, - "devDependencies": { - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "prettier": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "vitest": "catalog:devTools", - "zod": "catalog:runtimeShared" - }, - "typesVersions": { - "*": { - "*.js": [ - "dist/*.d.mts" - ], - "*": [ - "dist/*.d.mts", - "dist/*/index.d.mts" - ] + "name": "@modelcontextprotocol/sdk", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Full SDK (re-exports client, server, and node middleware)", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./stdio": { + "types": "./dist/stdio.d.mts", + "import": "./dist/stdio.mjs" + }, + "./types.js": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + }, + "./server/index.js": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./server/index": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./server/mcp.js": { + "types": "./dist/server/mcp.d.mts", + "import": "./dist/server/mcp.mjs" + }, + "./server/mcp": { + "types": "./dist/server/mcp.d.mts", + "import": "./dist/server/mcp.mjs" + }, + "./server/zod-compat.js": { + "types": "./dist/server/zod-compat.d.mts", + "import": "./dist/server/zod-compat.mjs" + }, + "./server/zod-compat": { + "types": "./dist/server/zod-compat.d.mts", + "import": "./dist/server/zod-compat.mjs" + }, + "./server/stdio.js": { + "types": "./dist/server/stdio.d.mts", + "import": "./dist/server/stdio.mjs" + }, + "./server/stdio": { + "types": "./dist/server/stdio.d.mts", + "import": "./dist/server/stdio.mjs" + }, + "./server/streamableHttp.js": { + "types": "./dist/server/streamableHttp.d.mts", + "import": "./dist/server/streamableHttp.mjs" + }, + "./server/streamableHttp": { + "types": "./dist/server/streamableHttp.d.mts", + "import": "./dist/server/streamableHttp.mjs" + }, + "./server/auth/types.js": { + "types": "./dist/server/auth/types.d.mts", + "import": "./dist/server/auth/types.mjs" + }, + "./server/auth/types": { + "types": "./dist/server/auth/types.d.mts", + "import": "./dist/server/auth/types.mjs" + }, + "./server/auth/errors.js": { + "types": "./dist/server/auth/errors.d.mts", + "import": "./dist/server/auth/errors.mjs" + }, + "./server/auth/errors": { + "types": "./dist/server/auth/errors.d.mts", + "import": "./dist/server/auth/errors.mjs" + }, + "./client": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./client/index.js": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./client/index": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./client/stdio.js": { + "types": "./dist/client/stdio.d.mts", + "import": "./dist/client/stdio.mjs" + }, + "./client/stdio": { + "types": "./dist/client/stdio.d.mts", + "import": "./dist/client/stdio.mjs" + }, + "./client/streamableHttp.js": { + "types": "./dist/client/streamableHttp.d.mts", + "import": "./dist/client/streamableHttp.mjs" + }, + "./client/streamableHttp": { + "types": "./dist/client/streamableHttp.d.mts", + "import": "./dist/client/streamableHttp.mjs" + }, + "./client/sse.js": { + "types": "./dist/client/sse.d.mts", + "import": "./dist/client/sse.mjs" + }, + "./client/sse": { + "types": "./dist/client/sse.d.mts", + "import": "./dist/client/sse.mjs" + }, + "./client/auth.js": { + "types": "./dist/client/auth.d.mts", + "import": "./dist/client/auth.mjs" + }, + "./client/auth": { + "types": "./dist/client/auth.d.mts", + "import": "./dist/client/auth.mjs" + }, + "./shared/protocol.js": { + "types": "./dist/shared/protocol.d.mts", + "import": "./dist/shared/protocol.mjs" + }, + "./shared/protocol": { + "types": "./dist/shared/protocol.d.mts", + "import": "./dist/shared/protocol.mjs" + }, + "./shared/transport.js": { + "types": "./dist/shared/transport.d.mts", + "import": "./dist/shared/transport.mjs" + }, + "./shared/transport": { + "types": "./dist/shared/transport.d.mts", + "import": "./dist/shared/transport.mjs" + }, + "./shared/auth.js": { + "types": "./dist/shared/auth.d.mts", + "import": "./dist/shared/auth.mjs" + }, + "./shared/auth": { + "types": "./dist/shared/auth.d.mts", + "import": "./dist/shared/auth.mjs" + }, + "./server/auth/middleware/bearerAuth.js": { + "types": "./dist/server/auth/middleware/bearerAuth.d.mts", + "import": "./dist/server/auth/middleware/bearerAuth.mjs" + }, + "./server/auth/middleware/bearerAuth": { + "types": "./dist/server/auth/middleware/bearerAuth.d.mts", + "import": "./dist/server/auth/middleware/bearerAuth.mjs" + }, + "./server/auth/router.js": { + "types": "./dist/server/auth/router.d.mts", + "import": "./dist/server/auth/router.mjs" + }, + "./server/auth/router": { + "types": "./dist/server/auth/router.d.mts", + "import": "./dist/server/auth/router.mjs" + }, + "./server/auth/provider.js": { + "types": "./dist/server/auth/provider.d.mts", + "import": "./dist/server/auth/provider.mjs" + }, + "./server/auth/provider": { + "types": "./dist/server/auth/provider.d.mts", + "import": "./dist/server/auth/provider.mjs" + }, + "./server/auth/clients.js": { + "types": "./dist/server/auth/clients.d.mts", + "import": "./dist/server/auth/clients.mjs" + }, + "./server/auth/clients": { + "types": "./dist/server/auth/clients.d.mts", + "import": "./dist/server/auth/clients.mjs" + }, + "./inMemory.js": { + "types": "./dist/inMemory.d.mts", + "import": "./dist/inMemory.mjs" + }, + "./inMemory": { + "types": "./dist/inMemory.d.mts", + "import": "./dist/inMemory.mjs" + }, + "./server/completable.js": { + "types": "./dist/server/completable.d.mts", + "import": "./dist/server/completable.mjs" + }, + "./server/completable": { + "types": "./dist/server/completable.d.mts", + "import": "./dist/server/completable.mjs" + }, + "./server/sse.js": { + "types": "./dist/server/sse.d.mts", + "import": "./dist/server/sse.mjs" + }, + "./server/sse": { + "types": "./dist/server/sse.d.mts", + "import": "./dist/server/sse.mjs" + }, + "./experimental/tasks": { + "types": "./dist/experimental/tasks.d.mts", + "import": "./dist/experimental/tasks.mjs" + }, + "./server": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./server.js": { + "types": "./dist/server/index.d.mts", + "import": "./dist/server/index.mjs" + }, + "./client.js": { + "types": "./dist/client/index.d.mts", + "import": "./dist/client/index.mjs" + }, + "./server/webStandardStreamableHttp.js": { + "types": "./dist/server/webStandardStreamableHttp.d.mts", + "import": "./dist/server/webStandardStreamableHttp.mjs" + }, + "./server/webStandardStreamableHttp": { + "types": "./dist/server/webStandardStreamableHttp.d.mts", + "import": "./dist/server/webStandardStreamableHttp.mjs" + }, + "./shared/stdio.js": { + "types": "./dist/shared/stdio.d.mts", + "import": "./dist/shared/stdio.mjs" + }, + "./shared/stdio": { + "types": "./dist/shared/stdio.d.mts", + "import": "./dist/shared/stdio.mjs" + }, + "./validation/types.js": { + "types": "./dist/validation/types.d.mts", + "import": "./dist/validation/types.mjs" + }, + "./validation/types": { + "types": "./dist/validation/types.d.mts", + "import": "./dist/validation/types.mjs" + }, + "./validation/cfworker-provider.js": { + "types": "./dist/validation/cfworker-provider.d.mts", + "import": "./dist/validation/cfworker-provider.mjs" + }, + "./validation/cfworker-provider": { + "types": "./dist/validation/cfworker-provider.d.mts", + "import": "./dist/validation/cfworker-provider.mjs" + }, + "./validation/ajv-provider.js": { + "types": "./dist/validation/ajv-provider.d.mts", + "import": "./dist/validation/ajv-provider.mjs" + }, + "./validation/ajv-provider": { + "types": "./dist/validation/ajv-provider.d.mts", + "import": "./dist/validation/ajv-provider.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "prepack": "pnpm run build" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/server-auth-legacy": "workspace:^" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0", + "hono": "*" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + }, + "hono": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "vitest": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "typesVersions": { + "*": { + "*.js": [ + "dist/*.d.mts" + ], + "*": [ + "dist/*.d.mts", + "dist/*/index.d.mts" + ] + } } - } } diff --git a/packages/sdk/src/server/auth/errors.ts b/packages/sdk/src/server/auth/errors.ts index 68c248ac7..c65da81ca 100644 --- a/packages/sdk/src/server/auth/errors.ts +++ b/packages/sdk/src/server/auth/errors.ts @@ -1,4 +1,5 @@ // v1 compat: `@modelcontextprotocol/sdk/server/auth/errors.js` +export { OAuthErrorCode } from '@modelcontextprotocol/server'; export { AccessDeniedError, CustomOAuthError, @@ -20,4 +21,3 @@ export { UnsupportedResponseTypeError, UnsupportedTokenTypeError } from '@modelcontextprotocol/server-auth-legacy'; -export { OAuthErrorCode } from '@modelcontextprotocol/server'; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 7c8c8d4d7..db973e627 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -3,8 +3,8 @@ // v2 splits them: spec TypeScript types live in the server barrel (via core/public), // zod schema constants live at @modelcontextprotocol/server/zod-schemas. -export * from '@modelcontextprotocol/server/zod-schemas'; export * from '@modelcontextprotocol/server'; +export * from '@modelcontextprotocol/server/zod-schemas'; // Explicit tie-break for symbols both barrels export. export { fromJsonSchema } from '@modelcontextprotocol/server'; // Explicit re-exports of commonly-used spec types (belt-and-suspenders over the diff --git a/packages/server-auth-legacy/package.json b/packages/server-auth-legacy/package.json index fdbb97cb4..0329a06ca 100644 --- a/packages/server-auth-legacy/package.json +++ b/packages/server-auth-legacy/package.json @@ -1,83 +1,83 @@ { - "name": "@modelcontextprotocol/server-auth-legacy", - "private": false, - "version": "2.0.0-alpha.2", - "description": "Frozen v1 OAuth Authorization Server helpers (mcpAuthRouter, ProxyOAuthServerProvider) for the Model Context Protocol TypeScript SDK. Deprecated; use a dedicated OAuth server in production.", - "deprecated": "The MCP SDK no longer ships an Authorization Server implementation. This package is a frozen copy of the v1 src/server/auth helpers for migration purposes only and will not receive new features. Use a dedicated OAuth Authorization Server (e.g. an IdP) and the Resource Server helpers in @modelcontextprotocol/express instead.", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "oauth", - "express", - "legacy" - ], - "types": "./dist/index.d.mts", - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "name": "@modelcontextprotocol/server-auth-legacy", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Frozen v1 OAuth Authorization Server helpers (mcpAuthRouter, ProxyOAuthServerProvider) for the Model Context Protocol TypeScript SDK. Deprecated; use a dedicated OAuth server in production.", + "deprecated": "The MCP SDK no longer ships an Authorization Server implementation. This package is a frozen copy of the v1 src/server/auth helpers for migration purposes only and will not receive new features. Use a dedicated OAuth Authorization Server (e.g. an IdP) and the Resource Server helpers in @modelcontextprotocol/express instead.", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "oauth", + "express", + "legacy" + ], + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "cors": "catalog:runtimeServerOnly", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependencies": { + "express": "catalog:runtimeServerOnly" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@types/supertest": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "express": "catalog:runtimeServerOnly", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "npm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "cors": "catalog:runtimeServerOnly", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "catalog:runtimeShared", - "zod": "catalog:runtimeShared" - }, - "peerDependencies": { - "express": "catalog:runtimeServerOnly" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - }, - "devDependencies": { - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@eslint/js": "catalog:devTools", - "@types/cors": "catalog:devTools", - "@types/express": "catalog:devTools", - "@types/express-serve-static-core": "catalog:devTools", - "@types/supertest": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "express": "catalog:runtimeServerOnly", - "prettier": "catalog:devTools", - "supertest": "catalog:devTools", - "tsdown": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - } } diff --git a/packages/server/package.json b/packages/server/package.json index 226b54efc..2bc6f8a9a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,109 +1,109 @@ { - "name": "@modelcontextprotocol/server", - "version": "2.0.0-alpha.2", - "description": "Model Context Protocol implementation for TypeScript - Server package", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp", - "server" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "name": "@modelcontextprotocol/server", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Server package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" }, - "./zod-schemas": { - "types": "./dist/zodSchemas.d.mts", - "import": "./dist/zodSchemas.mjs" + "engines": { + "node": ">=20" }, - "./validators/cf-worker": { - "types": "./dist/validators/cfWorker.d.mts", - "import": "./dist/validators/cfWorker.mjs" + "keywords": [ + "modelcontextprotocol", + "mcp", + "server" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./zod-schemas": { + "types": "./dist/zodSchemas.d.mts", + "import": "./dist/zodSchemas.mjs" + }, + "./validators/cf-worker": { + "types": "./dist/validators/cfWorker.d.mts", + "import": "./dist/validators/cfWorker.mjs" + }, + "./_shims": { + "workerd": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "browser": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "node": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + }, + "default": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + } + } }, - "./_shims": { - "workerd": { - "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" - }, - "browser": { - "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" - }, - "node": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - }, - "default": { - "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" - } - } - }, - "files": [ - "dist" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "test": "vitest run", - "test:watch": "vitest", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@cfworker/json-schema": "catalog:runtimeShared", - "@eslint/js": "catalog:devTools", - "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@types/cross-spawn": "catalog:devTools", - "@types/eventsource": "catalog:devTools", - "@typescript/native-preview": "catalog:devTools", - "eslint": "catalog:devTools", - "eslint-config-prettier": "catalog:devTools", - "eslint-plugin-n": "catalog:devTools", - "prettier": "catalog:devTools", - "supertest": "catalog:devTools", - "tsdown": "catalog:devTools", - "tsx": "catalog:devTools", - "typescript": "catalog:devTools", - "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" - }, - "types": "./dist/index.d.mts", - "typesVersions": { - "*": { - "zod-schemas": [ - "./dist/zodSchemas.d.mts" - ], - "validators/cf-worker": [ - "./dist/validators/cfWorker.d.mts" - ], - "*": [ - "./dist/*.d.mts" - ] + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "zod-schemas": [ + "./dist/zodSchemas.d.mts" + ], + "validators/cf-worker": [ + "./dist/validators/cfWorker.d.mts" + ], + "*": [ + "./dist/*.d.mts" + ] + } } - } } diff --git a/packages/server/src/server/serverLegacy.ts b/packages/server/src/server/serverLegacy.ts index 7f1e71f57..4829f99c3 100644 --- a/packages/server/src/server/serverLegacy.ts +++ b/packages/server/src/server/serverLegacy.ts @@ -25,8 +25,10 @@ export type LegacyPromptCallback = ( * v1 compat: extract the literal method string from a `z.object({method: z.literal('x'), ...})` schema. */ export function extractMethodFromSchema(schema: { shape: { method: unknown } }): string { - const lit = schema.shape.method as { value?: unknown; _zod?: { def?: { values?: unknown[] } } }; - const v = lit?.value ?? lit?._zod?.def?.values?.[0]; + const lit = schema.shape.method as + | { value?: unknown; def?: { values?: unknown[] }; _zod?: { def?: { values?: unknown[] } } } + | undefined; + const v = lit?.value ?? lit?.def?.values?.[0] ?? lit?._zod?.def?.values?.[0]; if (typeof v !== 'string') { throw new TypeError('setRequestHandler(schema, handler): schema.shape.method must be a z.literal(string)'); } From efacdbdc5dfa000c9154fa938ead395219ca52c1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 11:12:00 +0000 Subject: [PATCH 21/55] feat(server): Backchannel2511 + version-gated env.send in shttpHandler - New backchannel2511.ts: per-session {requestId -> resolver} map for the pre-2026-06 server-to-client request channel (elicitation/sampling over SSE) - SessionCompat: store negotiated protocolVersion per session - shttpHandler: route incoming JSON-RPC responses to backchannel; supply env.send for sessions negotiated < 2026-06-30; register standalone GET writer with backchannel --- packages/server/src/server/backchannel2511.ts | 136 ++++++++++++++++++ packages/server/src/server/sessionCompat.ts | 12 +- packages/server/src/server/shttpHandler.ts | 54 ++++++- 3 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/server/backchannel2511.ts diff --git a/packages/server/src/server/backchannel2511.ts b/packages/server/src/server/backchannel2511.ts new file mode 100644 index 000000000..f327e8cbe --- /dev/null +++ b/packages/server/src/server/backchannel2511.ts @@ -0,0 +1,136 @@ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResultResponse, + Request, + RequestOptions, + Result +} from '@modelcontextprotocol/core'; +import { DEFAULT_REQUEST_TIMEOUT_MSEC, isJSONRPCErrorResponse, ProtocolError, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; + +/** + * Isolated 2025-11 server-to-client request backchannel for {@linkcode shttpHandler}. + * + * The pre-2026-06 protocol allows a server to send `elicitation/create` and + * `sampling/createMessage` requests to the client mid-tool-call by writing them as + * SSE events on the open POST response stream and waiting for the client to POST + * the response back. This class owns the per-session `{requestId -> resolver}` + * map that correlation requires, plus the standalone-GET writer registry used for + * unsolicited server notifications. + * + * It exists so this stateful behaviour is in one removable file once 2026-06 (MRTR) + * is the floor and `env.send` becomes a hard error in stateless paths. + */ +export class Backchannel2511 { + private _pending = new Map void; reject: (e: Error) => void }>>(); + private _standaloneWriters = new Map void>(); + private _nextId = 0; + + /** + * Returns an `env.send` implementation bound to the given session and POST-stream writer. + * The returned function writes the outbound JSON-RPC request to `writeSSE` and resolves when + * {@linkcode handleResponse} is called for the same id on the same session. + */ + makeEnvSend(sessionId: string, writeSSE: (msg: JSONRPCMessage) => void): (req: Request, opts?: RequestOptions) => Promise { + return (req: Request, opts?: RequestOptions): Promise => { + return new Promise((resolve, reject) => { + if (opts?.signal?.aborted) { + reject(opts.signal.reason instanceof Error ? opts.signal.reason : new Error(String(opts.signal.reason))); + return; + } + + const id = this._nextId++; + const sessionMap = this._pending.get(sessionId) ?? new Map(); + this._pending.set(sessionId, sessionMap); + + const timeoutMs = opts?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timer = setTimeout(() => { + sessionMap.delete(id); + reject(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: timeoutMs })); + }, timeoutMs); + + const settle = { + resolve: (r: Result) => { + clearTimeout(timer); + sessionMap.delete(id); + resolve(r); + }, + reject: (e: Error) => { + clearTimeout(timer); + sessionMap.delete(id); + reject(e); + } + }; + sessionMap.set(id, settle); + + opts?.signal?.addEventListener( + 'abort', + () => { + settle.reject( + opts.signal!.reason instanceof Error ? opts.signal!.reason : new Error(String(opts.signal!.reason)) + ); + }, + { once: true } + ); + + const wire: JSONRPCRequest = { jsonrpc: '2.0', id, method: req.method, params: req.params }; + try { + writeSSE(wire); + } catch (error) { + settle.reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }; + } + + /** + * Routes an incoming JSON-RPC response (from a client POST) to the waiting `env.send` promise. + * @returns true if a pending request matched and was settled. + */ + handleResponse(sessionId: string, response: JSONRPCResultResponse | JSONRPCErrorResponse): boolean { + const sessionMap = this._pending.get(sessionId); + const id = typeof response.id === 'number' ? response.id : Number(response.id); + const settle = sessionMap?.get(id); + if (!settle) return false; + if (isJSONRPCErrorResponse(response)) { + settle.reject(ProtocolError.fromError(response.error.code, response.error.message, response.error.data)); + } else { + settle.resolve(response.result); + } + return true; + } + + /** + * Registers (or clears) the standalone GET subscription writer for a session, used to + * deliver server-initiated notifications outside any POST request. + */ + setStandaloneWriter(sessionId: string, write: ((msg: JSONRPCMessage) => void) | undefined): void { + if (write) this._standaloneWriters.set(sessionId, write); + else this._standaloneWriters.delete(sessionId); + } + + /** Writes a message on the session's standalone GET stream, if one is open. */ + writeStandalone(sessionId: string, msg: JSONRPCMessage): boolean { + const w = this._standaloneWriters.get(sessionId); + if (!w) return false; + try { + w(msg); + return true; + } catch { + this._standaloneWriters.delete(sessionId); + return false; + } + } + + /** Rejects all pending requests for a session and forgets it. */ + closeSession(sessionId: string): void { + const sessionMap = this._pending.get(sessionId); + if (sessionMap) { + const err = new SdkError(SdkErrorCode.ConnectionClosed, 'Session closed'); + for (const s of sessionMap.values()) s.reject(err); + this._pending.delete(sessionId); + } + this._standaloneWriters.delete(sessionId); + } +} diff --git a/packages/server/src/server/sessionCompat.ts b/packages/server/src/server/sessionCompat.ts index 303a96fe2..7cbd6a057 100644 --- a/packages/server/src/server/sessionCompat.ts +++ b/packages/server/src/server/sessionCompat.ts @@ -47,6 +47,8 @@ interface SessionEntry { lastSeen: number; /** Standalone GET subscription stream controller, if one is open. */ sseController?: ReadableStreamDefaultController; + /** Protocol version requested by the client in `initialize.params.protocolVersion`. */ + protocolVersion?: string; } /** Result of {@linkcode SessionCompat.validate}. */ @@ -115,7 +117,10 @@ export class SessionCompat { } const id = this._generate(); const now = Date.now(); - this._sessions.set(id, { createdAt: now, lastSeen: now }); + const initMsg = messages.find(m => isInitializeRequest(m)); + const protocolVersion = + initMsg && isInitializeRequest(initMsg) ? initMsg.params.protocolVersion : undefined; + this._sessions.set(id, { createdAt: now, lastSeen: now, protocolVersion }); await Promise.resolve(this._onsessioninitialized?.(id)); return { ok: true, sessionId: id, isInitialize: true }; } @@ -158,6 +163,11 @@ export class SessionCompat { await Promise.resolve(this._onsessionclosed?.(sessionId)); } + /** Protocol version the client requested in `initialize` for this session, if known. */ + negotiatedVersion(sessionId: string): string | undefined { + return this._sessions.get(sessionId)?.protocolVersion; + } + /** Returns true if a standalone GET stream is already open for this session. */ hasStandaloneStream(sessionId: string): boolean { return this._sessions.get(sessionId)?.sseController !== undefined; diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index 149d541f3..d25eb0349 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -2,19 +2,24 @@ import type { AuthInfo, DispatchEnv, DispatchOutput, + JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, - JSONRPCRequest + JSONRPCRequest, + JSONRPCResultResponse } from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, + isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, + isJSONRPCResultResponse, JSONRPCMessageSchema, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import type { Backchannel2511 } from './backchannel2511.js'; import type { SessionCompat } from './sessionCompat.js'; export type StreamId = string; @@ -71,6 +76,15 @@ export interface ShttpHandlerOptions { */ session?: SessionCompat; + /** + * Pre-2026-06 server-to-client request backchannel. When provided alongside `session`, + * the handler supplies `env.send` to dispatched handlers (so `ctx.mcpReq.elicitInput()` etc. + * work over the open POST SSE stream) and routes incoming JSON-RPC responses to the + * waiting `env.send` promise. Version-gated: only active for sessions whose negotiated + * protocol version is below `2026-06-30`. + */ + backchannel?: Backchannel2511; + /** * Event store for SSE resumability via `Last-Event-ID`. When configured, every * outgoing SSE event is persisted and a priming event is sent at stream start. @@ -149,11 +163,18 @@ export function shttpHandler( ): (req: Request, extra?: ShttpRequestExtra) => Promise { const enableJsonResponse = options.enableJsonResponse ?? false; const session = options.session; + const backchannel = options.backchannel; const eventStore = options.eventStore; const retryInterval = options.retryInterval; const supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; const onerror = options.onerror; + function backchannelEnabled(sessionId: string | undefined, clientProtocolVersion: string): boolean { + if (!backchannel || sessionId === undefined) return false; + const negotiated = session?.negotiatedVersion(sessionId) ?? clientProtocolVersion; + return negotiated < '2026-06-30'; + } + function validateProtocolVersion(req: Request): Response | undefined { const v = req.headers.get('mcp-protocol-version'); if (v !== null && !supportedProtocolVersions.includes(v)) { @@ -237,11 +258,18 @@ export function shttpHandler( const requests = messages.filter(m => isJSONRPCRequest(m)); const notifications = messages.filter(m => isJSONRPCNotification(m)); + const responses = messages.filter( + (m): m is JSONRPCResultResponse | JSONRPCErrorResponse => isJSONRPCResultResponse(m) || isJSONRPCErrorResponse(m) + ); for (const n of notifications) { void server.dispatchNotification(n).catch(error => onerror?.(error as Error)); } + if (backchannel && sessionId !== undefined) { + for (const r of responses) backchannel.handleResponse(sessionId, r); + } + if (requests.length === 0) { return new Response(null, { status: 202 }); } @@ -252,18 +280,19 @@ export function shttpHandler( ? initReq.params.protocolVersion : (req.headers.get('mcp-protocol-version') ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - const env: DispatchEnv = { sessionId, authInfo: extra?.authInfo, httpReq: req }; + const baseEnv: DispatchEnv = { sessionId, authInfo: extra?.authInfo, httpReq: req }; + const useBackchannel = backchannelEnabled(sessionId, clientProtocolVersion); if (enableJsonResponse) { - const responses: JSONRPCMessage[] = []; + const out: JSONRPCMessage[] = []; for (const r of requests) { - for await (const out of server.dispatch(r, env)) { - if (out.kind === 'response') responses.push(out.message); + for await (const item of server.dispatch(r, baseEnv)) { + if (item.kind === 'response') out.push(item.message); } } const headers: Record = { 'Content-Type': 'application/json' }; if (sessionId !== undefined) headers['mcp-session-id'] = sessionId; - const body = responses.length === 1 ? responses[0] : responses; + const body = out.length === 1 ? out[0] : out; return Response.json(body, { status: 200, headers }); } @@ -274,6 +303,11 @@ export function shttpHandler( const readable = new ReadableStream({ start: controller => { + const writeSSE = (msg: JSONRPCMessage) => void emit(controller, encoder, streamId, msg); + const env: DispatchEnv = + useBackchannel && backchannel && sessionId !== undefined + ? { ...baseEnv, send: backchannel.makeEnvSend(sessionId, writeSSE) } + : baseEnv; void (async () => { try { await writePrimingEvent(controller, encoder, streamId, clientProtocolVersion); @@ -329,13 +363,20 @@ export function shttpHandler( return jsonError(409, -32_000, 'Conflict: Only one SSE stream is allowed per session'); } + const encoder = new TextEncoder(); + const standaloneStreamId = `_GET_${sessionId}`; const headers: Record = { ...SSE_HEADERS, 'mcp-session-id': sessionId }; const readable = new ReadableStream({ start: controller => { session.setStandaloneStream(sessionId, controller); + backchannel?.setStandaloneWriter(sessionId, msg => + void emit(controller, encoder, standaloneStreamId, msg) + ); + void writePrimingEvent(controller, encoder, standaloneStreamId, session.negotiatedVersion(sessionId) ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); }, cancel: () => { session.setStandaloneStream(sessionId, undefined); + backchannel?.setStandaloneWriter(sessionId, undefined); } }); return new Response(readable, { headers }); @@ -384,6 +425,7 @@ export function shttpHandler( if (!v.ok) return v.response; const protoErr = validateProtocolVersion(req); if (protoErr) return protoErr; + backchannel?.closeSession(v.sessionId!); await session.delete(v.sessionId!); return new Response(null, { status: 200 }); } From 0ad25215cd4095777a0a83628d670ff369ff02d1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 11:18:25 +0000 Subject: [PATCH 22/55] fix(compat): accept zod v3 schemas + handle cross-instance zod in JSON-Schema emission isZodTypeLike widened to detect _def (v3) as well as _zod (v4). coerceSchema: v4 raw shapes wrap with z.object; v3/mixed shapes get a StandardSchemaWithJSON adapter that synthesizes JSON Schema from _def.typeName. standardSchemaToJsonSchema falls back to z.toJSONSchema for cross-instance zod values. Client.request gains (req, ResultSchema, opts) overload. --- packages/client/src/client/client.ts | 22 +++- packages/core/src/util/standardSchema.ts | 14 ++- packages/server/src/server/serverLegacy.ts | 122 ++++++++++++++++++++- 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a702c91f5..5e9067d13 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -390,9 +390,25 @@ export class Client { } /** Low-level: send one typed request. Runs the MRTR loop. */ - async request(req: { method: M; params?: RequestTypeMap[M]['params'] }, options?: RequestOptions) { - const schema = getResultSchema(req.method); - return this._request({ method: req.method, params: req.params }, schema, options) as Promise; + request( + req: { method: M; params?: RequestTypeMap[M]['params'] }, + options?: RequestOptions + ): Promise; + /** @deprecated Pass options as the second argument; the result schema is inferred from `req.method`. */ + request unknown }>( + req: { method: string; params?: Record }, + resultSchema: S, + options?: RequestOptions + ): Promise>; + async request( + req: { method: string; params?: Record }, + schemaOrOptions?: RequestOptions | { parse: (v: unknown) => unknown }, + maybeOptions?: RequestOptions + ) { + const isSchema = schemaOrOptions != null && typeof (schemaOrOptions as { parse?: unknown }).parse === 'function'; + const options = isSchema ? maybeOptions : (schemaOrOptions as RequestOptions | undefined); + const schema = isSchema ? (schemaOrOptions as AnySchema) : getResultSchema(req.method as RequestMethod); + return this._request({ method: req.method, params: req.params }, schema, options); } /** Low-level: send a notification to the server. */ diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 9817dc39a..7f0d17276 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -6,6 +6,9 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import type { ZodType as zType } from 'zod/v4'; +import { toJSONSchema as zToJSONSchema } from 'zod/v4'; + // Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025) export interface StandardTypedV1 { @@ -149,7 +152,16 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch * since that cannot satisfy the MCP spec. */ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { - const result = schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); + // For zod schemas, use the package-level converter which handles cross-instance + // children (consumer's zod ≠ SDK's zod). The schema-local `~standard.jsonSchema` + // is constructed with empty processors and throws on cross-instance children. + let result: Record; + if ('_zod' in schema) { + result = zToJSONSchema(schema as unknown as zType, { io }) as Record; + delete result.$schema; + } else { + result = schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); + } if (result.type !== undefined && result.type !== 'object') { throw new Error( `MCP tool and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` + diff --git a/packages/server/src/server/serverLegacy.ts b/packages/server/src/server/serverLegacy.ts index 4829f99c3..9fc61c541 100644 --- a/packages/server/src/server/serverLegacy.ts +++ b/packages/server/src/server/serverLegacy.ts @@ -36,6 +36,11 @@ export function extractMethodFromSchema(schema: { shape: { method: unknown } }): } function isZodTypeLike(v: unknown): boolean { + if (v == null || typeof v !== 'object') return false; + return '_zod' in (v as object) || '_def' in (v as object); +} + +function isZodV4Type(v: unknown): boolean { return v != null && typeof v === 'object' && '_zod' in (v as object); } @@ -47,6 +52,112 @@ export function isZodRawShapeCompat(v: unknown): v is ZodRawShapeCompat { return values.some(v => isZodTypeLike(v)); } +type ZodV3Like = { + _def: { typeName?: string; innerType?: ZodV3Like; type?: ZodV3Like; shape?: () => Record; values?: unknown[] }; + description?: string; + isOptional?: () => boolean; + '~standard'?: { validate: (v: unknown) => unknown }; +}; + +/** Best-effort JSON Schema synthesis for a single zod v3 schema (covers common primitives). */ +function v3ToJsonSchema(s: ZodV3Like): Record { + const out: Record = {}; + if (s.description) out.description = s.description; + const tn = s._def?.typeName; + switch (tn) { + case 'ZodString': { + out.type = 'string'; + break; + } + case 'ZodNumber': { + out.type = 'number'; + break; + } + case 'ZodBoolean': { + out.type = 'boolean'; + break; + } + case 'ZodArray': { + out.type = 'array'; + if (s._def.type) out.items = v3ToJsonSchema(s._def.type); + break; + } + case 'ZodEnum': + case 'ZodNativeEnum': { + if (Array.isArray(s._def.values)) out.enum = s._def.values; + break; + } + case 'ZodObject': { + const shape = s._def.shape?.(); + out.type = 'object'; + if (shape) { + const entries = Object.entries(shape); + out.properties = Object.fromEntries(entries.map(([k, v]) => [k, v3ToJsonSchema(v)])); + out.required = entries.filter(([, v]) => !v.isOptional?.()).map(([k]) => k); + } + break; + } + case 'ZodOptional': + case 'ZodNullable': + case 'ZodDefault': { + return s._def.innerType ? { ...v3ToJsonSchema(s._def.innerType), ...out } : out; + } + default: { + break; + } + } + return out; +} + +/** Wrap a raw shape whose values are zod v3 (or any Standard Schema lacking jsonSchema) into a {@linkcode StandardSchemaWithJSON}. */ +function adaptRawShapeToStandard(shape: Record): StandardSchemaWithJSON { + const entries = Object.entries(shape); + const required = entries.filter(([, v]) => !v.isOptional?.()).map(([k]) => k); + const jsonSchema = { + type: 'object', + properties: Object.fromEntries(entries.map(([k, v]) => [k, v3ToJsonSchema(v)])), + required, + additionalProperties: false + }; + const emit = () => jsonSchema; + return { + '~standard': { + version: 1, + vendor: 'mcp-zod-v3-compat', + validate: input => { + if (typeof input !== 'object' || input === null) { + return { issues: [{ message: 'Expected object' }] }; + } + const value: Record = {}; + const issues: { message: string; path: PropertyKey[] }[] = []; + for (const [k, field] of entries) { + const std = field['~standard']; + const raw = (input as Record)[k]; + if (std) { + const r = std.validate(raw) as { value?: unknown; issues?: { message: string }[] }; + if (r.issues) for (const i of r.issues) issues.push({ message: i.message, path: [k] }); + else value[k] = r.value; + } else { + value[k] = raw; + } + } + return issues.length > 0 ? { issues } : { value }; + }, + jsonSchema: { input: emit, output: emit } + } + } as StandardSchemaWithJSON; +} + +/** Wrap a Standard Schema that lacks `jsonSchema` (e.g. zod v3's `z.object({...})`) by synthesizing one from `_def`. */ +function adaptStandardSchemaWithoutJson(schema: ZodV3Like): StandardSchemaWithJSON { + const json = v3ToJsonSchema(schema); + const emit = () => json; + const std = schema['~standard'] as { version: 1; vendor: string; validate: (v: unknown) => unknown }; + return { + '~standard': { ...std, jsonSchema: { input: emit, output: emit } } + } as unknown as StandardSchemaWithJSON; +} + /** * Coerce a v1-style raw Zod shape (or empty object) to a {@linkcode StandardSchemaWithJSON}. * Standard Schemas pass through unchanged. @@ -54,8 +165,17 @@ export function isZodRawShapeCompat(v: unknown): v is ZodRawShapeCompat { export function coerceSchema(schema: unknown): StandardSchemaWithJSON | undefined { if (schema == null) return undefined; if (isStandardSchemaWithJSON(schema)) return schema; - if (isZodRawShapeCompat(schema)) return z.object(schema) as unknown as StandardSchemaWithJSON; + if (isZodRawShapeCompat(schema)) { + const values = Object.values(schema as object); + if (values.every(v => isZodV4Type(v))) { + return z.object(schema as ZodRawShapeCompat) as unknown as StandardSchemaWithJSON; + } + return adaptRawShapeToStandard(schema as unknown as Record); + } if (isStandardSchema(schema)) { + if ('_def' in (schema as object)) { + return adaptStandardSchemaWithoutJson(schema as unknown as ZodV3Like); + } throw new Error('Schema lacks JSON-Schema emission (zod >=4.2 or equivalent required).'); } throw new Error('inputSchema/argsSchema must be a Standard Schema or a Zod raw shape (e.g. {name: z.string()})'); From 008a74503b016f0041406f98db33c343efb2673f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 11:18:38 +0000 Subject: [PATCH 23/55] fix(server): expose v1-internal lazy-installer + helper hooks on McpServer for subclassers ServerRegistries' setToolRequestHandlers/setResourceRequestHandlers/etc were private and called directly from registerTool/etc. Consumers like shortcut's CustomMcpServer override (this as any).setToolRequestHandlers on the instance to install a custom tools/call handler that re-throws BearerAuthError as McpError. With registries calling its own private method, the override never fired and the default {isError:true} wrapping swallowed the error. Fix: route the lazy-install calls through the host (McpServer) via new methods on RegistriesHost. McpServer's default implementations delegate back to ServerRegistries. validateToolInput/validateToolOutput/handleAutomaticTaskPolling/ createToolError/executeToolHandler are also exposed as protected delegates so the override's body can call them via (this as any).X. Consumer test results after fix: - shortcut CustomMcpServer.test 9/9 (was 8/1) --- packages/server/src/server/mcpServer.ts | 55 +++++++++++++++++++ .../server/src/server/serverRegistries.ts | 46 +++++++++++----- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 4ee7d28c3..21c532815 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -1,12 +1,15 @@ import type { AuthInfo, BaseContext, + CallToolRequest, + CallToolResult, ClientCapabilities, CreateMessageRequest, CreateMessageRequestParamsBase, CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + CreateTaskResult, DispatchEnv, ElicitRequestFormParams, ElicitRequestURLParams, @@ -242,8 +245,17 @@ export class McpServer extends Dispatcher implements RegistriesHo /** * Attaches to the given transport, starts it, and starts listening for messages. * Builds a {@linkcode StreamDriver} internally. + * + * Transports that expose a `bind(server)` method (request-shaped transports like + * {@linkcode WebStandardStreamableHTTPServerTransport}) are bound to this server + * first so their `handleRequest` can dispatch directly via {@linkcode shttpHandler}; + * the {@linkcode StreamDriver} is still built so outbound `notification()`/`request()` + * route through `transport.send()`. */ async connect(transport: Transport): Promise { + if ('bind' in transport && typeof (transport as { bind: unknown }).bind === 'function') { + (transport as { bind: (server: McpServer) => void }).bind(this); + } const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, @@ -751,6 +763,49 @@ export class McpServer extends Dispatcher implements RegistriesHo return this._registries.registerResource(name, uriOrTemplate as never, config, readCallback as never); } + // ─────────────────────────────────────────────────────────────────────── + // v1-internal compat surface — for code that monkey-patches McpServer + // private methods (e.g., shortcut's CustomMcpServer overrides + // setToolRequestHandlers). Routed through here so instance overrides fire. + // ─────────────────────────────────────────────────────────────────────── + + /** @hidden v1 compat: lazy installer hook, override on instance to customize tools/* handlers. */ + setToolRequestHandlers(): void { + this._registries.setToolRequestHandlers(); + } + /** @hidden v1 compat */ + setResourceRequestHandlers(): void { + this._registries.setResourceRequestHandlers(); + } + /** @hidden v1 compat */ + setPromptRequestHandlers(): void { + this._registries.setPromptRequestHandlers(); + } + /** @hidden v1 compat */ + setCompletionRequestHandler(): void { + this._registries.setCompletionRequestHandler(); + } + /** @hidden v1 compat */ + protected validateToolInput(tool: RegisteredTool, args: unknown, toolName: string) { + return this._registries.validateToolInput(tool, args as never, toolName); + } + /** @hidden v1 compat */ + protected validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string) { + return this._registries.validateToolOutput(tool, result, toolName); + } + /** @hidden v1 compat */ + protected handleAutomaticTaskPolling(tool: RegisteredTool, request: CallToolRequest, ctx: ServerContext) { + return this._registries.handleAutomaticTaskPolling(tool, request, ctx); + } + /** @hidden v1 compat: was a private instance method in v1 mcp.ts. */ + protected createToolError(errorMessage: string): CallToolResult { + return { content: [{ type: 'text', text: errorMessage }], isError: true }; + } + /** @hidden v1 compat: removed in v2 (replaced by `tool.executor`); shim calls executor. */ + protected executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext) { + return tool.executor(args as never, ctx); + } + /** @hidden v1 compat for `(mcpServer as any)._registeredTools` and `experimental.tasks`. */ get _registeredTools(): { [name: string]: RegisteredTool } { return this._registries.registeredTools; diff --git a/packages/server/src/server/serverRegistries.ts b/packages/server/src/server/serverRegistries.ts index e5c65e008..67bd358a8 100644 --- a/packages/server/src/server/serverRegistries.ts +++ b/packages/server/src/server/serverRegistries.ts @@ -59,6 +59,15 @@ export interface RegistriesHost { sendToolListChanged(): Promise; sendResourceListChanged(): Promise; sendPromptListChanged(): Promise; + /** + * Lazy installers, called on first registerTool/Resource/Prompt. Defined on the host so + * subclasses can override the install (v1 compat for code that monkey-patches `setToolRequestHandlers`). + * Default impl on McpServer delegates back to {@link ServerRegistries}. + */ + setToolRequestHandlers(): void; + setResourceRequestHandlers(): void; + setPromptRequestHandlers(): void; + setCompletionRequestHandler(): void; } /** @@ -84,7 +93,8 @@ export class ServerRegistries { // Tools // ─────────────────────────────────────────────────────────────────────── - private setToolRequestHandlers(): void { + /** @internal v1-compat: kept callable so subclassers can invoke the default after overriding the host hook. */ + setToolRequestHandlers(): void { if (this._toolHandlersInitialized) return; const h = this.host; h.assertCanSetRequestHandler('tools/list'); @@ -159,7 +169,8 @@ export class ServerRegistries { this._toolHandlersInitialized = true; } - private async validateToolInput< + /** @internal v1-compat */ + async validateToolInput< ToolType extends RegisteredTool, Args extends ToolType['inputSchema'] extends infer InputSchema ? InputSchema extends StandardSchemaWithJSON @@ -178,7 +189,8 @@ export class ServerRegistries { return parsed.data as unknown as Args; } - private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { + /** @internal v1-compat */ + async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { if (!tool.outputSchema) return; if (!('content' in result)) return; if (result.isError) return; @@ -197,7 +209,8 @@ export class ServerRegistries { } } - private async handleAutomaticTaskPolling( + /** @internal v1-compat */ + async handleAutomaticTaskPolling( tool: RegisteredTool, request: RequestT, ctx: ServerContext @@ -225,7 +238,8 @@ export class ServerRegistries { // Completion // ─────────────────────────────────────────────────────────────────────── - private setCompletionRequestHandler(): void { + /** @internal v1-compat */ + setCompletionRequestHandler(): void { if (this._completionHandlerInitialized) return; const h = this.host; h.assertCanSetRequestHandler('completion/complete'); @@ -281,7 +295,8 @@ export class ServerRegistries { // Resources // ─────────────────────────────────────────────────────────────────────── - private setResourceRequestHandlers(): void { + /** @internal v1-compat */ + setResourceRequestHandlers(): void { if (this._resourceHandlersInitialized) return; const h = this.host; h.assertCanSetRequestHandler('resources/list'); @@ -336,7 +351,8 @@ export class ServerRegistries { // Prompts // ─────────────────────────────────────────────────────────────────────── - private setPromptRequestHandlers(): void { + /** @internal v1-compat */ + setPromptRequestHandlers(): void { if (this._promptHandlersInitialized) return; const h = this.host; h.assertCanSetRequestHandler('prompts/list'); @@ -399,7 +415,7 @@ export class ServerRegistries { config, readCallback as ReadResourceCallback ); - this.setResourceRequestHandlers(); + this.host.setResourceRequestHandlers(); this.host.sendResourceListChanged(); return r; } else { @@ -411,7 +427,7 @@ export class ServerRegistries { config, readCallback as ReadResourceTemplateCallback ); - this.setResourceRequestHandlers(); + this.host.setResourceRequestHandlers(); this.host.sendResourceListChanged(); return r; } @@ -465,7 +481,7 @@ export class ServerRegistries { cb as PromptCallback, _meta ); - this.setPromptRequestHandlers(); + this.host.setPromptRequestHandlers(); this.host.sendPromptListChanged(); return r; } @@ -540,7 +556,7 @@ export class ServerRegistries { this.registeredResourceTemplates[name] = r; const variableNames = template.uriTemplate.variableNames; const hasCompleter = Array.isArray(variableNames) && variableNames.some(v => !!template.completeCallback(v)); - if (hasCompleter) this.setCompletionRequestHandler(); + if (hasCompleter) this.host.setCompletionRequestHandler(); return r; } @@ -592,7 +608,7 @@ export class ServerRegistries { const shape = getSchemaShape(argsSchema); if (shape) { const hasCompletable = Object.values(shape).some(f => isCompletable(unwrapOptionalSchema(f))); - if (hasCompletable) this.setCompletionRequestHandler(); + if (hasCompletable) this.host.setCompletionRequestHandler(); } } return r; @@ -652,17 +668,17 @@ export class ServerRegistries { } }; this.registeredTools[name] = r; - this.setToolRequestHandlers(); + this.host.setToolRequestHandlers(); this.host.sendToolListChanged(); return r; } /** Expose lazy installers for callers (legacy `.prompt()/.resource()`) that build entries via `create*` directly. */ installResourceHandlers(): void { - this.setResourceRequestHandlers(); + this.host.setResourceRequestHandlers(); } installPromptHandlers(): void { - this.setPromptRequestHandlers(); + this.host.setPromptRequestHandlers(); } } From 66ecb7ad4d3e7eee55b34b381b3485785668ca93 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 11:41:59 +0000 Subject: [PATCH 24/55] refactor(server): gut WebStandardStreamableHTTPServerTransport to route through shttpHandler - streamableHttp.ts 1038 -> ~290: bind(server) builds shttpHandler with SessionCompat + Backchannel2511 - McpServer.connect detects request-shaped transports (has handleRequest), calls bind() instead of StreamDriver - shttpHandler: TaskManager wiring (processInboundRequest), DNS-rebinding option, EventStore replay, async session callbacks - Node wrapper updated for new shape - All 1685 SDK + 422 integration + conformance 40/40 + 289/289 green ON NEW PATH --- .../middleware/node/src/streamableHttp.ts | 7 + packages/server/src/index.ts | 1 + packages/server/src/server/backchannel2511.ts | 9 +- packages/server/src/server/mcpServer.ts | 159 +++ packages/server/src/server/sessionCompat.ts | 44 +- packages/server/src/server/shttpHandler.ts | 92 +- packages/server/src/server/streamableHttp.ts | 982 +++--------------- .../server/test/server/streamableHttp.test.ts | 17 +- 8 files changed, 421 insertions(+), 890 deletions(-) diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f..e698037e2 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -130,6 +130,13 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.onmessage; } + /** + * Binds the underlying web-standard transport to a server. Called by `McpServer.connect()`. + */ + bind(server: Parameters[0]): void { + this._webStandardTransport.bind(server); + } + /** * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op * for the Streamable HTTP transport as connections are managed per-request. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f4c811c76..ef64e3ac6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,6 +6,7 @@ // // Any new export added here becomes public API. Use named exports, not wildcards. +export { Backchannel2511 } from './server/backchannel2511.js'; export { Server } from './server/compat.js'; export type { CompletableSchema, CompleteCallback } from './server/completable.js'; export { completable, isCompletable } from './server/completable.js'; diff --git a/packages/server/src/server/backchannel2511.ts b/packages/server/src/server/backchannel2511.ts index f327e8cbe..946f0ec7c 100644 --- a/packages/server/src/server/backchannel2511.ts +++ b/packages/server/src/server/backchannel2511.ts @@ -67,9 +67,7 @@ export class Backchannel2511 { opts?.signal?.addEventListener( 'abort', () => { - settle.reject( - opts.signal!.reason instanceof Error ? opts.signal!.reason : new Error(String(opts.signal!.reason)) - ); + settle.reject(opts.signal!.reason instanceof Error ? opts.signal!.reason : new Error(String(opts.signal!.reason))); }, { once: true } ); @@ -110,6 +108,11 @@ export class Backchannel2511 { else this._standaloneWriters.delete(sessionId); } + /** True if a standalone writer is registered for the session. */ + hasStandaloneWriter(sessionId: string): boolean { + return this._standaloneWriters.has(sessionId); + } + /** Writes a message on the session's standalone GET stream, if one is open. */ writeStandalone(sessionId: string, msg: JSONRPCMessage): boolean { const w = this._standaloneWriters.get(sessionId); diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 21c532815..2bae0ad2b 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -11,14 +11,19 @@ import type { CreateMessageResultWithTools, CreateTaskResult, DispatchEnv, + DispatchOutput, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, Implementation, + InboundContext, InitializeRequest, InitializeResult, + JSONRPCErrorResponse, JSONRPCMessage, + JSONRPCNotification, JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, @@ -30,6 +35,7 @@ import type { NotificationOptions, ProtocolOptions, Request, + RequestId, RequestMethod, RequestOptions, RequestTypeMap, @@ -72,6 +78,7 @@ import { parseSchema, ProtocolError, ProtocolErrorCode, + RELATED_TASK_META_KEY, SdkError, SdkErrorCode, StreamDriver, @@ -137,6 +144,8 @@ export type ServerOptions = Omit & { export class McpServer extends Dispatcher implements RegistriesHost { private _driver?: StreamDriver; private readonly _registries = new ServerRegistries(this); + private readonly _dispatchYielders = new Map void>(); + private _dispatchOutboundId = 0; private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; @@ -199,6 +208,151 @@ export class McpServer extends Dispatcher implements RegistriesHo // Direct dispatch // ─────────────────────────────────────────────────────────────────────── + /** + * Task-aware dispatch. Threads {@linkcode TaskManager.processInboundRequest} so + * `tasks/*` methods, task-augmented `tools/call`, and `routeResponse` queueing all + * work for callers that bypass {@linkcode StreamDriver} (e.g. {@linkcode shttpHandler}). + */ + override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { + const sendOnStream = env.send; + const inboundCtx: InboundContext = { + sessionId: env.sessionId, + sendNotification: async () => {}, + sendRequest: (r, schema, opts) => + new Promise((resolve, reject) => { + const messageId = this._dispatchOutboundId++; + const wire: JSONRPCRequest = { jsonrpc: '2.0', id: messageId, method: r.method, params: r.params }; + const settle = (resp: { result: Result } | Error) => { + if (resp instanceof Error) return reject(resp); + const parsed = parseSchema(schema, resp.result); + if (parsed.success) { + resolve(parsed.data); + } else { + reject(parsed.error); + } + }; + const { queued } = this._taskManager.processOutboundRequest(wire, opts, messageId, settle, reject); + if (queued) return; + if (!sendOnStream) { + reject( + new SdkError( + SdkErrorCode.NotConnected, + 'ctx.mcpReq.send is unavailable: no peer channel. Use the MRTR-native return form for elicitation/sampling, or run via connect()/StreamDriver.' + ) + ); + return; + } + sendOnStream({ method: wire.method, params: wire.params }, opts).then(result => settle({ result }), reject); + }) + }; + const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); + + if (taskResult.validateInbound) { + try { + taskResult.validateInbound(); + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + yield { + kind: 'response', + message: { + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + message: e?.message ?? 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + } + }; + return; + } + } + + const relatedTaskId = taskResult.taskContext?.id; + const taskEnv: DispatchEnv = { + ...env, + task: taskResult.taskContext ?? env.task, + send: (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise + }; + + // Queued task messages delivered via host.sendOnResponseStream are routed to this + // generator (instead of `_driver.pipe.send`) so they yield on the same stream. + const sideQueue: JSONRPCMessage[] = []; + let wake: (() => void) | undefined; + this._dispatchYielders.set(request.id, msg => { + sideQueue.push(msg); + wake?.(); + }); + + const drain = function* (): Generator { + while (sideQueue.length > 0) { + const msg = sideQueue.shift()!; + yield 'method' in msg + ? { kind: 'notification', message: msg as JSONRPCNotification } + : { kind: 'response', message: msg as JSONRPCResponse | JSONRPCErrorResponse }; + } + }; + + try { + const inner = super.dispatch(request, taskEnv); + let pending: Promise> | undefined; + + while (true) { + yield* drain(); + pending ??= inner.next(); + const wakeP = new Promise<'side'>(resolve => { + wake = () => resolve('side'); + }); + if (sideQueue.length > 0) { + wake = undefined; + continue; + } + const r = await Promise.race([pending, wakeP]); + wake = undefined; + if (r === 'side') continue; + pending = undefined; + if (r.done) break; + const out = r.value; + if (out.kind === 'response') { + const routed = await taskResult.routeResponse(out.message); + if (!routed) { + yield* drain(); + yield out; + } + } else if (relatedTaskId === undefined) { + yield out; + } else { + const params = (out.message.params ?? {}) as Record; + yield { + kind: 'notification', + message: { + ...out.message, + params: { + ...params, + _meta: { ...(params._meta as object), [RELATED_TASK_META_KEY]: { taskId: relatedTaskId } } + } + } + }; + } + } + yield* drain(); + } finally { + this._dispatchYielders.delete(request.id); + } + } + + /** + * Routes an incoming JSON-RPC response (e.g. a client's reply to an `elicitation/create` + * request the server issued) to the {@linkcode TaskManager} resolver chain. + * Called by {@linkcode shttpHandler} for response-typed POST bodies. + * + * @returns true if the response was consumed. + */ + dispatchInboundResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean { + const id = typeof response.id === 'number' ? response.id : Number(response.id); + return this._taskManager.processInboundResponse(response, id).consumed; + } + /** * Handle one inbound request without a transport. Yields any notifications the handler * emits via `ctx.mcpReq.notify()`, then yields exactly one terminal response. @@ -323,6 +477,11 @@ export class McpServer extends Dispatcher implements RegistriesHo removeProgressHandler: t => this._driver?.removeProgressHandler(t), registerHandler: (m, h) => this.setRawRequestHandler(m, h as never), sendOnResponseStream: async (msg, relatedRequestId) => { + const yielder = relatedRequestId === undefined ? undefined : this._dispatchYielders.get(relatedRequestId); + if (yielder) { + yielder(msg); + return; + } await this._driver?.pipe.send(msg, { relatedRequestId }); }, enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, diff --git a/packages/server/src/server/sessionCompat.ts b/packages/server/src/server/sessionCompat.ts index 7cbd6a057..5aee5b8e2 100644 --- a/packages/server/src/server/sessionCompat.ts +++ b/packages/server/src/server/sessionCompat.ts @@ -40,6 +40,18 @@ export interface SessionCompatOptions { /** Called when a session is deleted (via DELETE) or evicted. */ onsessionclosed?: (sessionId: string) => void | Promise; + + /** + * When `true`, this instance allows at most one session: a second `initialize` + * is rejected with "Server already initialized". Matches the per-transport-instance + * v1 behaviour where each `WebStandardStreamableHTTPServerTransport` holds one session. + * + * @default false + */ + singleSession?: boolean; + + /** Called for validation failures (re-init, missing/unknown session header). */ + onerror?: (error: Error) => void; } interface SessionEntry { @@ -77,6 +89,8 @@ export class SessionCompat { private readonly _retryAfterSeconds: number; private readonly _onsessioninitialized?: (sessionId: string) => void | Promise; private readonly _onsessionclosed?: (sessionId: string) => void | Promise; + private readonly _singleSession: boolean; + private readonly _onerror?: (error: Error) => void; constructor(options: SessionCompatOptions = {}) { this._generate = options.sessionIdGenerator ?? (() => crypto.randomUUID()); @@ -85,6 +99,8 @@ export class SessionCompat { this._retryAfterSeconds = options.retryAfterSeconds ?? 30; this._onsessioninitialized = options.onsessioninitialized; this._onsessionclosed = options.onsessionclosed; + this._singleSession = options.singleSession ?? false; + this._onerror = options.onerror; } /** @@ -98,11 +114,19 @@ export class SessionCompat { if (isInit) { if (messages.length > 1) { + this._onerror?.(new Error('Invalid Request: Only one initialization request is allowed')); return { ok: false, response: jsonError(400, -32_600, 'Invalid Request: Only one initialization request is allowed') }; } + if (this._singleSession && this._sessions.size > 0) { + this._onerror?.(new Error('Invalid Request: Server already initialized')); + return { + ok: false, + response: jsonError(400, -32_600, 'Invalid Request: Server already initialized') + }; + } this._evictIdle(); if (this._sessions.size >= this._maxSessions) { this._evictOldest(); @@ -118,8 +142,7 @@ export class SessionCompat { const id = this._generate(); const now = Date.now(); const initMsg = messages.find(m => isInitializeRequest(m)); - const protocolVersion = - initMsg && isInitializeRequest(initMsg) ? initMsg.params.protocolVersion : undefined; + const protocolVersion = initMsg && isInitializeRequest(initMsg) ? initMsg.params.protocolVersion : undefined; this._sessions.set(id, { createdAt: now, lastSeen: now, protocolVersion }); await Promise.resolve(this._onsessioninitialized?.(id)); return { ok: true, sessionId: id, isInitialize: true }; @@ -132,8 +155,13 @@ export class SessionCompat { * Validates the `mcp-session-id` header without inspecting a body (for GET/DELETE). */ validateHeader(req: Request): SessionValidation { + if (this._singleSession && this._sessions.size === 0) { + this._onerror?.(new Error('Bad Request: Server not initialized')); + return { ok: false, response: jsonError(400, -32_000, 'Bad Request: Server not initialized') }; + } const headerId = req.headers.get('mcp-session-id'); if (!headerId) { + this._onerror?.(new Error('Bad Request: Mcp-Session-Id header is required')); return { ok: false, response: jsonError(400, -32_000, 'Bad Request: Mcp-Session-Id header is required') @@ -141,6 +169,7 @@ export class SessionCompat { } const entry = this._sessions.get(headerId); if (!entry) { + this._onerror?.(new Error('Session not found')); return { ok: false, response: jsonError(404, -32_001, 'Session not found') }; } entry.lastSeen = Date.now(); @@ -179,6 +208,17 @@ export class SessionCompat { if (entry) entry.sseController = controller; } + /** Closes the standalone GET stream for this session if one is open. */ + closeStandaloneStream(sessionId: string): void { + const entry = this._sessions.get(sessionId); + try { + entry?.sseController?.close(); + } catch { + // Already closed. + } + if (entry) entry.sseController = undefined; + } + /** Number of live sessions. */ get size(): number { return this._sessions.size; diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index d25eb0349..ab26dcedd 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -6,7 +6,8 @@ import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, - JSONRPCResultResponse + JSONRPCResultResponse, + MessageExtraInfo } from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, @@ -45,6 +46,12 @@ export interface EventStore { lastEventId: EventId, opts: { send: (eventId: EventId, message: JSONRPCMessage) => Promise } ): Promise; + + /** + * Get the stream ID associated with a given event ID. + * @deprecated No longer used; the handler does not maintain a stream-mapping table. + */ + getStreamIdForEventId?(eventId: EventId): Promise; } /** @@ -54,6 +61,8 @@ export interface EventStore { export interface McpServerLike { dispatch(request: JSONRPCRequest, env?: DispatchEnv): AsyncIterable; dispatchNotification(notification: JSONRPCNotification): Promise; + /** Optional: route incoming JSON-RPC responses to a task-aware resolver. Returns true if handled. */ + dispatchInboundResponse?(response: JSONRPCResultResponse | JSONRPCErrorResponse): boolean; } /** @@ -143,6 +152,12 @@ function writeSSEEvent( } } +/** Sentinel session key for the standalone GET stream when no {@linkcode SessionCompat} is configured. */ +export const STATELESS_GET_KEY = '_stateless'; + +/** EventStore stream-ID prefix for the standalone GET stream (matches v1 `_standaloneSseStreamId`). */ +const STANDALONE_STREAM_ID = '_GET_stream'; + const SSE_HEADERS: Record = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', @@ -266,8 +281,9 @@ export function shttpHandler( void server.dispatchNotification(n).catch(error => onerror?.(error as Error)); } - if (backchannel && sessionId !== undefined) { - for (const r of responses) backchannel.handleResponse(sessionId, r); + for (const r of responses) { + if (server.dispatchInboundResponse?.(r)) continue; + if (backchannel && sessionId !== undefined) backchannel.handleResponse(sessionId, r); } if (requests.length === 0) { @@ -304,10 +320,33 @@ export function shttpHandler( const readable = new ReadableStream({ start: controller => { const writeSSE = (msg: JSONRPCMessage) => void emit(controller, encoder, streamId, msg); - const env: DispatchEnv = - useBackchannel && backchannel && sessionId !== undefined - ? { ...baseEnv, send: backchannel.makeEnvSend(sessionId, writeSSE) } - : baseEnv; + const closeStream = () => { + try { + controller.close(); + } catch { + // Already closed. + } + }; + const supportsPolling = eventStore !== undefined && clientProtocolVersion >= '2025-11-25'; + const transportExtra: MessageExtraInfo = { + request: req, + authInfo: extra?.authInfo, + closeSSEStream: supportsPolling ? closeStream : undefined, + closeStandaloneSSEStream: + supportsPolling && sessionId !== undefined + ? () => { + session?.closeStandaloneStream(sessionId); + backchannel?.setStandaloneWriter(sessionId, undefined); + } + : undefined + }; + const env: DispatchEnv & { _transportExtra?: MessageExtraInfo } = { + ...baseEnv, + _transportExtra: transportExtra, + ...(useBackchannel && backchannel && sessionId !== undefined + ? { send: backchannel.makeEnvSend(sessionId, writeSSE) } + : {}) + }; void (async () => { try { await writePrimingEvent(controller, encoder, streamId, clientProtocolVersion); @@ -333,7 +372,7 @@ export function shttpHandler( } async function handleGet(req: Request): Promise { - if (!session) { + if (!session && !backchannel) { return jsonError(405, -32_000, 'Method Not Allowed: stateless handler does not support GET stream', { headers: { Allow: 'POST' } }); @@ -345,11 +384,16 @@ export function shttpHandler( return jsonError(406, -32_000, 'Not Acceptable: Client must accept text/event-stream'); } - const v = session.validateHeader(req); - if (!v.ok) return v.response; + let sessionId: string; + if (session) { + const v = session.validateHeader(req); + if (!v.ok) return v.response; + sessionId = v.sessionId!; + } else { + sessionId = STATELESS_GET_KEY; + } const protoErr = validateProtocolVersion(req); if (protoErr) return protoErr; - const sessionId = v.sessionId!; if (eventStore) { const lastEventId = req.headers.get('last-event-id'); @@ -358,24 +402,21 @@ export function shttpHandler( } } - if (session.hasStandaloneStream(sessionId)) { + if (session?.hasStandaloneStream(sessionId) || (!session && backchannel?.hasStandaloneWriter(sessionId))) { onerror?.(new Error('Conflict: Only one SSE stream is allowed per session')); return jsonError(409, -32_000, 'Conflict: Only one SSE stream is allowed per session'); } const encoder = new TextEncoder(); - const standaloneStreamId = `_GET_${sessionId}`; - const headers: Record = { ...SSE_HEADERS, 'mcp-session-id': sessionId }; + const headers: Record = { ...SSE_HEADERS }; + if (session) headers['mcp-session-id'] = sessionId; const readable = new ReadableStream({ start: controller => { - session.setStandaloneStream(sessionId, controller); - backchannel?.setStandaloneWriter(sessionId, msg => - void emit(controller, encoder, standaloneStreamId, msg) - ); - void writePrimingEvent(controller, encoder, standaloneStreamId, session.negotiatedVersion(sessionId) ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + session?.setStandaloneStream(sessionId, controller); + backchannel?.setStandaloneWriter(sessionId, msg => void emit(controller, encoder, STANDALONE_STREAM_ID, msg)); }, cancel: () => { - session.setStandaloneStream(sessionId, undefined); + session?.setStandaloneStream(sessionId, undefined); backchannel?.setStandaloneWriter(sessionId, undefined); } }); @@ -398,6 +439,7 @@ export function shttpHandler( } }); if (session) session.setStandaloneStream(sessionId, controller); + backchannel?.setStandaloneWriter(sessionId, msg => void emit(controller, encoder, STANDALONE_STREAM_ID, msg)); } catch (error) { onerror?.(error as Error); try { @@ -410,6 +452,7 @@ export function shttpHandler( }, cancel: () => { session?.setStandaloneStream(sessionId, undefined); + backchannel?.setStandaloneWriter(sessionId, undefined); } }); return new Response(readable, { headers }); @@ -426,7 +469,14 @@ export function shttpHandler( const protoErr = validateProtocolVersion(req); if (protoErr) return protoErr; backchannel?.closeSession(v.sessionId!); - await session.delete(v.sessionId!); + try { + await session.delete(v.sessionId!); + } catch (error) { + onerror?.(error as Error); + return jsonError(500, -32_603, 'Internal server error: onsessionclosed callback failed', { + data: String(error) + }); + } return new Response(null, { status: 200 }); } diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 6284189dd..a9f31a3c0 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -1,71 +1,26 @@ /** * Web Standards Streamable HTTP Server Transport * - * This is the core transport implementation using Web Standard APIs (`Request`, `Response`, `ReadableStream`). - * It can run on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc. + * Thin compat wrapper over {@linkcode shttpHandler} + {@linkcode SessionCompat} + + * {@linkcode Backchannel2511}. The class name, constructor options, and + * {@linkcode Transport} interface are kept for back-compat so existing + * `server.connect(new WebStandardStreamableHTTPServerTransport({...}))` code + * works unchanged. Request handling delegates to {@linkcode shttpHandler}. * - * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. + * For Node.js Express/HTTP compatibility, use + * {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} + * which wraps this transport. */ -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; -import { - DEFAULT_NEGOTIATED_PROTOCOL_VERSION, - isInitializeRequest, - isJSONRPCErrorResponse, - isJSONRPCRequest, - isJSONRPCResultResponse, - JSONRPCMessageSchema, - SUPPORTED_PROTOCOL_VERSIONS -} from '@modelcontextprotocol/core'; +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, Transport, TransportSendOptions } from '@modelcontextprotocol/core'; +import { isJSONRPCErrorResponse, isJSONRPCResultResponse, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; -export type StreamId = string; -export type EventId = string; +import { Backchannel2511 } from './backchannel2511.js'; +import { SessionCompat } from './sessionCompat.js'; +import type { McpServerLike, ShttpRequestExtra } from './shttpHandler.js'; +import { shttpHandler, STATELESS_GET_KEY } from './shttpHandler.js'; -/** - * Interface for resumability support via event storage - */ -export interface EventStore { - /** - * Stores an event for later retrieval - * @param streamId ID of the stream the event belongs to - * @param message The JSON-RPC message to store - * @returns The generated event ID for the stored event - */ - storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; - - /** - * Get the stream ID associated with a given event ID. - * @param eventId The event ID to look up - * @returns The stream ID, or `undefined` if not found - * - * Optional: If not provided, the SDK will use the `streamId` returned by - * {@linkcode replayEventsAfter} for stream mapping. - */ - getStreamIdForEventId?(eventId: EventId): Promise; - - replayEventsAfter( - lastEventId: EventId, - { - send - }: { - send: (eventId: EventId, message: JSONRPCMessage) => Promise; - } - ): Promise; -} - -/** - * Internal stream mapping for managing SSE connections - */ -interface StreamMapping { - /** Stream controller for pushing SSE data - only used with `ReadableStream` approach */ - controller?: ReadableStreamDefaultController; - /** Text encoder for SSE formatting */ - encoder?: InstanceType; - /** Promise resolver for JSON response mode */ - resolveJson?: (response: Response) => void; - /** Cleanup function to close stream and remove mapping */ - cleanup: () => void; -} +export type { EventId, EventStore, StreamId } from './shttpHandler.js'; /** * Configuration options for {@linkcode WebStandardStreamableHTTPServerTransport} @@ -111,7 +66,7 @@ export interface WebStandardStreamableHTTPServerTransportOptions { * Event store for resumability support * If provided, resumability will be enabled, allowing clients to reconnect and resume messages */ - eventStore?: EventStore; + eventStore?: import('./shttpHandler.js').EventStore; /** * List of allowed `Host` header values for DNS rebinding protection. @@ -180,65 +135,23 @@ export interface HandleRequestOptions { * - Session ID is generated and included in response headers * - Session ID is always included in initialization responses * - Requests with invalid session IDs are rejected with `404 Not Found` - * - Non-initialization requests without a session ID are rejected with `400 Bad Request` - * - State is maintained in-memory (connections, message history) + * - GET opens a standalone subscription stream; DELETE terminates the session * - * In stateless mode: - * - No Session ID is included in any responses - * - No session validation is performed + * In stateless mode (no `sessionIdGenerator`): + * - No session validation; GET/DELETE return 405 * - * @example Stateful setup - * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateful" - * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - * - * const transport = new WebStandardStreamableHTTPServerTransport({ - * sessionIdGenerator: () => crypto.randomUUID() - * }); - * - * await server.connect(transport); - * ``` - * - * @example Stateless setup - * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateless" - * const transport = new WebStandardStreamableHTTPServerTransport({ - * sessionIdGenerator: undefined - * }); - * ``` - * - * @example Hono.js - * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_hono" - * app.all('/mcp', async c => { - * return transport.handleRequest(c.req.raw); - * }); - * ``` - * - * @example Cloudflare Workers - * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_workers" - * const worker = { - * async fetch(request: Request): Promise { - * return transport.handleRequest(request); - * } - * }; - * ``` + * The class is now a thin shim: {@linkcode handleRequest} delegates to a captured + * {@linkcode shttpHandler} bound at {@linkcode connect | connect()} time. The + * {@linkcode Transport} interface methods route outbound messages through the + * per-session {@linkcode Backchannel2511}. */ export class WebStandardStreamableHTTPServerTransport implements Transport { - // when sessionId is not set (undefined), it means the transport is in stateless mode - private sessionIdGenerator: (() => string) | undefined; - private _started: boolean = false; - private _closed: boolean = false; - private _streamMapping: Map = new Map(); - private _requestToStreamMapping: Map = new Map(); - private _requestResponseMap: Map = new Map(); - private _initialized: boolean = false; - private _enableJsonResponse: boolean = false; - private _standaloneSseStreamId: string = '_GET_stream'; - private _eventStore?: EventStore; - private _onsessioninitialized?: (sessionId: string) => void | Promise; - private _onsessionclosed?: (sessionId: string) => void | Promise; - private _allowedHosts?: string[]; - private _allowedOrigins?: string[]; - private _enableDnsRebindingProtection: boolean; - private _retryInterval?: number; + private _options: WebStandardStreamableHTTPServerTransportOptions; + private _session?: SessionCompat; + private _backchannel = new Backchannel2511(); + private _handler?: (req: Request, extra?: ShttpRequestExtra) => Promise; + private _started = false; + private _closed = false; private _supportedProtocolVersions: string[]; sessionId?: string; @@ -247,792 +160,155 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { - this.sessionIdGenerator = options.sessionIdGenerator; - this._enableJsonResponse = options.enableJsonResponse ?? false; - this._eventStore = options.eventStore; - this._onsessioninitialized = options.onsessioninitialized; - this._onsessionclosed = options.onsessionclosed; - this._allowedHosts = options.allowedHosts; - this._allowedOrigins = options.allowedOrigins; - this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; - this._retryInterval = options.retryInterval; + this._options = options; this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - } - - /** - * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op - * for the Streamable HTTP transport as connections are managed per-request. - */ - async start(): Promise { - if (this._started) { - throw new Error('Transport already started'); - } - this._started = true; - } - - /** - * Sets the supported protocol versions for header validation. - * Called by the server during {@linkcode server/server.Server.connect | connect()} to pass its supported versions. - */ - setSupportedProtocolVersions(versions: string[]): void { - this._supportedProtocolVersions = versions; - } - - /** - * Helper to create a JSON error response - */ - private createJsonErrorResponse( - status: number, - code: number, - message: string, - options?: { headers?: Record; data?: string } - ): Response { - const error: { code: number; message: string; data?: string } = { code, message }; - if (options?.data !== undefined) { - error.data = options.data; - } - return Response.json( - { - jsonrpc: '2.0', - error, - id: null - }, - { - status, - headers: { - 'Content-Type': 'application/json', - ...options?.headers + if (options.sessionIdGenerator) { + this._session = new SessionCompat({ + sessionIdGenerator: options.sessionIdGenerator, + singleSession: true, + onerror: e => this.onerror?.(e), + onsessioninitialized: id => { + this.sessionId = id; + return options.onsessioninitialized?.(id); + }, + onsessionclosed: id => { + this._backchannel.closeSession(id); + return options.onsessionclosed?.(id); } - } - ); - } - - /** - * Validates request headers for DNS rebinding protection. - * @returns Error response if validation fails, `undefined` if validation passes. - */ - private validateRequestHeaders(req: Request): Response | undefined { - // Skip validation if protection is not enabled - if (!this._enableDnsRebindingProtection) { - return undefined; - } - - // Validate Host header if allowedHosts is configured - if (this._allowedHosts && this._allowedHosts.length > 0) { - const hostHeader = req.headers.get('host'); - if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { - const error = `Invalid Host header: ${hostHeader}`; - this.onerror?.(new Error(error)); - return this.createJsonErrorResponse(403, -32_000, error); - } - } - - // Validate Origin header if allowedOrigins is configured - if (this._allowedOrigins && this._allowedOrigins.length > 0) { - const originHeader = req.headers.get('origin'); - if (originHeader && !this._allowedOrigins.includes(originHeader)) { - const error = `Invalid Origin header: ${originHeader}`; - this.onerror?.(new Error(error)); - return this.createJsonErrorResponse(403, -32_000, error); - } - } - - return undefined; - } - - /** - * Handles an incoming HTTP request, whether `GET`, `POST`, or `DELETE` - * Returns a `Response` object (Web Standard) - */ - async handleRequest(req: Request, options?: HandleRequestOptions): Promise { - // Validate request headers for DNS rebinding protection - const validationError = this.validateRequestHeaders(req); - if (validationError) { - return validationError; - } - - switch (req.method) { - case 'POST': { - return this.handlePostRequest(req, options); - } - case 'GET': { - return this.handleGetRequest(req); - } - case 'DELETE': { - return this.handleDeleteRequest(req); - } - default: { - return this.handleUnsupportedRequest(); - } - } - } - - /** - * Writes a priming event to establish resumption capability. - * Only sends if `eventStore` is configured (opt-in for resumability) and - * the client's protocol version supports empty SSE data (>= `2025-11-25`). - */ - private async writePrimingEvent( - controller: ReadableStreamDefaultController, - encoder: InstanceType, - streamId: string, - protocolVersion: string - ): Promise { - if (!this._eventStore) { - return; - } - - // Priming events have empty data which older clients cannot handle. - // Only send priming events to clients with protocol version >= 2025-11-25 - // which includes the fix for handling empty SSE data. - if (protocolVersion < '2025-11-25') { - return; - } - - const primingEventId = await this._eventStore.storeEvent(streamId, {} as JSONRPCMessage); - - let primingEvent = `id: ${primingEventId}\ndata: \n\n`; - if (this._retryInterval !== undefined) { - primingEvent = `id: ${primingEventId}\nretry: ${this._retryInterval}\ndata: \n\n`; + }); } - controller.enqueue(encoder.encode(primingEvent)); } /** - * Handles `GET` requests for SSE stream + * Called by `McpServer.connect()` to bind this transport to a server. Builds the underlying + * {@linkcode shttpHandler} that {@linkcode handleRequest} delegates to. */ - private async handleGetRequest(req: Request): Promise { - // The client MUST include an Accept header, listing text/event-stream as a supported content type. - const acceptHeader = req.headers.get('accept'); - if (!acceptHeader?.includes('text/event-stream')) { - this.onerror?.(new Error('Not Acceptable: Client must accept text/event-stream')); - return this.createJsonErrorResponse(406, -32_000, 'Not Acceptable: Client must accept text/event-stream'); - } - - // If an Mcp-Session-Id is returned by the server during initialization, - // clients using the Streamable HTTP transport MUST include it - // in the Mcp-Session-Id header on all of their subsequent HTTP requests. - const sessionError = this.validateSession(req); - if (sessionError) { - return sessionError; - } - const protocolError = this.validateProtocolVersion(req); - if (protocolError) { - return protocolError; - } - - // Handle resumability: check for Last-Event-ID header - if (this._eventStore) { - const lastEventId = req.headers.get('last-event-id'); - if (lastEventId) { - return this.replayEvents(lastEventId); - } - } - - // Check if there's already an active standalone SSE stream for this session - if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { - // Only one GET SSE stream is allowed per session - this.onerror?.(new Error('Conflict: Only one SSE stream is allowed per session')); - return this.createJsonErrorResponse(409, -32_000, 'Conflict: Only one SSE stream is allowed per session'); - } - - const encoder = new TextEncoder(); - let streamController: ReadableStreamDefaultController; - - // Create a ReadableStream with a controller we can use to push SSE events - const readable = new ReadableStream({ - start: controller => { - streamController = controller; - }, - cancel: () => { - // Stream was cancelled by client - this._streamMapping.delete(this._standaloneSseStreamId); - } + bind(server: McpServerLike): void { + this._handler = shttpHandler(server, { + session: this._session, + backchannel: this._backchannel, + eventStore: this._options.eventStore, + enableJsonResponse: this._options.enableJsonResponse, + retryInterval: this._options.retryInterval, + supportedProtocolVersions: this._supportedProtocolVersions, + onerror: e => this.onerror?.(e) }); - - const headers: Record = { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive' - }; - - // After initialization, always include the session ID if we have one - if (this.sessionId !== undefined) { - headers['mcp-session-id'] = this.sessionId; - } - - // Store the stream mapping with the controller for pushing data - this._streamMapping.set(this._standaloneSseStreamId, { - controller: streamController!, - encoder, - cleanup: () => { - this._streamMapping.delete(this._standaloneSseStreamId); - try { - streamController!.close(); - } catch { - // Controller might already be closed - } - } - }); - - return new Response(readable, { headers }); } /** - * Replays events that would have been sent after the specified event ID - * Only used when resumability is enabled + * Handles an incoming Web-standard {@linkcode Request} and returns a Web-standard {@linkcode Response}. */ - private async replayEvents(lastEventId: string): Promise { - if (!this._eventStore) { - this.onerror?.(new Error('Event store not configured')); - return this.createJsonErrorResponse(400, -32_000, 'Event store not configured'); - } - - try { - // If getStreamIdForEventId is available, use it for conflict checking - let streamId: string | undefined; - if (this._eventStore.getStreamIdForEventId) { - streamId = await this._eventStore.getStreamIdForEventId(lastEventId); - - if (!streamId) { - this.onerror?.(new Error('Invalid event ID format')); - return this.createJsonErrorResponse(400, -32_000, 'Invalid event ID format'); - } - - // Check conflict with the SAME streamId we'll use for mapping - if (this._streamMapping.get(streamId) !== undefined) { - this.onerror?.(new Error('Conflict: Stream already has an active connection')); - return this.createJsonErrorResponse(409, -32_000, 'Conflict: Stream already has an active connection'); - } - } - - const headers: Record = { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive' - }; - - if (this.sessionId !== undefined) { - headers['mcp-session-id'] = this.sessionId; - } - - // Create a ReadableStream with controller for SSE - const encoder = new TextEncoder(); - let streamController: ReadableStreamDefaultController; - - const readable = new ReadableStream({ - start: controller => { - streamController = controller; + async handleRequest(req: Request, options: HandleRequestOptions = {}): Promise { + if (!this._handler) { + return Response.json( + { + jsonrpc: '2.0', + error: { code: -32_603, message: 'Transport not bound to a server. Call server.connect(transport) first.' }, + id: null }, - cancel: () => { - // Stream was cancelled by client - // Cleanup will be handled by the mapping - } - }); - - // Replay events - returns the streamId for backwards compatibility - const replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { - send: async (eventId: string, message: JSONRPCMessage) => { - const success = this.writeSSEEvent(streamController!, encoder, message, eventId); - if (!success) { - try { - streamController!.close(); - } catch { - // Controller might already be closed - } - } - } - }); - - this._streamMapping.set(replayedStreamId, { - controller: streamController!, - encoder, - cleanup: () => { - this._streamMapping.delete(replayedStreamId); - try { - streamController!.close(); - } catch { - // Controller might already be closed - } - } - }); - - return new Response(readable, { headers }); - } catch (error) { - this.onerror?.(error as Error); - return this.createJsonErrorResponse(500, -32_000, 'Error replaying events'); + { status: 500 } + ); } - } - - /** - * Writes an event to an SSE stream via controller with proper formatting - */ - private writeSSEEvent( - controller: ReadableStreamDefaultController, - encoder: InstanceType, - message: JSONRPCMessage, - eventId?: string - ): boolean { - try { - let eventData = `event: message\n`; - // Include event ID if provided - this is important for resumability - if (eventId) { - eventData += `id: ${eventId}\n`; - } - eventData += `data: ${JSON.stringify(message)}\n\n`; - controller.enqueue(encoder.encode(eventData)); - return true; - } catch (error) { - this.onerror?.(error as Error); - return false; + if (this._options.enableDnsRebindingProtection) { + const err = this._validateDnsRebinding(req); + if (err) return err; } + return this._handler(req, { parsedBody: options.parsedBody, authInfo: options.authInfo }); } /** - * Handles unsupported requests (`PUT`, `PATCH`, etc.) - */ - private handleUnsupportedRequest(): Response { - this.onerror?.(new Error('Method not allowed.')); - return Response.json( - { - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }, - { - status: 405, - headers: { - Allow: 'GET, POST, DELETE', - 'Content-Type': 'application/json' - } - } - ); - } - - /** - * Handles `POST` requests containing JSON-RPC messages + * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op + * for the Streamable HTTP transport as connections are managed per-request. */ - private async handlePostRequest(req: Request, options?: HandleRequestOptions): Promise { - try { - // Validate the Accept header - const acceptHeader = req.headers.get('accept'); - // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. - if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { - this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); - return this.createJsonErrorResponse( - 406, - -32_000, - 'Not Acceptable: Client must accept both application/json and text/event-stream' - ); - } - - const ct = req.headers.get('content-type'); - if (!ct || !ct.includes('application/json')) { - this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); - return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); - } - - const request = req; - - let rawMessage; - if (options?.parsedBody === undefined) { - try { - rawMessage = await req.json(); - } catch (error) { - this.onerror?.(error as Error); - return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON'); - } - } else { - rawMessage = options.parsedBody; - } - - let messages: JSONRPCMessage[]; - - // handle batch and single messages - try { - messages = Array.isArray(rawMessage) - ? rawMessage.map(msg => JSONRPCMessageSchema.parse(msg)) - : [JSONRPCMessageSchema.parse(rawMessage)]; - } catch (error) { - this.onerror?.(error as Error); - return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON-RPC message'); - } - - // Check if this is an initialization request - // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ - const isInitializationRequest = messages.some(element => isInitializeRequest(element)); - if (isInitializationRequest) { - // If it's a server with session management and the session ID is already set we should reject the request - // to avoid re-initialization. - if (this._initialized && this.sessionId !== undefined) { - this.onerror?.(new Error('Invalid Request: Server already initialized')); - return this.createJsonErrorResponse(400, -32_600, 'Invalid Request: Server already initialized'); - } - if (messages.length > 1) { - this.onerror?.(new Error('Invalid Request: Only one initialization request is allowed')); - return this.createJsonErrorResponse(400, -32_600, 'Invalid Request: Only one initialization request is allowed'); - } - this.sessionId = this.sessionIdGenerator?.(); - this._initialized = true; - - // If we have a session ID and an onsessioninitialized handler, call it immediately - // This is needed in cases where the server needs to keep track of multiple sessions - if (this.sessionId && this._onsessioninitialized) { - await Promise.resolve(this._onsessioninitialized(this.sessionId)); - } - } - if (!isInitializationRequest) { - // If an Mcp-Session-Id is returned by the server during initialization, - // clients using the Streamable HTTP transport MUST include it - // in the Mcp-Session-Id header on all of their subsequent HTTP requests. - const sessionError = this.validateSession(req); - if (sessionError) { - return sessionError; - } - // Mcp-Protocol-Version header is required for all requests after initialization. - const protocolError = this.validateProtocolVersion(req); - if (protocolError) { - return protocolError; - } - } - - // check if it contains requests - const hasRequests = messages.some(element => isJSONRPCRequest(element)); - - if (!hasRequests) { - // if it only contains notifications or responses, return 202 - for (const message of messages) { - this.onmessage?.(message, { authInfo: options?.authInfo, request }); - } - return new Response(null, { status: 202 }); - } - - // The default behavior is to use SSE streaming - // but in some cases server will return JSON responses - const streamId = crypto.randomUUID(); - - // Extract protocol version for priming event decision. - // For initialize requests, get from request params. - // For other requests, get from header (already validated). - const initRequest = messages.find(m => isInitializeRequest(m)); - const clientProtocolVersion = initRequest - ? initRequest.params.protocolVersion - : (req.headers.get('mcp-protocol-version') ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - - if (this._enableJsonResponse) { - // For JSON response mode, return a Promise that resolves when all responses are ready - return new Promise(resolve => { - this._streamMapping.set(streamId, { - resolveJson: resolve, - cleanup: () => { - this._streamMapping.delete(streamId); - } - }); - - for (const message of messages) { - if (isJSONRPCRequest(message)) { - this._requestToStreamMapping.set(message.id, streamId); - } - } - - for (const message of messages) { - this.onmessage?.(message, { authInfo: options?.authInfo, request }); - } - }); - } - - // SSE streaming mode - use ReadableStream with controller for more reliable data pushing - const encoder = new TextEncoder(); - let streamController: ReadableStreamDefaultController; - - const readable = new ReadableStream({ - start: controller => { - streamController = controller; - }, - cancel: () => { - // Stream was cancelled by client - this._streamMapping.delete(streamId); - } - }); - - const headers: Record = { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - }; - - // After initialization, always include the session ID if we have one - if (this.sessionId !== undefined) { - headers['mcp-session-id'] = this.sessionId; - } - - // Store the response for this request to send messages back through this connection - // We need to track by request ID to maintain the connection - for (const message of messages) { - if (isJSONRPCRequest(message)) { - this._streamMapping.set(streamId, { - controller: streamController!, - encoder, - cleanup: () => { - this._streamMapping.delete(streamId); - try { - streamController!.close(); - } catch { - // Controller might already be closed - } - } - }); - this._requestToStreamMapping.set(message.id, streamId); - } - } - - // Write priming event if event store is configured (after mapping is set up) - await this.writePrimingEvent(streamController!, encoder, streamId, clientProtocolVersion); - - // handle each message - for (const message of messages) { - // Build closeSSEStream callback for requests when eventStore is configured - // AND client supports resumability (protocol version >= 2025-11-25). - // Old clients can't resume if the stream is closed early because they - // didn't receive a priming event with an event ID. - let closeSSEStream: (() => void) | undefined; - let closeStandaloneSSEStream: (() => void) | undefined; - if (isJSONRPCRequest(message) && this._eventStore && clientProtocolVersion >= '2025-11-25') { - closeSSEStream = () => { - this.closeSSEStream(message.id); - }; - closeStandaloneSSEStream = () => { - this.closeStandaloneSSEStream(); - }; - } - - this.onmessage?.(message, { authInfo: options?.authInfo, request, closeSSEStream, closeStandaloneSSEStream }); - } - // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses - // This will be handled by the send() method when responses are ready - - return new Response(readable, { status: 200, headers }); - } catch (error) { - // return JSON-RPC formatted error - this.onerror?.(error as Error); - return this.createJsonErrorResponse(400, -32_700, 'Parse error', { data: String(error) }); + async start(): Promise { + if (this._started) { + throw new Error('Transport already started'); } + this._started = true; } /** - * Handles `DELETE` requests to terminate sessions + * Sets the supported protocol versions for header validation. + * Called by the server during {@linkcode server/server.Server.connect | connect()} to pass its supported versions. */ - private async handleDeleteRequest(req: Request): Promise { - const sessionError = this.validateSession(req); - if (sessionError) { - return sessionError; - } - const protocolError = this.validateProtocolVersion(req); - if (protocolError) { - return protocolError; - } - - await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); - await this.close(); - return new Response(null, { status: 200 }); + setSupportedProtocolVersions(versions: string[]): void { + this._supportedProtocolVersions = versions; } - /** - * Validates session ID for non-initialization requests. - * Returns `Response` error if invalid, `undefined` otherwise - */ - private validateSession(req: Request): Response | undefined { - if (this.sessionIdGenerator === undefined) { - // If the sessionIdGenerator ID is not set, the session management is disabled - // and we don't need to validate the session ID - return undefined; - } - if (!this._initialized) { - // If the server has not been initialized yet, reject all requests - this.onerror?.(new Error('Bad Request: Server not initialized')); - return this.createJsonErrorResponse(400, -32_000, 'Bad Request: Server not initialized'); - } - - const sessionId = req.headers.get('mcp-session-id'); - - if (!sessionId) { - // Non-initialization requests without a session ID should return 400 Bad Request - this.onerror?.(new Error('Bad Request: Mcp-Session-Id header is required')); - return this.createJsonErrorResponse(400, -32_000, 'Bad Request: Mcp-Session-Id header is required'); - } - - if (sessionId !== this.sessionId) { - // Reject requests with invalid session ID with 404 Not Found - this.onerror?.(new Error('Session not found')); - return this.createJsonErrorResponse(404, -32_001, 'Session not found'); - } - - return undefined; + setProtocolVersion(_version: string): void { + // No-op: protocol version is per-session in SessionCompat. } /** - * Validates the `MCP-Protocol-Version` header on incoming requests. + * Sends a message over the transport. Outbound responses are routed to the + * {@linkcode Backchannel2511} resolver map (the inverse direction of `env.send`); + * notifications and server-initiated requests go on the session's standalone GET stream. * - * For initialization: Version negotiation handles unknown versions gracefully - * (server responds with its supported version). - * - * For subsequent requests with `MCP-Protocol-Version` header: - * - Accept if in supported list - * - 400 if unsupported - * - * For HTTP requests without the `MCP-Protocol-Version` header: - * - Accept and default to the version negotiated at initialization + * `relatedRequestId` is ignored: in the new model the dispatch generator yields + * directly into the originating POST's SSE stream, so the only `send()` callers are + * `StreamDriver` for unsolicited notifications/requests. */ - private validateProtocolVersion(req: Request): Response | undefined { - const protocolVersion = req.headers.get('mcp-protocol-version'); - - if (protocolVersion !== null && !this._supportedProtocolVersions.includes(protocolVersion)) { - const error = `Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${this._supportedProtocolVersions.join(', ')})`; - this.onerror?.(new Error(error)); - return this.createJsonErrorResponse(400, -32_000, error); - } - return undefined; - } - - async close(): Promise { - if (this._closed) { + async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise { + if (this._closed) return; + const sessionId = this.sessionId ?? STATELESS_GET_KEY; + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + this._backchannel.handleResponse(sessionId, message); return; } - this._closed = true; - - // Close all SSE connections - for (const { cleanup } of this._streamMapping.values()) { - cleanup(); + const written = this._backchannel.writeStandalone(sessionId, message); + if (!written && this._options.eventStore) { + // Store for replay even when no GET stream is open (matches v1 send()). + await this._options.eventStore.storeEvent('_GET_stream', message); } - this._streamMapping.clear(); - - // Clear any pending responses - this._requestResponseMap.clear(); - this.onclose?.(); } /** * Close an SSE stream for a specific request, triggering client reconnection. - * Use this to implement polling behavior during long-running operations - - * client will reconnect after the retry interval specified in the priming event. + * @deprecated Per-request stream tracking was removed; this is now a no-op. Use + * `ctx.http?.closeSSE` from inside the handler instead. */ - closeSSEStream(requestId: RequestId): void { - const streamId = this._requestToStreamMapping.get(requestId); - if (!streamId) return; - - const stream = this._streamMapping.get(streamId); - if (stream) { - stream.cleanup(); - } + closeSSEStream(_requestId: unknown): void { + // No per-request stream map in the new model. } /** - * Close the standalone `GET` SSE stream, triggering client reconnection. - * Use this to implement polling behavior for server-initiated notifications. + * Close the standalone GET SSE stream, triggering client reconnection. */ closeStandaloneSSEStream(): void { - const stream = this._streamMapping.get(this._standaloneSseStreamId); - if (stream) { - stream.cleanup(); + if (this.sessionId !== undefined) { + this._session?.closeStandaloneStream(this.sessionId); + this._backchannel.setStandaloneWriter(this.sessionId, undefined); } } - async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { - let requestId = options?.relatedRequestId; - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - // If the message is a response, use the request ID from the message - requestId = message.id; - } - - // Check if this message should be sent on the standalone SSE stream (no request ID) - // Ignore notifications from tools (which have relatedRequestId set) - // Those will be sent via dedicated response SSE streams - if (requestId === undefined) { - // For standalone SSE streams, we can only send requests and notifications - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - throw new Error('Cannot send a response on a standalone SSE stream unless resuming a previous client request'); - } - - // Generate and store event ID if event store is provided - // Store even if stream is disconnected so events can be replayed on reconnect - let eventId: string | undefined; - if (this._eventStore) { - // Stores the event and gets the generated event ID - eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message); - } - - const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId); - if (standaloneSse === undefined) { - // Stream is disconnected - event is stored for replay, nothing more to do - return; - } - - // Send the message to the standalone SSE stream - if (standaloneSse.controller && standaloneSse.encoder) { - this.writeSSEEvent(standaloneSse.controller, standaloneSse.encoder, message, eventId); - } - return; - } - - // Get the response for this request - const streamId = this._requestToStreamMapping.get(requestId); - if (!streamId) { - throw new Error(`No connection established for request ID: ${String(requestId)}`); - } - - const stream = this._streamMapping.get(streamId); - - if (!this._enableJsonResponse && stream?.controller && stream?.encoder) { - // For SSE responses, generate event ID if event store is provided - let eventId: string | undefined; - - if (this._eventStore) { - eventId = await this._eventStore.storeEvent(streamId, message); - } - // Write the event to the response stream - this.writeSSEEvent(stream.controller, stream.encoder, message, eventId); + /** + * Closes the transport. + */ + async close(): Promise { + if (this._closed) return; + this._closed = true; + if (this.sessionId !== undefined) { + this._backchannel.closeSession(this.sessionId); + await this._session?.delete(this.sessionId); } + this.onclose?.(); + } - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - this._requestResponseMap.set(requestId, message); - const relatedIds = [...this._requestToStreamMapping.entries()].filter(([_, sid]) => sid === streamId).map(([id]) => id); - - // Check if we have responses for all requests using this connection - const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id)); - - if (allResponsesReady) { - if (!stream) { - throw new Error(`No connection established for request ID: ${String(requestId)}`); - } - if (this._enableJsonResponse && stream.resolveJson) { - // All responses ready, send as JSON - const headers: Record = { - 'Content-Type': 'application/json' - }; - if (this.sessionId !== undefined) { - headers['mcp-session-id'] = this.sessionId; - } - - const responses = relatedIds.map(id => this._requestResponseMap.get(id)!); - - if (responses.length === 1) { - stream.resolveJson(Response.json(responses[0], { status: 200, headers })); - } else { - stream.resolveJson(Response.json(responses, { status: 200, headers })); - } - } else { - // End the SSE stream - stream.cleanup(); - } - // Clean up - for (const id of relatedIds) { - this._requestResponseMap.delete(id); - this._requestToStreamMapping.delete(id); - } - } + private _validateDnsRebinding(req: Request): Response | undefined { + const host = req.headers.get('host'); + if (this._options.allowedHosts && host && !this._options.allowedHosts.includes(host)) { + return Response.json( + { jsonrpc: '2.0', error: { code: -32_000, message: `Invalid Host header: ${host}` }, id: null }, + { status: 403 } + ); + } + const origin = req.headers.get('origin'); + if (this._options.allowedOrigins && origin && !this._options.allowedOrigins.includes(origin)) { + return Response.json( + { jsonrpc: '2.0', error: { code: -32_000, message: `Invalid Origin header: ${origin}` }, id: null }, + { status: 403 } + ); } + return undefined; } } diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 7a23dd56b..df3b6d000 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -974,23 +974,18 @@ describe('Zod v4', () => { expect(closeCallCount).toBe(1); }); - it('should clean up all streams exactly once even when close() is called concurrently', async () => { + it('should fire onclose exactly once even when close() is called concurrently', async () => { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); - const cleanupCalls: string[] = []; - - // Inject a fake stream entry to verify cleanup runs exactly once - // @ts-expect-error accessing private map for test purposes - transport._streamMapping.set('stream-1', { - cleanup: () => { - cleanupCalls.push('stream-1'); - } - }); + let closeCount = 0; + transport.onclose = () => { + closeCount++; + }; // Fire two concurrent close() calls — only the first should proceed await Promise.all([transport.close(), transport.close()]); - expect(cleanupCalls).toEqual(['stream-1']); + expect(closeCount).toBe(1); }); }); }); From 5490e2d1a0e4315cefd532ff6190c3e628a50fef Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 11:51:45 +0000 Subject: [PATCH 25/55] docs: remove superseded shttpHandler limitation doc (backchannel now implemented) --- docs/shttp-handler-limitations.md | 46 ------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 docs/shttp-handler-limitations.md diff --git a/docs/shttp-handler-limitations.md b/docs/shttp-handler-limitations.md deleted file mode 100644 index 7d17b6b55..000000000 --- a/docs/shttp-handler-limitations.md +++ /dev/null @@ -1,46 +0,0 @@ -# `shttpHandler` limitations (2025-11 protocol) - -`shttpHandler` is the request/response entry point for the new architecture: it -calls `mcpServer.dispatch(req, env)` per HTTP POST and streams the result back -as SSE or JSON. It is intentionally stateless — no `_streamMapping`, no -`relatedRequestId` routing. - -## Elicitation / sampling over the new path - -`shttpHandler` does **not** supply `env.send`. If a tool handler calls -`ctx.mcpReq.elicitInput(...)` or `ctx.mcpReq.requestSampling(...)` while -running under `shttpHandler`, it throws `SdkError(NotConnected)` with a message -pointing at the MRTR-native form. - -This is because the 2025-11 mechanism for server→client requests is: - -1. Server writes the elicit request as an SSE event on the open POST response. -2. Client posts the answer back on a **separate** HTTP POST as a JSON-RPC response. -3. Server matches that response to the pending `env.send` promise by request id. - -Step 3 requires a per-session map of `{requestId → resolver}` that survives -across HTTP requests — exactly the `_requestToStreamMapping` / -`_responseHandlers` state that `WebStandardStreamableHTTPServerTransport` -carries and that this rebuild moved out of the request path. - -## What to use instead - -| Need | Use | -|---|---| -| Elicitation/sampling on a 2025-11 client | `mcpServer.connect(new WebStandardStreamableHTTPServerTransport(...))` — the old transport still works via `StreamDriver`, which provides `env.send`. | -| Elicitation/sampling on a 2026-06+ client | Handler returns `IncompleteResult` (MRTR, SEP-2322). `shttpHandler` returns it as the response; client re-calls with `inputResponses`. No back-channel needed. | -| Stateless server, no elicitation | `shttpHandler` directly. | - -## If we decide to implement it later - -Add `pendingServerRequests: Map void>` to -`SessionCompat`. `shttpHandler`: - -- Supply `env.send = (req) => { write req to SSE; return new Promise((res, rej) => session.pendingServerRequests.set(id, ...)) }` -- On inbound POST whose body is a JSON-RPC **response** (not request), look up - the resolver in `session.pendingServerRequests` and resolve it instead of - calling `dispatch`. - -Estimated ~120 LOC across `shttpHandler.ts` + `sessionCompat.ts`. Deferred -because it re-introduces the per-session correlation state the rebuild -removed, and MRTR (accepted-with-changes) makes it obsolete. From 90e31ffff9d5368e74ff14e715f6690243cc7cd8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 11:53:33 +0000 Subject: [PATCH 26/55] fix(security): reject missing Host header when allowedHosts configured (DNS rebinding) The gutted _validateDnsRebinding allowed requests with no Host header through. Restore original behavior: when allowedHosts is set, missing Host = reject. --- packages/server/src/server/streamableHttp.ts | 28 +++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index a9f31a3c0..a840aa5d8 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -295,19 +295,23 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } private _validateDnsRebinding(req: Request): Response | undefined { - const host = req.headers.get('host'); - if (this._options.allowedHosts && host && !this._options.allowedHosts.includes(host)) { - return Response.json( - { jsonrpc: '2.0', error: { code: -32_000, message: `Invalid Host header: ${host}` }, id: null }, - { status: 403 } - ); + if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { + const host = req.headers.get('host'); + if (!host || !this._options.allowedHosts.includes(host)) { + return Response.json( + { jsonrpc: '2.0', error: { code: -32_000, message: `Invalid Host header: ${host ?? '(missing)'}` }, id: null }, + { status: 403 } + ); + } } - const origin = req.headers.get('origin'); - if (this._options.allowedOrigins && origin && !this._options.allowedOrigins.includes(origin)) { - return Response.json( - { jsonrpc: '2.0', error: { code: -32_000, message: `Invalid Origin header: ${origin}` }, id: null }, - { status: 403 } - ); + if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { + const origin = req.headers.get('origin'); + if (origin && !this._options.allowedOrigins.includes(origin)) { + return Response.json( + { jsonrpc: '2.0', error: { code: -32_000, message: `Invalid Origin header: ${origin}` }, id: null }, + { status: 403 } + ); + } } return undefined; } From e502d63fe24ce88e2bf7bedb51ddbb7c9b96ee3a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 12:33:37 +0000 Subject: [PATCH 27/55] fix(core): avoid TaskManager double-process on stdio path via dispatcherHandlesTasks flag McpServer's task-aware dispatch() override + StreamDriver._onrequest both called processInboundRequest. Idempotent for non-task requests but redundant. StreamDriver now skips its own task processing when the dispatcher handles it. --- packages/core/src/shared/streamDriver.ts | 49 +++++++++++++++++------- packages/server/src/server/mcpServer.ts | 1 + 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 79cd53cdc..08aa2d87c 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -57,6 +57,12 @@ export type StreamDriverOptions = { taskManager?: TaskManager; tasks?: TaskManagerOptions; enforceStrictCapabilities?: boolean; + /** + * Set when the dispatcher's {@linkcode Dispatcher.dispatch | dispatch()} override handles + * {@linkcode TaskManager.processInboundRequest} itself (e.g. {@linkcode McpServer}). + * When true, the driver skips its own inbound task processing to avoid double-processing. + */ + dispatcherHandlesTasks?: boolean; }; /** @@ -291,27 +297,44 @@ export class StreamDriver { const abort = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abort); - const inboundCtx: InboundContext = { - sessionId: this.pipe.sessionId, - sendNotification: (n, opts) => this.notification(n, { ...opts, relatedRequestId: request.id }), - sendRequest: (r, schema, opts) => this.request(r, schema, { ...opts, relatedRequestId: request.id }) - }; - const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); + const directSend = (r: Request, opts?: RequestOptions) => + this.request(r, getResultSchema(r.method as RequestMethod), { ...opts, relatedRequestId: request.id }) as Promise; + + let task: DispatchEnv['task']; + let send = directSend; + let routeResponse = async (_m: JSONRPCResponse | JSONRPCErrorResponse) => false; + let drainNotification = (n: Notification, opts?: NotificationOptions) => + this.notification(n, { ...opts, relatedRequestId: request.id }); + let validateInbound: (() => void) | undefined; + + if (!this._options.dispatcherHandlesTasks) { + const inboundCtx: InboundContext = { + sessionId: this.pipe.sessionId, + sendNotification: drainNotification, + sendRequest: (r, schema, opts) => this.request(r, schema, { ...opts, relatedRequestId: request.id }) + }; + const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); + task = taskResult.taskContext; + send = (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise; + routeResponse = taskResult.routeResponse; + drainNotification = taskResult.sendNotification; + validateInbound = taskResult.validateInbound; + } const baseEnv: DispatchEnv = { signal: abort.signal, sessionId: this.pipe.sessionId, authInfo: extra?.authInfo, httpReq: extra?.request, - task: taskResult.taskContext, - send: (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise + task, + send }; const env = this._options.buildEnv ? this._options.buildEnv(extra, baseEnv) : baseEnv; const drain = async () => { - if (taskResult.validateInbound) { + if (validateInbound) { try { - taskResult.validateInbound(); + validateInbound(); } catch (error) { const e = error as { code?: number; message?: string; data?: unknown }; const errResp: JSONRPCErrorResponse = { @@ -323,17 +346,17 @@ export class StreamDriver { ...(e?.data !== undefined && { data: e.data }) } }; - const routed = await taskResult.routeResponse(errResp); + const routed = await routeResponse(errResp); if (!routed) await this.pipe.send(errResp, { relatedRequestId: request.id }); return; } } for await (const out of this.dispatcher.dispatch(request, env)) { if (out.kind === 'notification') { - await taskResult.sendNotification({ method: out.message.method, params: out.message.params }); + await drainNotification({ method: out.message.method, params: out.message.params }); } else { if (abort.signal.aborted) return; - const routed = await taskResult.routeResponse(out.message); + const routed = await routeResponse(out.message); if (!routed) await this.pipe.send(out.message, { relatedRequestId: request.id }); } } diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 2bae0ad2b..c07b9be65 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -414,6 +414,7 @@ export class McpServer extends Dispatcher implements RegistriesHo supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, taskManager: this._taskManager, + dispatcherHandlesTasks: true, enforceStrictCapabilities: this._options?.enforceStrictCapabilities, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) }; From 53586349424d8c36da95912ea0b3d6ae67b85cd5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 12:40:24 +0000 Subject: [PATCH 28/55] feat(core): 3-arg setRequestHandler(method, paramsSchema, handler) for custom methods Ports the BC-carve A1b overload to Dispatcher + McpServer + Client overrides. Validates request.params (minus _meta) against any StandardSchemaV1; handler receives the typed parsed params. Adds protected _wrapParamsSchemaHandler so subclass overrides compose uniformly. Also: lint-disable for streamDriver routeResponse default (conditional reassign pattern from 250b6667). --- packages/client/src/client/client.ts | 17 ++++++- packages/core/src/shared/dispatcher.ts | 50 ++++++++++++++++++-- packages/core/src/shared/streamDriver.ts | 1 + packages/core/test/shared/dispatcher.test.ts | 21 ++++++++ packages/server/src/server/mcpServer.ts | 17 ++++++- 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 5e9067d13..b6b0f33a3 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -342,6 +342,11 @@ export class Client { method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise ): void; + setRequestHandler( + method: string, + paramsSchema: S, + handler: (params: StandardSchemaV1.InferOutput, ctx: ClientContext) => Result | Promise + ): void; /** @deprecated Pass a method string instead of a Zod request schema. */ setRequestHandler( schema: S, @@ -351,10 +356,18 @@ export class Client { ) => Result | Promise ): void; setRequestHandler( - methodOrSchema: RequestMethod | { shape: { method: unknown } }, + methodOrSchema: string | { shape: { method: unknown } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any - handler: (request: any, ctx: ClientContext) => Result | Promise + handlerOrSchema: any, + maybeHandler?: (params: unknown, ctx: ClientContext) => Result | Promise ): void { + if (maybeHandler !== undefined) { + const customMethod = methodOrSchema as string; + this._assertRequestHandlerCapability(customMethod); + this._localDispatcher.setRequestHandler(customMethod, handlerOrSchema, maybeHandler); + return; + } + const handler = handlerOrSchema; const method = ( typeof methodOrSchema === 'string' ? methodOrSchema diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index f1d01494c..857e12a8e 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -15,7 +15,8 @@ import type { Result, ResultTypeMap } from '../types/index.js'; -import { getNotificationSchema, getRequestSchema, ProtocolErrorCode } from '../types/index.js'; +import { getNotificationSchema, getRequestSchema, ProtocolError, ProtocolErrorCode } from '../types/index.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; import type { BaseContext, RequestOptions } from './context.js'; import type { TaskContext } from './taskManager.js'; @@ -219,18 +220,59 @@ export class Dispatcher { /** * Registers a handler to invoke when this dispatcher receives a request with the given method. + * + * For spec methods, the request is parsed against the spec schema and the handler receives + * the typed `RequestTypeMap[M]`. */ setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise - ): void { - const schema = getRequestSchema(method); + ): void; + /** + * Registers a handler for a custom (non-spec) method. The provided `paramsSchema` validates + * `request.params` (with `_meta` stripped); the handler receives the parsed params object. + */ + setRequestHandler( + method: string, + paramsSchema: S, + handler: (params: StandardSchemaV1.InferOutput, ctx: ContextT) => Result | Promise + ): void; + setRequestHandler(method: string, schemaOrHandler: unknown, maybeHandler?: unknown): void { + if (maybeHandler !== undefined) { + const userHandler = maybeHandler as (params: unknown, ctx: ContextT) => Result | Promise; + this._requestHandlers.set(method, this._wrapParamsSchemaHandler(method, schemaOrHandler as StandardSchemaV1, userHandler)); + return; + } + const handler = schemaOrHandler as (request: unknown, ctx: ContextT) => Result | Promise; + const schema = getRequestSchema(method as RequestMethod); this._requestHandlers.set(method, (request, ctx) => { - const parsed = schema.parse(request) as RequestTypeMap[M]; + const parsed = schema.parse(request); return Promise.resolve(handler(parsed, ctx)); }); } + /** + * Builds a raw handler that validates `request.params` (minus `_meta`) against `paramsSchema` + * and invokes `handler(parsedParams, ctx)`. Shared with subclass overrides so per-method + * wrapping composes uniformly with the 3-arg form. + */ + protected _wrapParamsSchemaHandler( + method: string, + paramsSchema: StandardSchemaV1, + handler: (params: unknown, ctx: ContextT) => Result | Promise + ): RawHandler { + return async (request, ctx) => { + const { _meta, ...userParams } = (request.params ?? {}) as Record; + void _meta; + const parsed = await paramsSchema['~standard'].validate(userParams); + if (parsed.issues) { + const msg = parsed.issues.map(i => i.message).join('; '); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${msg}`); + } + return handler(parsed.value, ctx); + }; + } + /** Registers a raw handler with no schema parsing. Used for compat shims. */ setRawRequestHandler(method: string, handler: RawHandler): void { this._requestHandlers.set(method, handler); diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 08aa2d87c..6fbadb317 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -302,6 +302,7 @@ export class StreamDriver { let task: DispatchEnv['task']; let send = directSend; + // eslint-disable-next-line unicorn/consistent-function-scoping -- conditionally reassigned below let routeResponse = async (_m: JSONRPCResponse | JSONRPCErrorResponse) => false; let drainNotification = (n: Notification, opts?: NotificationOptions) => this.notification(n, { ...opts, relatedRequestId: request.id }); diff --git a/packages/core/test/shared/dispatcher.test.ts b/packages/core/test/shared/dispatcher.test.ts index 1cad76ad7..0aee6040b 100644 --- a/packages/core/test/shared/dispatcher.test.ts +++ b/packages/core/test/shared/dispatcher.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest'; +import { z } from 'zod/v4'; import { SdkError } from '../../src/errors/sdkErrors.js'; import type { DispatchOutput } from '../../src/shared/dispatcher.js'; @@ -192,6 +193,26 @@ describe('Dispatcher', () => { }); }); +describe('Dispatcher.setRequestHandler 3-arg (custom method + paramsSchema)', () => { + test('parses params, strips _meta, types handler arg', async () => { + const d = new Dispatcher(); + const schema = z.object({ q: z.string(), limit: z.number().optional() }); + d.setRequestHandler('acme/search', schema, async params => { + return { hits: [params.q], limit: params.limit ?? 10 } as Result; + }); + const r = (await d.dispatchToResponse(req('acme/search', { q: 'foo', _meta: { progressToken: 1 } }))) as JSONRPCResultResponse; + expect(r.result).toEqual({ hits: ['foo'], limit: 10 }); + }); + + test('schema validation failure becomes InvalidParams error response', async () => { + const d = new Dispatcher(); + d.setRequestHandler('acme/search', z.object({ q: z.string() }), async () => ({}) as Result); + const r = (await d.dispatchToResponse(req('acme/search', { q: 123 }))) as JSONRPCErrorResponse; + expect(r.error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(r.error.message).toMatch(/Invalid params for acme\/search/); + }); +}); + describe('Dispatcher.dispatchRaw (envelope-agnostic)', () => { test('yields result without JSON-RPC envelope', async () => { const d = new Dispatcher(); diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index c07b9be65..361911ec6 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -617,6 +617,11 @@ export class McpServer extends Dispatcher implements RegistriesHo method: M, handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise ): void; + public override setRequestHandler( + method: string, + paramsSchema: S, + handler: (params: StandardSchemaV1.InferOutput, ctx: ServerContext) => Result | Promise + ): void; /** @deprecated Pass a method string instead of a Zod request schema. */ public override setRequestHandler( schema: S, @@ -626,9 +631,17 @@ export class McpServer extends Dispatcher implements RegistriesHo ) => Result | Promise ): void; public override setRequestHandler( - methodOrSchema: RequestMethod | { shape: { method: unknown } }, - handler: (request: never, ctx: ServerContext) => Result | Promise + methodOrSchema: string | { shape: { method: unknown } }, + handlerOrSchema: unknown, + maybeHandler?: (params: unknown, ctx: ServerContext) => Result | Promise ): void { + if (maybeHandler !== undefined) { + const method = methodOrSchema as string; + assertRequestHandlerCapability(method as RequestMethod, this._capabilities); + this.setRawRequestHandler(method, this._wrapParamsSchemaHandler(method, handlerOrSchema as StandardSchemaV1, maybeHandler)); + return; + } + const handler = handlerOrSchema as (request: never, ctx: ServerContext) => Result | Promise; const method = (typeof methodOrSchema === 'string' ? methodOrSchema : extractMethodFromSchema(methodOrSchema)) as RequestMethod; assertRequestHandlerCapability(method, this._capabilities); const h = handler as (request: JSONRPCRequest, ctx: ServerContext) => Result | Promise; From 8187039757a2e7baa288647a043ec55b1c7b2d2b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 12:40:33 +0000 Subject: [PATCH 29/55] fix(sdk): add require condition to all export subpaths for jest CJS resolution jest's resolver follows the require condition, not import. Each entry now has {types, import, require} where require points at the same .mjs (Node 22+ require(esm) and jest's enhanced-resolve handle this). --- packages/sdk/package.json | 189 +++++++++++++++++++++++++------------- 1 file changed, 126 insertions(+), 63 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 635452d18..c23becf8d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -22,255 +22,318 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" }, "./stdio": { "types": "./dist/stdio.d.mts", - "import": "./dist/stdio.mjs" + "import": "./dist/stdio.mjs", + "require": "./dist/stdio.mjs" }, "./types.js": { "types": "./dist/types.d.mts", - "import": "./dist/types.mjs" + "import": "./dist/types.mjs", + "require": "./dist/types.mjs" }, "./types": { "types": "./dist/types.d.mts", - "import": "./dist/types.mjs" + "import": "./dist/types.mjs", + "require": "./dist/types.mjs" }, "./server/index.js": { "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.mjs" }, "./server/index": { "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.mjs" }, "./server/mcp.js": { "types": "./dist/server/mcp.d.mts", - "import": "./dist/server/mcp.mjs" + "import": "./dist/server/mcp.mjs", + "require": "./dist/server/mcp.mjs" }, "./server/mcp": { "types": "./dist/server/mcp.d.mts", - "import": "./dist/server/mcp.mjs" + "import": "./dist/server/mcp.mjs", + "require": "./dist/server/mcp.mjs" }, "./server/zod-compat.js": { "types": "./dist/server/zod-compat.d.mts", - "import": "./dist/server/zod-compat.mjs" + "import": "./dist/server/zod-compat.mjs", + "require": "./dist/server/zod-compat.mjs" }, "./server/zod-compat": { "types": "./dist/server/zod-compat.d.mts", - "import": "./dist/server/zod-compat.mjs" + "import": "./dist/server/zod-compat.mjs", + "require": "./dist/server/zod-compat.mjs" }, "./server/stdio.js": { "types": "./dist/server/stdio.d.mts", - "import": "./dist/server/stdio.mjs" + "import": "./dist/server/stdio.mjs", + "require": "./dist/server/stdio.mjs" }, "./server/stdio": { "types": "./dist/server/stdio.d.mts", - "import": "./dist/server/stdio.mjs" + "import": "./dist/server/stdio.mjs", + "require": "./dist/server/stdio.mjs" }, "./server/streamableHttp.js": { "types": "./dist/server/streamableHttp.d.mts", - "import": "./dist/server/streamableHttp.mjs" + "import": "./dist/server/streamableHttp.mjs", + "require": "./dist/server/streamableHttp.mjs" }, "./server/streamableHttp": { "types": "./dist/server/streamableHttp.d.mts", - "import": "./dist/server/streamableHttp.mjs" + "import": "./dist/server/streamableHttp.mjs", + "require": "./dist/server/streamableHttp.mjs" }, "./server/auth/types.js": { "types": "./dist/server/auth/types.d.mts", - "import": "./dist/server/auth/types.mjs" + "import": "./dist/server/auth/types.mjs", + "require": "./dist/server/auth/types.mjs" }, "./server/auth/types": { "types": "./dist/server/auth/types.d.mts", - "import": "./dist/server/auth/types.mjs" + "import": "./dist/server/auth/types.mjs", + "require": "./dist/server/auth/types.mjs" }, "./server/auth/errors.js": { "types": "./dist/server/auth/errors.d.mts", - "import": "./dist/server/auth/errors.mjs" + "import": "./dist/server/auth/errors.mjs", + "require": "./dist/server/auth/errors.mjs" }, "./server/auth/errors": { "types": "./dist/server/auth/errors.d.mts", - "import": "./dist/server/auth/errors.mjs" + "import": "./dist/server/auth/errors.mjs", + "require": "./dist/server/auth/errors.mjs" }, "./client": { "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" + "import": "./dist/client/index.mjs", + "require": "./dist/client/index.mjs" }, "./client/index.js": { "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" + "import": "./dist/client/index.mjs", + "require": "./dist/client/index.mjs" }, "./client/index": { "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" + "import": "./dist/client/index.mjs", + "require": "./dist/client/index.mjs" }, "./client/stdio.js": { "types": "./dist/client/stdio.d.mts", - "import": "./dist/client/stdio.mjs" + "import": "./dist/client/stdio.mjs", + "require": "./dist/client/stdio.mjs" }, "./client/stdio": { "types": "./dist/client/stdio.d.mts", - "import": "./dist/client/stdio.mjs" + "import": "./dist/client/stdio.mjs", + "require": "./dist/client/stdio.mjs" }, "./client/streamableHttp.js": { "types": "./dist/client/streamableHttp.d.mts", - "import": "./dist/client/streamableHttp.mjs" + "import": "./dist/client/streamableHttp.mjs", + "require": "./dist/client/streamableHttp.mjs" }, "./client/streamableHttp": { "types": "./dist/client/streamableHttp.d.mts", - "import": "./dist/client/streamableHttp.mjs" + "import": "./dist/client/streamableHttp.mjs", + "require": "./dist/client/streamableHttp.mjs" }, "./client/sse.js": { "types": "./dist/client/sse.d.mts", - "import": "./dist/client/sse.mjs" + "import": "./dist/client/sse.mjs", + "require": "./dist/client/sse.mjs" }, "./client/sse": { "types": "./dist/client/sse.d.mts", - "import": "./dist/client/sse.mjs" + "import": "./dist/client/sse.mjs", + "require": "./dist/client/sse.mjs" }, "./client/auth.js": { "types": "./dist/client/auth.d.mts", - "import": "./dist/client/auth.mjs" + "import": "./dist/client/auth.mjs", + "require": "./dist/client/auth.mjs" }, "./client/auth": { "types": "./dist/client/auth.d.mts", - "import": "./dist/client/auth.mjs" + "import": "./dist/client/auth.mjs", + "require": "./dist/client/auth.mjs" }, "./shared/protocol.js": { "types": "./dist/shared/protocol.d.mts", - "import": "./dist/shared/protocol.mjs" + "import": "./dist/shared/protocol.mjs", + "require": "./dist/shared/protocol.mjs" }, "./shared/protocol": { "types": "./dist/shared/protocol.d.mts", - "import": "./dist/shared/protocol.mjs" + "import": "./dist/shared/protocol.mjs", + "require": "./dist/shared/protocol.mjs" }, "./shared/transport.js": { "types": "./dist/shared/transport.d.mts", - "import": "./dist/shared/transport.mjs" + "import": "./dist/shared/transport.mjs", + "require": "./dist/shared/transport.mjs" }, "./shared/transport": { "types": "./dist/shared/transport.d.mts", - "import": "./dist/shared/transport.mjs" + "import": "./dist/shared/transport.mjs", + "require": "./dist/shared/transport.mjs" }, "./shared/auth.js": { "types": "./dist/shared/auth.d.mts", - "import": "./dist/shared/auth.mjs" + "import": "./dist/shared/auth.mjs", + "require": "./dist/shared/auth.mjs" }, "./shared/auth": { "types": "./dist/shared/auth.d.mts", - "import": "./dist/shared/auth.mjs" + "import": "./dist/shared/auth.mjs", + "require": "./dist/shared/auth.mjs" }, "./server/auth/middleware/bearerAuth.js": { "types": "./dist/server/auth/middleware/bearerAuth.d.mts", - "import": "./dist/server/auth/middleware/bearerAuth.mjs" + "import": "./dist/server/auth/middleware/bearerAuth.mjs", + "require": "./dist/server/auth/middleware/bearerAuth.mjs" }, "./server/auth/middleware/bearerAuth": { "types": "./dist/server/auth/middleware/bearerAuth.d.mts", - "import": "./dist/server/auth/middleware/bearerAuth.mjs" + "import": "./dist/server/auth/middleware/bearerAuth.mjs", + "require": "./dist/server/auth/middleware/bearerAuth.mjs" }, "./server/auth/router.js": { "types": "./dist/server/auth/router.d.mts", - "import": "./dist/server/auth/router.mjs" + "import": "./dist/server/auth/router.mjs", + "require": "./dist/server/auth/router.mjs" }, "./server/auth/router": { "types": "./dist/server/auth/router.d.mts", - "import": "./dist/server/auth/router.mjs" + "import": "./dist/server/auth/router.mjs", + "require": "./dist/server/auth/router.mjs" }, "./server/auth/provider.js": { "types": "./dist/server/auth/provider.d.mts", - "import": "./dist/server/auth/provider.mjs" + "import": "./dist/server/auth/provider.mjs", + "require": "./dist/server/auth/provider.mjs" }, "./server/auth/provider": { "types": "./dist/server/auth/provider.d.mts", - "import": "./dist/server/auth/provider.mjs" + "import": "./dist/server/auth/provider.mjs", + "require": "./dist/server/auth/provider.mjs" }, "./server/auth/clients.js": { "types": "./dist/server/auth/clients.d.mts", - "import": "./dist/server/auth/clients.mjs" + "import": "./dist/server/auth/clients.mjs", + "require": "./dist/server/auth/clients.mjs" }, "./server/auth/clients": { "types": "./dist/server/auth/clients.d.mts", - "import": "./dist/server/auth/clients.mjs" + "import": "./dist/server/auth/clients.mjs", + "require": "./dist/server/auth/clients.mjs" }, "./inMemory.js": { "types": "./dist/inMemory.d.mts", - "import": "./dist/inMemory.mjs" + "import": "./dist/inMemory.mjs", + "require": "./dist/inMemory.mjs" }, "./inMemory": { "types": "./dist/inMemory.d.mts", - "import": "./dist/inMemory.mjs" + "import": "./dist/inMemory.mjs", + "require": "./dist/inMemory.mjs" }, "./server/completable.js": { "types": "./dist/server/completable.d.mts", - "import": "./dist/server/completable.mjs" + "import": "./dist/server/completable.mjs", + "require": "./dist/server/completable.mjs" }, "./server/completable": { "types": "./dist/server/completable.d.mts", - "import": "./dist/server/completable.mjs" + "import": "./dist/server/completable.mjs", + "require": "./dist/server/completable.mjs" }, "./server/sse.js": { "types": "./dist/server/sse.d.mts", - "import": "./dist/server/sse.mjs" + "import": "./dist/server/sse.mjs", + "require": "./dist/server/sse.mjs" }, "./server/sse": { "types": "./dist/server/sse.d.mts", - "import": "./dist/server/sse.mjs" + "import": "./dist/server/sse.mjs", + "require": "./dist/server/sse.mjs" }, "./experimental/tasks": { "types": "./dist/experimental/tasks.d.mts", - "import": "./dist/experimental/tasks.mjs" + "import": "./dist/experimental/tasks.mjs", + "require": "./dist/experimental/tasks.mjs" }, "./server": { "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.mjs" }, "./server.js": { "types": "./dist/server/index.d.mts", - "import": "./dist/server/index.mjs" + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.mjs" }, "./client.js": { "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs" + "import": "./dist/client/index.mjs", + "require": "./dist/client/index.mjs" }, "./server/webStandardStreamableHttp.js": { "types": "./dist/server/webStandardStreamableHttp.d.mts", - "import": "./dist/server/webStandardStreamableHttp.mjs" + "import": "./dist/server/webStandardStreamableHttp.mjs", + "require": "./dist/server/webStandardStreamableHttp.mjs" }, "./server/webStandardStreamableHttp": { "types": "./dist/server/webStandardStreamableHttp.d.mts", - "import": "./dist/server/webStandardStreamableHttp.mjs" + "import": "./dist/server/webStandardStreamableHttp.mjs", + "require": "./dist/server/webStandardStreamableHttp.mjs" }, "./shared/stdio.js": { "types": "./dist/shared/stdio.d.mts", - "import": "./dist/shared/stdio.mjs" + "import": "./dist/shared/stdio.mjs", + "require": "./dist/shared/stdio.mjs" }, "./shared/stdio": { "types": "./dist/shared/stdio.d.mts", - "import": "./dist/shared/stdio.mjs" + "import": "./dist/shared/stdio.mjs", + "require": "./dist/shared/stdio.mjs" }, "./validation/types.js": { "types": "./dist/validation/types.d.mts", - "import": "./dist/validation/types.mjs" + "import": "./dist/validation/types.mjs", + "require": "./dist/validation/types.mjs" }, "./validation/types": { "types": "./dist/validation/types.d.mts", - "import": "./dist/validation/types.mjs" + "import": "./dist/validation/types.mjs", + "require": "./dist/validation/types.mjs" }, "./validation/cfworker-provider.js": { "types": "./dist/validation/cfworker-provider.d.mts", - "import": "./dist/validation/cfworker-provider.mjs" + "import": "./dist/validation/cfworker-provider.mjs", + "require": "./dist/validation/cfworker-provider.mjs" }, "./validation/cfworker-provider": { "types": "./dist/validation/cfworker-provider.d.mts", - "import": "./dist/validation/cfworker-provider.mjs" + "import": "./dist/validation/cfworker-provider.mjs", + "require": "./dist/validation/cfworker-provider.mjs" }, "./validation/ajv-provider.js": { "types": "./dist/validation/ajv-provider.d.mts", - "import": "./dist/validation/ajv-provider.mjs" + "import": "./dist/validation/ajv-provider.mjs", + "require": "./dist/validation/ajv-provider.mjs" }, "./validation/ajv-provider": { "types": "./dist/validation/ajv-provider.d.mts", - "import": "./dist/validation/ajv-provider.mjs" + "import": "./dist/validation/ajv-provider.mjs", + "require": "./dist/validation/ajv-provider.mjs" } }, "files": [ From fa9c235a84f2d173c7af51cc2f538873a1c4478f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 12:46:46 +0000 Subject: [PATCH 30/55] fix(packaging): add require condition to server/client/node/express/hono/fastify/auth-legacy exports Jest CJS resolver needs an explicit require condition; without it deep-import subpaths fail to resolve in projects using moduleResolution that consults require. --- packages/client/package.json | 18 ++++++++++++------ packages/middleware/express/package.json | 3 ++- packages/middleware/fastify/package.json | 3 ++- packages/middleware/hono/package.json | 3 ++- packages/middleware/node/package.json | 3 ++- packages/server-auth-legacy/package.json | 3 ++- packages/server/package.json | 21 ++++++++++++++------- 7 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index 5bb25629b..558f8d2dc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,28 +22,34 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" }, "./validators/cf-worker": { "types": "./dist/validators/cfWorker.d.mts", - "import": "./dist/validators/cfWorker.mjs" + "import": "./dist/validators/cfWorker.mjs", + "require": "./dist/validators/cfWorker.mjs" }, "./_shims": { "workerd": { "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" + "import": "./dist/shimsWorkerd.mjs", + "require": "./dist/shimsWorkerd.mjs" }, "browser": { "types": "./dist/shimsBrowser.d.mts", - "import": "./dist/shimsBrowser.mjs" + "import": "./dist/shimsBrowser.mjs", + "require": "./dist/shimsBrowser.mjs" }, "node": { "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" + "import": "./dist/shimsNode.mjs", + "require": "./dist/shimsNode.mjs" }, "default": { "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" + "import": "./dist/shimsNode.mjs", + "require": "./dist/shimsNode.mjs" } } }, diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index c7e5763ae..633570d5c 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -24,7 +24,8 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" } }, "files": [ diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index 0cb8eff24..071fcdab7 100644 --- a/packages/middleware/fastify/package.json +++ b/packages/middleware/fastify/package.json @@ -24,7 +24,8 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" } }, "files": [ diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index 497f2b127..c20e5ddbb 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -24,7 +24,8 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" } }, "files": [ diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index a284ea597..9ea58d700 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -23,7 +23,8 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" } }, "files": [ diff --git a/packages/server-auth-legacy/package.json b/packages/server-auth-legacy/package.json index 0329a06ca..39c505619 100644 --- a/packages/server-auth-legacy/package.json +++ b/packages/server-auth-legacy/package.json @@ -27,7 +27,8 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" } }, "files": [ diff --git a/packages/server/package.json b/packages/server/package.json index 2bc6f8a9a..c8195f459 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,32 +22,39 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "require": "./dist/index.mjs" }, "./zod-schemas": { "types": "./dist/zodSchemas.d.mts", - "import": "./dist/zodSchemas.mjs" + "import": "./dist/zodSchemas.mjs", + "require": "./dist/zodSchemas.mjs" }, "./validators/cf-worker": { "types": "./dist/validators/cfWorker.d.mts", - "import": "./dist/validators/cfWorker.mjs" + "import": "./dist/validators/cfWorker.mjs", + "require": "./dist/validators/cfWorker.mjs" }, "./_shims": { "workerd": { "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" + "import": "./dist/shimsWorkerd.mjs", + "require": "./dist/shimsWorkerd.mjs" }, "browser": { "types": "./dist/shimsWorkerd.d.mts", - "import": "./dist/shimsWorkerd.mjs" + "import": "./dist/shimsWorkerd.mjs", + "require": "./dist/shimsWorkerd.mjs" }, "node": { "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" + "import": "./dist/shimsNode.mjs", + "require": "./dist/shimsNode.mjs" }, "default": { "types": "./dist/shimsNode.d.mts", - "import": "./dist/shimsNode.mjs" + "import": "./dist/shimsNode.mjs", + "require": "./dist/shimsNode.mjs" } } }, From 39b0d575adc24c588a422eb27511d62c6a78df7d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 13:04:45 +0000 Subject: [PATCH 31/55] docs(examples): add helloStateless server + client (no connect on server, handleHttp per request) --- examples/client/src/helloStatelessClient.ts | 20 +++++++++++ examples/server/src/helloStateless.ts | 40 +++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 examples/client/src/helloStatelessClient.ts create mode 100644 examples/server/src/helloStateless.ts diff --git a/examples/client/src/helloStatelessClient.ts b/examples/client/src/helloStatelessClient.ts new file mode 100644 index 000000000..41a3821b4 --- /dev/null +++ b/examples/client/src/helloStatelessClient.ts @@ -0,0 +1,20 @@ +/** + * Client for the stateless hello-world server. + * + * Run: npx tsx examples/client/src/helloStatelessClient.ts + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const client = new Client({ name: 'hello-client', version: '1.0.0' }); +await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3400/mcp'))); + +const { tools } = await client.listTools(); +console.log( + 'Tools:', + tools.map(t => t.name) +); + +const result = await client.callTool({ name: 'greet', arguments: { name: 'world' } }); +console.log('Result:', result.content[0]); + +await client.close(); diff --git a/examples/server/src/helloStateless.ts b/examples/server/src/helloStateless.ts new file mode 100644 index 000000000..f0be60d5e --- /dev/null +++ b/examples/server/src/helloStateless.ts @@ -0,0 +1,40 @@ +/** + * Stateless hello-world MCP server. No connect(), no transport instance — + * one McpServer at module scope, handleHttp() per request. + * + * Run: npx tsx examples/server/src/helloStateless.ts + */ +import { createServer } from 'node:http'; + +import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod/v4'; + +const mcp = new McpServer({ name: 'hello-stateless', version: '1.0.0' }); + +mcp.registerTool( + 'greet', + { description: 'Say hello', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) +); + +createServer(async (req, res) => { + if (req.url !== '/mcp' || req.method !== 'POST') { + res.writeHead(404).end(); + return; + } + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c); + const webReq = new Request(`http://localhost${req.url}`, { + method: 'POST', + headers: req.headers as Record, + body: Buffer.concat(chunks) + }); + + const webRes = await mcp.handleHttp(webReq); + + res.writeHead(webRes.status, Object.fromEntries(webRes.headers)); + if (webRes.body) { + for await (const chunk of webRes.body) res.write(chunk); + } + res.end(); +}).listen(3400, () => console.log('Stateless MCP server on http://localhost:3400/mcp')); From de6e996eedcb62f41506ecf07355962a756ae09b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 13:07:07 +0000 Subject: [PATCH 32/55] docs(examples): simplify helloStateless to Hono (one-line route) --- examples/server/src/helloStateless.ts | 29 +++++++-------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/examples/server/src/helloStateless.ts b/examples/server/src/helloStateless.ts index f0be60d5e..ac725d55f 100644 --- a/examples/server/src/helloStateless.ts +++ b/examples/server/src/helloStateless.ts @@ -4,10 +4,11 @@ * * Run: npx tsx examples/server/src/helloStateless.ts */ -import { createServer } from 'node:http'; +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { z } from 'zod/v4'; import { McpServer } from '@modelcontextprotocol/server'; -import { z } from 'zod/v4'; const mcp = new McpServer({ name: 'hello-stateless', version: '1.0.0' }); @@ -17,24 +18,8 @@ mcp.registerTool( async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) ); -createServer(async (req, res) => { - if (req.url !== '/mcp' || req.method !== 'POST') { - res.writeHead(404).end(); - return; - } - const chunks: Buffer[] = []; - for await (const c of req) chunks.push(c); - const webReq = new Request(`http://localhost${req.url}`, { - method: 'POST', - headers: req.headers as Record, - body: Buffer.concat(chunks) - }); - - const webRes = await mcp.handleHttp(webReq); +const app = new Hono(); +app.post('/mcp', c => mcp.handleHttp(c.req.raw)); - res.writeHead(webRes.status, Object.fromEntries(webRes.headers)); - if (webRes.body) { - for await (const chunk of webRes.body) res.write(chunk); - } - res.end(); -}).listen(3400, () => console.log('Stateless MCP server on http://localhost:3400/mcp')); +serve({ fetch: app.fetch, port: 3400 }); +console.log('Stateless MCP server on http://localhost:3400/mcp'); From 53db0d8411fee8f55efb41d5d4437e031be37fc6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 13:10:02 +0000 Subject: [PATCH 33/55] feat(node): add toNodeHttpHandler helper; docs(examples): Express variant showing both v1-style and new-direct patterns --- examples/server/src/helloStatelessExpress.ts | 39 +++++++++++++++++++ .../middleware/node/src/streamableHttp.ts | 20 ++++++++++ 2 files changed, 59 insertions(+) create mode 100644 examples/server/src/helloStatelessExpress.ts diff --git a/examples/server/src/helloStatelessExpress.ts b/examples/server/src/helloStatelessExpress.ts new file mode 100644 index 000000000..37535638a --- /dev/null +++ b/examples/server/src/helloStatelessExpress.ts @@ -0,0 +1,39 @@ +/** + * Hello-world MCP server (Express). Shown two equivalent ways: + * + * 1. The existing v1/v2 pattern — `connect(transport)` + `transport.handleRequest`. + * Works unchanged in the rebuild. + * 2. The new direct pattern — `mcp.handleHttp(req)` with no transport instance. + * + * Both produce identical wire behavior. Pick one. + * + * Run: npx tsx examples/server/src/helloStatelessExpress.ts + */ +import { randomUUID } from 'node:crypto'; + +import express from 'express'; +import { z } from 'zod/v4'; + +import { NodeStreamableHTTPServerTransport, toNodeHttpHandler } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; + +const mcp = new McpServer({ name: 'hello-express', version: '1.0.0' }); + +mcp.registerTool( + 'greet', + { description: 'Say hello', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) +); + +const app = express(); + +// ─── Way 1: existing v1/v2 pattern (unchanged) ───────────────────────────── +const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); +await mcp.connect(transport); +app.all('/mcp-v1style', express.json(), (req, res) => transport.handleRequest(req, res, req.body)); + +// ─── Way 2: new direct pattern (no connect, no transport instance) ───────── +// Don't pre-parse the body — handleHttp reads it from the raw Request. +app.post('/mcp', toNodeHttpHandler(req => mcp.handleHttp(req))); + +app.listen(3400, () => console.log('Express MCP server on :3400 — /mcp (new) and /mcp-v1style (existing)')); diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index e698037e2..7760f30e4 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -21,6 +21,26 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/ */ export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServerTransportOptions; +/** + * Converts a web-standard `(Request) => Response` handler into a Node.js + * `(IncomingMessage, ServerResponse) => void` handler suitable for Express, + * `http.createServer`, etc. + * + * @example + * ```ts + * const app = express(); + * app.post('/mcp', toNodeHttpHandler(req => mcpServer.handleHttp(req))); + * ``` + */ +export function toNodeHttpHandler( + handler: (req: Request) => Response | Promise +): (req: IncomingMessage, res: ServerResponse) => Promise { + const listener = getRequestListener(handler, { overrideGlobalObjects: false }); + return async (req, res) => { + await listener(req, res); + }; +} + /** * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It supports both SSE streaming and direct HTTP responses. From ef1daf06e381a986a7ba86b67cc3fe11a86930f5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 13:18:46 +0000 Subject: [PATCH 34/55] docs(examples): clarify client example is identical to v1/v2 pattern --- examples/client/src/helloStatelessClient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/client/src/helloStatelessClient.ts b/examples/client/src/helloStatelessClient.ts index 41a3821b4..bb85d0f69 100644 --- a/examples/client/src/helloStatelessClient.ts +++ b/examples/client/src/helloStatelessClient.ts @@ -1,6 +1,9 @@ /** * Client for the stateless hello-world server. * + * This is identical to the v1/v2 client pattern — same classes, same `connect()` call. + * Nothing about the client side changes for users. + * * Run: npx tsx examples/client/src/helloStatelessClient.ts */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; From 2d4bafd13424dbdd6f09d13195888af913696635 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 14:09:59 +0000 Subject: [PATCH 35/55] refactor(client): StreamableHTTPClientTransport implements ClientTransport (request-shaped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-interface: keeps Transport methods (send/start/onmessage/etc) for back-compat, adds fetch/notify/subscribe (ClientTransport). isPipeTransport prefers ClientTransport when fetch present, so client.connect uses the request-shaped path directly. - Auth dedup: 401/403/upscope into one _authedHttpFetch - ClientFetchOptions.onrequest/.onresponse for inbound elicitation + queued task responses - Client owns TaskManager when no _ct.driver (request-shaped path) - _discoverOrInitialize: only accept discover result if serverInfo present - streamableHttpV2.ts deleted (merged in) Conformance client: 289 -> 312/318. Known gap: elicitation-sep1034-client-defaults (6) — server-sent elicitation request on SSE stream not yet round-tripped (onrequest hook present but POST-back wiring incomplete). Tier-4 follow-up. 1687 SDK + 422 integration + tc/lint clean. --- examples/server/src/helloStateless.ts | 11 +- examples/server/src/helloStatelessExpress.ts | 18 +- packages/client/src/client/client.ts | 103 ++- packages/client/src/client/clientTransport.ts | 18 +- packages/client/src/client/streamableHttp.ts | 643 ++++++++++-------- .../client/src/client/streamableHttpV2.ts | 294 -------- packages/client/test/client/client.test.ts | 32 +- 7 files changed, 493 insertions(+), 626 deletions(-) delete mode 100644 packages/client/src/client/streamableHttpV2.ts diff --git a/examples/server/src/helloStateless.ts b/examples/server/src/helloStateless.ts index ac725d55f..d26abfe5e 100644 --- a/examples/server/src/helloStateless.ts +++ b/examples/server/src/helloStateless.ts @@ -5,18 +5,15 @@ * Run: npx tsx examples/server/src/helloStateless.ts */ import { serve } from '@hono/node-server'; +import { McpServer } from '@modelcontextprotocol/server'; import { Hono } from 'hono'; import { z } from 'zod/v4'; -import { McpServer } from '@modelcontextprotocol/server'; - const mcp = new McpServer({ name: 'hello-stateless', version: '1.0.0' }); -mcp.registerTool( - 'greet', - { description: 'Say hello', inputSchema: z.object({ name: z.string() }) }, - async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) -); +mcp.registerTool('greet', { description: 'Say hello', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] +})); const app = new Hono(); app.post('/mcp', c => mcp.handleHttp(c.req.raw)); diff --git a/examples/server/src/helloStatelessExpress.ts b/examples/server/src/helloStatelessExpress.ts index 37535638a..be710bf3f 100644 --- a/examples/server/src/helloStatelessExpress.ts +++ b/examples/server/src/helloStatelessExpress.ts @@ -11,19 +11,16 @@ */ import { randomUUID } from 'node:crypto'; -import express from 'express'; -import { z } from 'zod/v4'; - import { NodeStreamableHTTPServerTransport, toNodeHttpHandler } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; +import express from 'express'; +import { z } from 'zod/v4'; const mcp = new McpServer({ name: 'hello-express', version: '1.0.0' }); -mcp.registerTool( - 'greet', - { description: 'Say hello', inputSchema: z.object({ name: z.string() }) }, - async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) -); +mcp.registerTool('greet', { description: 'Say hello', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] +})); const app = express(); @@ -34,6 +31,9 @@ app.all('/mcp-v1style', express.json(), (req, res) => transport.handleRequest(re // ─── Way 2: new direct pattern (no connect, no transport instance) ───────── // Don't pre-parse the body — handleHttp reads it from the raw Request. -app.post('/mcp', toNodeHttpHandler(req => mcp.handleHttp(req))); +app.post( + '/mcp', + toNodeHttpHandler(req => mcp.handleHttp(req)) +); app.listen(3400, () => console.log('Express MCP server on :3400 — /mcp (new) and /mcp-v1style (existing)')); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b6b0f33a3..952d216e1 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -15,7 +15,9 @@ import type { GetTaskRequest, GetTaskResult, Implementation, + JSONRPCErrorResponse, JSONRPCRequest, + JSONRPCResultResponse, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, @@ -44,7 +46,7 @@ import type { StandardSchemaV1, StreamDriverOptions, SubscribeRequest, - TaskManager, + TaskManagerHost, TaskManagerOptions, Tool, Transport, @@ -75,13 +77,16 @@ import { ListTasksResultSchema, ListToolsResultSchema, mergeCapabilities, + NullTaskManager, parseSchema, ProtocolError, ProtocolErrorCode, ReadResourceResultSchema, + RELATED_TASK_META_KEY, SdkError, SdkErrorCode, - SUPPORTED_PROTOCOL_VERSIONS + SUPPORTED_PROTOCOL_VERSIONS, + TaskManager } from '@modelcontextprotocol/core'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; @@ -226,6 +231,7 @@ export class Client { private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); private _tasksOptions?: TaskManagerOptions; + private _taskManager?: TaskManager; onclose?: () => void; onerror?: (error: Error) => void; @@ -287,14 +293,44 @@ export class Client { return; } this._ct = transport; + this._bindTaskManager(); + const t = transport as { sessionId?: string; setProtocolVersion?: (v: string) => void }; + const setProtocolVersion = (v: string) => t.setProtocolVersion?.(v); + if (t.sessionId !== undefined) { + if (this._negotiatedProtocolVersion) setProtocolVersion(this._negotiatedProtocolVersion); + return; + } try { - await this._discoverOrInitialize(options); + await this._discoverOrInitialize(options, setProtocolVersion); } catch (error) { void this.close(); throw error; } } + /** + * Construct and bind a {@linkcode TaskManager} for the request-shaped {@linkcode ClientTransport} + * path. The pipe-shaped path uses the StreamDriver's TaskManager instead. + */ + private _bindTaskManager(): void { + const tm = this._tasksOptions ? new TaskManager(this._tasksOptions) : new NullTaskManager(); + const host: TaskManagerHost = { + request: (r, schema, opts) => this._request(r, schema, opts), + notification: (n, opts) => this.notification(n, opts), + reportError: e => this.onerror?.(e), + removeProgressHandler: () => {}, + registerHandler: (method, handler) => this._localDispatcher.setRawRequestHandler(method, handler), + sendOnResponseStream: async () => { + throw new SdkError(SdkErrorCode.NotConnected, 'sendOnResponseStream is server-side only'); + }, + enforceStrictCapabilities: this._enforceStrictCapabilities, + assertTaskCapability: () => {}, + assertTaskHandlerCapability: () => {} + }; + tm.bind(host); + this._taskManager = tm; + } + async close(): Promise { const ct = this._ct; this._ct = undefined; @@ -546,12 +582,9 @@ export class Client { * transports have no per-connection task buffer. */ get taskManager(): TaskManager { - const tm = this._ct?.driver?.taskManager; + const tm = this._ct?.driver?.taskManager ?? this._taskManager; if (!tm) { - throw new SdkError( - SdkErrorCode.NotConnected, - 'taskManager is only available when connected via a pipe-shaped Transport (stdio/SSE/InMemory).' - ); + throw new SdkError(SdkErrorCode.NotConnected, 'taskManager is unavailable: call connect() first.'); } return tm; } @@ -628,6 +661,15 @@ export class Client { method: req.method, params: req.params || round > 0 ? { ...req.params, _meta: Object.keys(meta).length > 0 ? meta : undefined } : undefined }; + // Thread task augmentation into request params (mirrors TaskManager.prepareOutboundRequest + // for the request-shaped path; the pipe path threads via StreamDriver.request). + if (options?.task) jr.params = { ...jr.params, task: options.task }; + if (options?.relatedTask) { + jr.params = { + ...jr.params, + _meta: { ...(jr.params?._meta as Record | undefined), [RELATED_TASK_META_KEY]: options.relatedTask } + }; + } const opts: ClientFetchOptions = { signal: options?.signal, timeout: options?.timeout, @@ -639,7 +681,18 @@ export class Client { relatedTask: options?.relatedTask, resumptionToken: options?.resumptionToken, onresumptiontoken: options?.onresumptiontoken, - onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)) + onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)), + onresponse: r => { + const consumed = this.taskManager.processInboundResponse(r, Number(r.id)).consumed; + if (!consumed) this.onerror?.(new Error(`Unmatched response on stream: ${JSON.stringify(r)}`)); + }, + onrequest: async r => { + let resp: JSONRPCResultResponse | JSONRPCErrorResponse | undefined; + for await (const out of this._localDispatcher.dispatch(r)) { + if (out.kind === 'response') resp = out.message; + } + return resp ?? { jsonrpc: '2.0', id: r.id, error: { code: -32_601, message: 'Method not found' } }; + } }; const resp = await this._ct.fetch(jr, opts); if (isJSONRPCErrorResponse(resp)) { @@ -698,7 +751,7 @@ export class Client { } } - private async _discoverOrInitialize(options?: RequestOptions): Promise { + private async _discoverOrInitialize(options: RequestOptions | undefined, setProtocolVersion: (v: string) => void): Promise { // 2026-06: try server/discover, fall back to initialize. Discover schema // is not yet in spec types, so probe and accept the result loosely. try { @@ -707,20 +760,26 @@ export class Client { { timeout: options?.timeout, signal: options?.signal } ); if (!isJSONRPCErrorResponse(resp)) { - const r = resp.result as { capabilities?: ServerCapabilities; serverInfo?: Implementation; instructions?: string }; - this._serverCapabilities = r.capabilities; - this._serverVersion = r.serverInfo; - this._instructions = r.instructions; - return; - } - if (resp.error.code !== ProtocolErrorCode.MethodNotFound) { - throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); + const r = resp.result as { + capabilities?: ServerCapabilities; + serverInfo?: Implementation; + instructions?: string; + protocolVersion?: string; + }; + // Only accept discover if the result is shaped like a real discover response; + // pre-2026-06 servers may return an empty/echo result for unknown methods. + if (r?.serverInfo) { + this._serverCapabilities = r.capabilities; + this._serverVersion = r.serverInfo; + this._instructions = r.instructions; + if (r.protocolVersion) setProtocolVersion(r.protocolVersion); + return; + } } - } catch (error) { - // Surface non-MethodNotFound protocol errors from discover; otherwise fall through to initialize. - if (error instanceof ProtocolError && error.code !== ProtocolErrorCode.MethodNotFound) throw error; + } catch { + // Any error from the discover probe falls through to initialize. } - await this._initializeHandshake(options, () => {}); + await this._initializeHandshake(options, setProtocolVersion); } private _cacheToolMetadata(tools: Tool[]): void { diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index 60e063aea..5e6ec6e9e 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -26,6 +26,18 @@ export type ClientFetchOptions = { onprogress?: (progress: Progress) => void; /** Called for each non-progress notification received before the terminal response. */ onnotification?: (notification: JSONRPCNotification) => void; + /** + * Called for each server-initiated request (elicitation/sampling/roots) received on the + * response stream. Must return the response to send back. If absent, such requests are + * surfaced via {@linkcode onnotification} (best-effort). + */ + onrequest?: (request: JSONRPCRequest) => Promise; + /** + * Called for each JSON-RPC response on the stream whose `id` does NOT match the outbound + * request (e.g. queued task messages delivered via `sendOnResponseStream`). If absent, + * such responses are dropped. + */ + onresponse?: (response: JSONRPCResultResponse | JSONRPCErrorResponse) => void; /** Per-request timeout (ms). */ timeout?: number; /** Reset {@linkcode timeout} when a progress notification arrives. */ @@ -83,9 +95,13 @@ export interface ClientTransport { /** * Type guard distinguishing the legacy pipe-shaped {@linkcode Transport} from - * a request-shaped {@linkcode ClientTransport}. + * a request-shaped {@linkcode ClientTransport}. A transport that implements + * both (e.g. {@linkcode StreamableHTTPClientTransport}) is treated as + * {@linkcode ClientTransport} so {@linkcode Client.connect} uses the + * request-shaped path. */ export function isPipeTransport(t: Transport | ClientTransport): t is Transport { + if (typeof (t as ClientTransport).fetch === 'function') return false; return typeof (t as Transport).start === 'function' && typeof (t as Transport).send === 'function'; } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 6c27d9424..1c5cb59ef 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,10 +1,20 @@ import type { ReadableWritablePair } from 'node:stream/web'; -import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import type { + FetchLike, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + Notification, + Transport +} from '@modelcontextprotocol/core'; import { createFetchWithInit, isInitializedNotification, isJSONRPCErrorResponse, + isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, @@ -16,6 +26,7 @@ import { EventSourceParserStream } from 'eventsource-parser/stream'; import type { AuthProvider, OAuthClientProvider } from './auth.js'; import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js'; +import type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; /** * @deprecated Use {@linkcode SdkError} with {@linkcode SdkErrorCode}. Kept for v1 import compatibility. @@ -178,8 +189,12 @@ export type StreamableHTTPClientTransportOptions = { * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It will connect to a server using HTTP `POST` for sending messages and HTTP `GET` with Server-Sent Events * for receiving messages. + * + * Implements both the request-shaped {@linkcode ClientTransport} (the primary path used by + * {@linkcode Client.connect}) and the legacy pipe-shaped {@linkcode Transport} (deprecated; kept for + * direct callers and v1 compat). */ -export class StreamableHTTPClientTransport implements Transport { +export class StreamableHTTPClientTransport implements ClientTransport, Transport { private _abortController?: AbortController; private _url: URL; private _resourceMetadataUrl?: URL; @@ -197,8 +212,11 @@ export class StreamableHTTPClientTransport implements Transport { private readonly _reconnectionScheduler?: ReconnectionScheduler; private _cancelReconnection?: () => void; + /** @deprecated Pipe-shaped {@linkcode Transport} callback. The {@linkcode ClientTransport} path returns responses directly. */ onclose?: () => void; + /** @deprecated Pipe-shaped {@linkcode Transport} callback. */ onerror?: (error: Error) => void; + /** @deprecated Pipe-shaped {@linkcode Transport} callback. */ onmessage?: (message: JSONRPCMessage) => void; constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) { @@ -220,6 +238,10 @@ export class StreamableHTTPClientTransport implements Transport { this._reconnectionScheduler = opts?.reconnectionScheduler; } + // ─────────────────────────────────────────────────────────────────────── + // Shared internals + // ─────────────────────────────────────────────────────────────────────── + private async _commonHeaders(): Promise { const headers: RequestInit['headers'] & Record = {}; const token = await this._authProvider?.token(); @@ -242,75 +264,88 @@ export class StreamableHTTPClientTransport implements Transport { }); } - private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { - const { resumptionToken } = options; + /** + * Single auth-aware HTTP request. Adds bearer header, captures session id, and + * handles 401 (one retry via {@linkcode AuthProvider.onUnauthorized}) and 403 + * insufficient_scope (upscope via OAuth, with loop guard). Returns the Response + * even when not-ok for status codes other than the handled auth cases. + */ + private async _authedHttpFetch( + build: (headers: Headers) => RequestInit, + opts: { signal?: AbortSignal } = {}, + isAuthRetry = false + ): Promise { + const headers = await this._commonHeaders(); + const init = { ...this._requestInit, ...build(headers), signal: opts.signal ?? this._abortController?.signal }; + const response = await (this._fetch ?? fetch)(this._url, init); + + const sessionId = response.headers?.get('mcp-session-id'); + if (sessionId) { + this._sessionId = sessionId; + } + if (response.ok) { + this._lastUpscopingHeader = undefined; + return response; + } - try { - // Try to open an initial SSE stream with GET to listen for server messages - // This is optional according to the spec - server may not support it - const headers = await this._commonHeaders(); - const userAccept = headers.get('accept'); - const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'text/event-stream']; - headers.set('accept', [...new Set(types)].join(', ')); - - // Include Last-Event-ID header for resumable streams if provided - if (resumptionToken) { - headers.set('last-event-id', resumptionToken); + if (response.status === 401 && this._authProvider) { + if (response.headers.has('www-authenticate')) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; } - - const response = await (this._fetch ?? fetch)(this._url, { - ...this._requestInit, - method: 'GET', - headers, - signal: this._abortController?.signal - }); - - if (!response.ok) { - if (response.status === 401 && this._authProvider) { - if (response.headers.has('www-authenticate')) { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; - } - - if (this._authProvider.onUnauthorized && !isAuthRetry) { - await this._authProvider.onUnauthorized({ - response, - serverUrl: this._url, - fetchFn: this._fetchWithInit - }); - await response.text?.().catch(() => {}); - // Purposely _not_ awaited, so we don't call onerror twice - return this._startOrAuthSse(options, true); - } - await response.text?.().catch(() => {}); - if (isAuthRetry) { - throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { - status: 401 - }); - } - throw new UnauthorizedError(); - } - + if (this._authProvider.onUnauthorized && !isAuthRetry) { + await this._authProvider.onUnauthorized({ + response, + serverUrl: this._url, + fetchFn: this._fetchWithInit + }); await response.text?.().catch(() => {}); + return this._authedHttpFetch(build, opts, true); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) { + throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { status: 401 }); + } + throw new UnauthorizedError(); + } - // 405 indicates that the server does not offer an SSE stream at GET endpoint - // This is an expected case that should not trigger an error - if (response.status === 405) { - return; + if (response.status === 403 && this._oauthProvider) { + const text = await response.text?.().catch(() => null); + const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); + if (error === 'insufficient_scope') { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (this._lastUpscopingHeader === wwwAuthHeader) { + throw new SdkError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { + status: 403, + text + }); } - - throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { - status: response.status, - statusText: response.statusText + if (scope) this._scope = scope; + if (resourceMetadataUrl) this._resourceMetadataUrl = resourceMetadataUrl; + this._lastUpscopingHeader = wwwAuthHeader ?? undefined; + const result = await auth(this._oauthProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + return this._authedHttpFetch(build, opts, isAuthRetry); } - - this._handleSseStream(response.body, options, true); - } catch (error) { - this.onerror?.(error as Error); - throw error; + // Re-wrap consumed-body 403 so caller's `await response.text()` doesn't blow up. + return new Response(text, { status: 403, headers: response.headers }); } + + return response; + } + + private _setAccept(headers: Headers, ...required: string[]): void { + const userAccept = headers.get('accept'); + const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), ...required]; + headers.set('accept', [...new Set(types)].join(', ')); } /** @@ -320,39 +355,267 @@ export class StreamableHTTPClientTransport implements Transport { * @returns Time to wait in milliseconds before next reconnection attempt */ private _getNextReconnectionDelay(attempt: number): number { - // Use server-provided retry value if available - if (this._serverRetryMs !== undefined) { - return this._serverRetryMs; - } - - // Fall back to exponential backoff + if (this._serverRetryMs !== undefined) return this._serverRetryMs; const initialDelay = this._reconnectionOptions.initialReconnectionDelay; const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor; const maxDelay = this._reconnectionOptions.maxReconnectionDelay; - - // Cap at maximum delay return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay); } + private _sseReader(stream: ReadableStream) { + return stream + .pipeThrough(new TextDecoderStream() as ReadableWritablePair) + .pipeThrough(new EventSourceParserStream({ onRetry: ms => (this._serverRetryMs = ms) })) + .getReader(); + } + + private _linkSignal(a: AbortSignal | undefined): AbortSignal | undefined { + const b = this._abortController?.signal; + if (!a) return b; + if (!b) return a; + if (typeof (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any === 'function') { + return (AbortSignal as unknown as { any: (s: AbortSignal[]) => AbortSignal }).any([a, b]); + } + const c = new AbortController(); + const wire = (s: AbortSignal) => + s.aborted ? c.abort(s.reason) : s.addEventListener('abort', () => c.abort(s.reason), { once: true }); + wire(a); + wire(b); + return c.signal; + } + + // ─────────────────────────────────────────────────────────────────────── + // ClientTransport (request-shaped) — primary path + // ─────────────────────────────────────────────────────────────────────── + + /** + * Send one JSON-RPC request and resolve with the terminal response. Progress and other + * notifications received before the response are surfaced via {@linkcode ClientFetchOptions}. + */ + async fetch(request: JSONRPCRequest, opts: ClientFetchOptions = {}): Promise { + this._abortController ??= new AbortController(); + return this._fetchOnce(request, opts, opts.resumptionToken, 0); + } + + private async _fetchOnce( + request: JSONRPCRequest, + opts: ClientFetchOptions, + lastEventId: string | undefined, + attempt: number + ): Promise { + const signal = this._linkSignal(opts.signal); + const isResume = lastEventId !== undefined; + const res = await this._authedHttpFetch( + headers => { + if (isResume) { + this._setAccept(headers, 'text/event-stream'); + headers.set('last-event-id', lastEventId); + return { method: 'GET', headers }; + } + headers.set('content-type', 'application/json'); + this._setAccept(headers, 'application/json', 'text/event-stream'); + return { method: 'POST', headers, body: JSON.stringify(request) }; + }, + { signal } + ); + + if (!res.ok) { + const text = await res.text?.().catch(() => null); + throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint (HTTP ${res.status}): ${text}`, { + status: res.status, + text + }); + } + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('text/event-stream')) { + return this._readSseToTerminal(res, request, opts, attempt); + } + if (ct.includes('application/json')) { + const data = await res.json(); + const messages = Array.isArray(data) ? data : [data]; + let terminal: JSONRPCResultResponse | JSONRPCErrorResponse | undefined; + for (const m of messages) { + const msg = JSONRPCMessageSchema.parse(m); + if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) terminal = msg; + else if (isJSONRPCNotification(msg)) this._routeFetchNotification(msg, opts); + } + if (!terminal) { + throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'JSON response contained no terminal response'); + } + return terminal; + } + await res.text?.().catch(() => {}); + throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${ct}`, { contentType: ct }); + } + + private async _readSseToTerminal( + res: Response, + request: JSONRPCRequest, + opts: ClientFetchOptions, + attempt: number + ): Promise { + if (!res.body) throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'SSE response has no body'); + let lastEventId: string | undefined; + let primed = false; + const reader = this._sseReader(res.body); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value.id) { + lastEventId = value.id; + primed = true; + opts.onresumptiontoken?.(value.id); + } + if (!value.data) continue; + if (value.event && value.event !== 'message') continue; + const msg = JSONRPCMessageSchema.parse(JSON.parse(value.data)); + if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) { + if (msg.id === request.id) return msg; + opts.onresponse?.(msg); + continue; + } + if (isJSONRPCNotification(msg)) { + this._routeFetchNotification(msg, opts); + } else if (isJSONRPCRequest(msg)) { + void this._serviceInboundRequest(msg, opts); + } + } + } catch { + // fallthrough to resume below + } finally { + try { + reader.releaseLock(); + } catch { + /* noop */ + } + } + if (primed && attempt < this._reconnectionOptions.maxRetries && !this._abortController?.signal.aborted && !opts.signal?.aborted) { + await new Promise(r => setTimeout(r, this._getNextReconnectionDelay(attempt))); + return this._fetchOnce(request, opts, lastEventId, attempt + 1); + } + throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, 'SSE stream ended without a terminal response'); + } + + /** Handle a server-initiated request received on the SSE response stream and POST the reply back. */ + private async _serviceInboundRequest(inbound: JSONRPCRequest, opts: ClientFetchOptions): Promise { + if (!opts.onrequest) { + opts.onnotification?.(inbound as unknown as JSONRPCNotification); + return; + } + let response: JSONRPCResultResponse | JSONRPCErrorResponse; + try { + response = await opts.onrequest(inbound); + } catch (error) { + response = { + jsonrpc: '2.0', + id: inbound.id, + error: { code: -32_603, message: error instanceof Error ? error.message : String(error) } + }; + } + try { + const r = await this._authedHttpFetch(headers => { + headers.set('content-type', 'application/json'); + this._setAccept(headers, 'application/json', 'text/event-stream'); + return { method: 'POST', headers, body: JSON.stringify(response) }; + }); + await r.text?.().catch(() => {}); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } + + private _routeFetchNotification(msg: JSONRPCNotification, opts: ClientFetchOptions): void { + if (msg.method === 'notifications/progress' && opts.onprogress) { + const { progressToken: _t, ...progress } = (msg.params ?? {}) as Record; + void _t; + opts.onprogress(progress as never); + return; + } + opts.onnotification?.(msg); + } + + /** Send a fire-and-forget JSON-RPC notification. */ + async notify(n: Notification): Promise { + this._abortController ??= new AbortController(); + const res = await this._authedHttpFetch(headers => { + headers.set('content-type', 'application/json'); + this._setAccept(headers, 'application/json', 'text/event-stream'); + return { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', method: n.method, params: n.params }) }; + }); + await res.text?.().catch(() => {}); + if (!res.ok && res.status !== 202) { + throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Notification POST failed: ${res.status}`, { status: res.status }); + } + } + + /** + * Open the standalone GET SSE stream and yield server-initiated notifications. + * Best-effort: if the server replies 405 (no SSE GET), the iterable completes immediately. + */ + async *subscribe(): AsyncIterable { + this._abortController ??= new AbortController(); + const res = await this._authedHttpFetch(headers => { + this._setAccept(headers, 'text/event-stream'); + return { method: 'GET', headers }; + }); + if (res.status === 405 || !res.ok || !res.body) { + await res.text?.().catch(() => {}); + return; + } + const reader = this._sseReader(res.body); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) return; + if (!value.data) continue; + const msg = JSONRPCMessageSchema.parse(JSON.parse(value.data)); + if (isJSONRPCNotification(msg)) yield msg; + } + } finally { + reader.releaseLock(); + } + } + + // ─────────────────────────────────────────────────────────────────────── + // Transport (pipe-shaped) — deprecated compat surface + // ─────────────────────────────────────────────────────────────────────── + + private async _startOrAuthSse(options: StartSSEOptions): Promise { + const { resumptionToken } = options; + try { + const response = await this._authedHttpFetch(headers => { + this._setAccept(headers, 'text/event-stream'); + if (resumptionToken) headers.set('last-event-id', resumptionToken); + return { method: 'GET', headers }; + }); + + if (!response.ok) { + await response.text?.().catch(() => {}); + // 405 indicates that the server does not offer an SSE stream at GET endpoint + if (response.status === 405) return; + throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { + status: response.status, + statusText: response.statusText + }); + } + this._handleSseStream(response.body, options, true); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } + /** * Schedule a reconnection attempt using server-provided retry interval or backoff - * - * @param lastEventId The ID of the last received event for resumability - * @param attemptCount Current reconnection attempt count for this specific stream */ private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void { - // Use provided options or default options const maxRetries = this._reconnectionOptions.maxRetries; - - // Check if we've exceeded maximum retry attempts if (attemptCount >= maxRetries) { this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); return; } - - // Calculate next delay based on current attempt count const delay = this._getNextReconnectionDelay(attemptCount); - const reconnect = (): void => { this._cancelReconnection = undefined; if (this._abortController?.signal.aborted) return; @@ -365,7 +628,6 @@ export class StreamableHTTPClientTransport implements Transport { } }); }; - if (this._reconnectionScheduler) { const cancel = this._reconnectionScheduler(reconnect, delay, attemptCount); this._cancelReconnection = typeof cancel === 'function' ? cancel : undefined; @@ -376,60 +638,28 @@ export class StreamableHTTPClientTransport implements Transport { } private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions, isReconnectable: boolean): void { - if (!stream) { - return; - } + if (!stream) return; const { onresumptiontoken, replayMessageId } = options; let lastEventId: string | undefined; - // Track whether we've received a priming event (event with ID) - // Per spec, server SHOULD send a priming event with ID before closing let hasPrimingEvent = false; - // Track whether we've received a response - if so, no need to reconnect - // Reconnection is for when server disconnects BEFORE sending response let receivedResponse = false; const processStream = async () => { - // this is the closest we can get to trying to catch network errors - // if something happens reader will throw try { - // Create a pipeline: binary stream -> text decoder -> SSE parser - const reader = stream - .pipeThrough(new TextDecoderStream() as ReadableWritablePair) - .pipeThrough( - new EventSourceParserStream({ - onRetry: (retryMs: number) => { - // Capture server-provided retry value for reconnection timing - this._serverRetryMs = retryMs; - } - }) - ) - .getReader(); - + const reader = this._sseReader(stream); while (true) { const { value: event, done } = await reader.read(); - if (done) { - break; - } - - // Update last event ID if provided + if (done) break; if (event.id) { lastEventId = event.id; - // Mark that we've received a priming event - stream is now resumable hasPrimingEvent = true; onresumptiontoken?.(event.id); } - - // Skip events with no data (priming events, keep-alives) - if (!event.data) { - continue; - } - + if (!event.data) continue; if (!event.event || event.event === 'message') { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - // Handle both success AND error responses for completion detection and ID remapping if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - // Mark that we received a response - no need to reconnect for this request receivedResponse = true; if (replayMessageId !== undefined) { message.id = replayMessageId; @@ -441,43 +671,18 @@ export class StreamableHTTPClientTransport implements Transport { } } } - - // Handle graceful server-side disconnect - // Server may close connection after sending event ID and retry field - // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) - // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { - this._scheduleReconnection( - { - resumptionToken: lastEventId, - onresumptiontoken, - replayMessageId - }, - 0 - ); + this._scheduleReconnection({ resumptionToken: lastEventId, onresumptiontoken, replayMessageId }, 0); } } catch (error) { - // Handle stream errors - likely a network disconnect this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); - - // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing - // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) - // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { - // Use the exponential backoff reconnection strategy try { - this._scheduleReconnection( - { - resumptionToken: lastEventId, - onresumptiontoken, - replayMessageId - }, - 0 - ); + this._scheduleReconnection({ resumptionToken: lastEventId, onresumptiontoken, replayMessageId }, 0); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); } @@ -487,13 +692,13 @@ export class StreamableHTTPClientTransport implements Transport { processStream(); } + /** @deprecated Part of the pipe-shaped {@linkcode Transport} interface. {@linkcode Client.connect} uses the request-shaped path. */ async start() { if (this._abortController) { throw new Error( 'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.' ); } - this._abortController = new AbortController(); } @@ -504,7 +709,6 @@ export class StreamableHTTPClientTransport implements Transport { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } - const result = await auth(this._oauthProvider, { serverUrl: this._url, authorizationCode, @@ -527,161 +731,55 @@ export class StreamableHTTPClientTransport implements Transport { } } + /** @deprecated Part of the pipe-shaped {@linkcode Transport} interface. Use {@linkcode fetch} / {@linkcode notify}. */ async send( message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } - ): Promise { - return this._send(message, options, false); - } - - private async _send( - message: JSONRPCMessage | JSONRPCMessage[], - options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } | undefined, - isAuthRetry: boolean ): Promise { try { const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { - // If we have a last event ID, we need to reconnect the SSE stream this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch( error => this.onerror?.(error) ); return; } - const headers = await this._commonHeaders(); - headers.set('content-type', 'application/json'); - const userAccept = headers.get('accept'); - const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; - headers.set('accept', [...new Set(types)].join(', ')); - - const init = { - ...this._requestInit, - method: 'POST', - headers, - body: JSON.stringify(message), - signal: this._abortController?.signal - }; - - const response = await (this._fetch ?? fetch)(this._url, init); - - // Handle session ID received during initialization - const sessionId = response.headers.get('mcp-session-id'); - if (sessionId) { - this._sessionId = sessionId; - } + const response = await this._authedHttpFetch(headers => { + headers.set('content-type', 'application/json'); + this._setAccept(headers, 'application/json', 'text/event-stream'); + return { method: 'POST', headers, body: JSON.stringify(message) }; + }); if (!response.ok) { - if (response.status === 401 && this._authProvider) { - // Store WWW-Authenticate params for interactive finishAuth() path - if (response.headers.has('www-authenticate')) { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; - } - - if (this._authProvider.onUnauthorized && !isAuthRetry) { - await this._authProvider.onUnauthorized({ - response, - serverUrl: this._url, - fetchFn: this._fetchWithInit - }); - await response.text?.().catch(() => {}); - // Purposely _not_ awaited, so we don't call onerror twice - return this._send(message, options, true); - } - await response.text?.().catch(() => {}); - if (isAuthRetry) { - throw new SdkError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { - status: 401 - }); - } - throw new UnauthorizedError(); - } - const text = await response.text?.().catch(() => null); - - if (response.status === 403 && this._oauthProvider) { - const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); - - if (error === 'insufficient_scope') { - const wwwAuthHeader = response.headers.get('WWW-Authenticate'); - - // Check if we've already tried upscoping with this header to prevent infinite loops. - if (this._lastUpscopingHeader === wwwAuthHeader) { - throw new SdkError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { - status: 403, - text - }); - } - - if (scope) { - this._scope = scope; - } - - if (resourceMetadataUrl) { - this._resourceMetadataUrl = resourceMetadataUrl; - } - - // Mark that upscoping was tried. - this._lastUpscopingHeader = wwwAuthHeader ?? undefined; - const result = await auth(this._oauthProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetchWithInit - }); - - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } - - return this._send(message, options, isAuthRetry); - } - } - throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { status: response.status, text }); } - this._lastUpscopingHeader = undefined; - - // If the response is 202 Accepted, there's no body to process if (response.status === 202) { await response.text?.().catch(() => {}); - // if the accepted notification is initialized, we start the SSE stream - // if it's supported by the server if (isInitializedNotification(message)) { - // Start without a lastEventId since this is a fresh connection this._startOrAuthSse({ resumptionToken: undefined }).catch(error => this.onerror?.(error)); } return; } - // Get original message(s) for detecting request IDs const messages = Array.isArray(message) ? message : [message]; - const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg && msg.id !== undefined); - - // Check the response type const contentType = response.headers.get('content-type'); if (hasRequests) { if (contentType?.includes('text/event-stream')) { - // Handle SSE stream responses for requests - // We use the same handler as standalone streams, which now supports - // reconnection with the last event ID this._handleSseStream(response.body, { onresumptiontoken }, false); } else if (contentType?.includes('application/json')) { - // For non-streaming servers, we might get direct JSON responses const data = await response.json(); const responseMessages = Array.isArray(data) ? data.map(msg => JSONRPCMessageSchema.parse(msg)) : [JSONRPCMessageSchema.parse(data)]; - for (const msg of responseMessages) { this.onmessage?.(msg); } @@ -692,7 +790,6 @@ export class StreamableHTTPClientTransport implements Transport { }); } } else { - // No requests in message but got 200 OK - still need to release connection await response.text?.().catch(() => {}); } } catch (error) { @@ -717,32 +814,16 @@ export class StreamableHTTPClientTransport implements Transport { * the server does not allow clients to terminate sessions. */ async terminateSession(): Promise { - if (!this._sessionId) { - return; // No session to terminate - } - + if (!this._sessionId) return; try { - const headers = await this._commonHeaders(); - - const init = { - ...this._requestInit, - method: 'DELETE', - headers, - signal: this._abortController?.signal - }; - - const response = await (this._fetch ?? fetch)(this._url, init); + const response = await this._authedHttpFetch(headers => ({ method: 'DELETE', headers })); await response.text?.().catch(() => {}); - - // We specifically handle 405 as a valid response according to the spec, - // meaning the server does not support explicit session termination if (!response.ok && response.status !== 405) { throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, { status: response.status, statusText: response.statusText }); } - this._sessionId = undefined; } catch (error) { this.onerror?.(error as Error); @@ -761,13 +842,9 @@ export class StreamableHTTPClientTransport implements Transport { * Resume an SSE stream from a previous event ID. * Opens a `GET` SSE connection with `Last-Event-ID` header to replay missed events. * - * @param lastEventId The event ID to resume from - * @param options Optional callback to receive new resumption tokens + * @deprecated Part of the pipe-shaped {@linkcode Transport} surface; messages surface via {@linkcode onmessage}. */ async resumeStream(lastEventId: string, options?: { onresumptiontoken?: (token: string) => void }): Promise { - await this._startOrAuthSse({ - resumptionToken: lastEventId, - onresumptiontoken: options?.onresumptiontoken - }); + await this._startOrAuthSse({ resumptionToken: lastEventId, onresumptiontoken: options?.onresumptiontoken }); } } diff --git a/packages/client/src/client/streamableHttpV2.ts b/packages/client/src/client/streamableHttpV2.ts deleted file mode 100644 index 7e241c844..000000000 --- a/packages/client/src/client/streamableHttpV2.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { - FetchLike, - JSONRPCErrorResponse, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResultResponse -} from '@modelcontextprotocol/core'; -import { - isJSONRPCErrorResponse, - isJSONRPCNotification, - isJSONRPCResultResponse, - JSONRPCMessageSchema, - normalizeHeaders, - SdkError, - SdkErrorCode -} from '@modelcontextprotocol/core'; -import { EventSourceParserStream } from 'eventsource-parser/stream'; - -import type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; - -export interface StreamableHttpReconnectionOptions { - initialReconnectionDelay: number; - maxReconnectionDelay: number; - reconnectionDelayGrowFactor: number; - maxRetries: number; -} - -const DEFAULT_RECONNECT: StreamableHttpReconnectionOptions = { - initialReconnectionDelay: 1000, - maxReconnectionDelay: 30_000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2 -}; - -export type StreamableHttpClientTransportV2Options = { - /** - * Custom `fetch`. Auth composes here via `withOAuth(fetch)` middleware - * instead of being baked into the transport. - */ - fetch?: FetchLike; - /** Extra headers/init merged into every request. */ - requestInit?: RequestInit; - /** Reconnection backoff for resumable SSE responses. */ - reconnectionOptions?: StreamableHttpReconnectionOptions; - /** - * Seed session id for reconnecting to an existing session - * (2025-11 stateful servers). - */ - sessionId?: string; - /** Seed protocol version header for reconnect-without-init. */ - protocolVersion?: string; -}; - -/** - * Request-shaped Streamable HTTP client transport (Proposal 9). One POST per - * {@linkcode fetch}; the response body may be JSON or an SSE stream. Progress - * and other notifications are surfaced via {@linkcode ClientFetchOptions} - * callbacks; the returned promise resolves with the terminal response. - * - * Auth retry is intentionally not implemented here. Compose via - * `withOAuth(fetch)` and pass as {@linkcode StreamableHttpClientTransportV2Options.fetch}. - * - * The transport is stateful internally for 2025-11 compat: it captures - * `mcp-session-id` from response headers and echoes it on subsequent requests. - * That state is private; nothing on the {@linkcode ClientTransport} contract - * exposes it. - */ -export class StreamableHttpClientTransportV2 implements ClientTransport { - private _fetch: FetchLike; - private _requestInit?: RequestInit; - private _sessionId?: string; - private _protocolVersion?: string; - private _reconnect: StreamableHttpReconnectionOptions; - private _abort = new AbortController(); - private _serverRetryMs?: number; - - constructor( - private _url: URL, - opts: StreamableHttpClientTransportV2Options = {} - ) { - this._fetch = opts.fetch ?? fetch; - this._requestInit = opts.requestInit; - this._sessionId = opts.sessionId; - this._protocolVersion = opts.protocolVersion; - this._reconnect = opts.reconnectionOptions ?? DEFAULT_RECONNECT; - } - - get sessionId(): string | undefined { - return this._sessionId; - } - setProtocolVersion(v: string): void { - this._protocolVersion = v; - } - - async fetch(request: JSONRPCRequest, opts: ClientFetchOptions = {}): Promise { - return this._fetchOnce(request, opts, undefined, 0); - } - - async notify(n: { method: string; params?: unknown }): Promise { - const headers = this._headers(); - headers.set('content-type', 'application/json'); - headers.set('accept', 'application/json, text/event-stream'); - const res = await this._fetch(this._url, { - ...this._requestInit, - method: 'POST', - headers, - body: JSON.stringify({ jsonrpc: '2.0', method: n.method, params: n.params }), - signal: this._abort.signal - }); - const sid = res.headers.get('mcp-session-id'); - if (sid) this._sessionId = sid; - await res.text?.().catch(() => {}); - if (!res.ok && res.status !== 202) { - throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Notification POST failed: ${res.status}`, { status: res.status }); - } - } - - async *subscribe(): AsyncIterable { - // 2026-06 messages/listen replaces the standalone GET stream. For now, - // open a GET SSE for 2025-11 compat. Best-effort: 405 means unsupported. - const headers = this._headers(); - headers.set('accept', 'text/event-stream'); - const res = await this._fetch(this._url, { ...this._requestInit, method: 'GET', headers, signal: this._abort.signal }); - if (res.status === 405 || !res.ok || !res.body) { - await res.text?.().catch(() => {}); - return; - } - const reader = res.body - .pipeThrough(new TextDecoderStream() as unknown as ReadableWritablePair) - .pipeThrough(new EventSourceParserStream({ onRetry: ms => (this._serverRetryMs = ms) })) - .getReader(); - try { - while (true) { - const { value, done } = await reader.read(); - if (done) return; - if (!value.data) continue; - const msg = JSONRPCMessageSchema.parse(JSON.parse(value.data)); - if (isJSONRPCNotification(msg)) yield msg; - } - } finally { - reader.releaseLock(); - } - } - - async close(): Promise { - this._abort.abort(); - } - - /** Explicitly terminate a 2025-11 session via DELETE. */ - async terminateSession(): Promise { - if (!this._sessionId) return; - const headers = this._headers(); - const res = await this._fetch(this._url, { ...this._requestInit, method: 'DELETE', headers, signal: this._abort.signal }); - await res.text?.().catch(() => {}); - if (!res.ok && res.status !== 405) { - throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${res.statusText}`, { - status: res.status - }); - } - this._sessionId = undefined; - } - - private _headers(): Headers { - const h: Record = {}; - if (this._sessionId) h['mcp-session-id'] = this._sessionId; - if (this._protocolVersion) h['mcp-protocol-version'] = this._protocolVersion; - return new Headers({ ...h, ...normalizeHeaders(this._requestInit?.headers) }); - } - - private _delay(attempt: number): number { - if (this._serverRetryMs !== undefined) return this._serverRetryMs; - const { initialReconnectionDelay: i, reconnectionDelayGrowFactor: g, maxReconnectionDelay: m } = this._reconnect; - return Math.min(i * Math.pow(g, attempt), m); - } - - private _link(a: AbortSignal | undefined, b: AbortSignal): AbortSignal { - if (!a) return b; - if (typeof (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any === 'function') { - return (AbortSignal as unknown as { any: (s: AbortSignal[]) => AbortSignal }).any([a, b]); - } - const c = new AbortController(); - const onA = () => c.abort(a.reason); - const onB = () => c.abort(b.reason); - if (a.aborted) c.abort(a.reason); - else a.addEventListener('abort', onA, { once: true }); - if (b.aborted) c.abort(b.reason); - else b.addEventListener('abort', onB, { once: true }); - return c.signal; - } - - private async _fetchOnce( - request: JSONRPCRequest, - opts: ClientFetchOptions, - lastEventId: string | undefined, - attempt: number - ): Promise { - const headers = this._headers(); - headers.set('content-type', 'application/json'); - headers.set('accept', 'application/json, text/event-stream'); - if (lastEventId) headers.set('last-event-id', lastEventId); - const signal = this._link(opts.signal, this._abort.signal); - const isResume = lastEventId !== undefined; - const init: RequestInit = isResume - ? { ...this._requestInit, method: 'GET', headers, signal } - : { ...this._requestInit, method: 'POST', headers, body: JSON.stringify(request), signal }; - const res = await this._fetch(this._url, init); - const sid = res.headers.get('mcp-session-id'); - if (sid) this._sessionId = sid; - if (!res.ok) { - const text = await res.text?.().catch(() => null); - throw new SdkError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint (HTTP ${res.status}): ${text}`, { - status: res.status, - text - }); - } - const ct = res.headers.get('content-type') ?? ''; - if (ct.includes('text/event-stream')) { - return this._readSse(res, request, opts, attempt); - } - if (ct.includes('application/json')) { - const data = await res.json(); - const messages = Array.isArray(data) ? data : [data]; - let terminal: JSONRPCResultResponse | JSONRPCErrorResponse | undefined; - for (const m of messages) { - const msg = JSONRPCMessageSchema.parse(m); - if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) terminal = msg; - else if (isJSONRPCNotification(msg)) this._routeNotification(msg, opts); - } - if (!terminal) { - throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'JSON response contained no terminal response'); - } - return terminal; - } - await res.text?.().catch(() => {}); - throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${ct}`, { contentType: ct }); - } - - private async _readSse( - res: Response, - request: JSONRPCRequest, - opts: ClientFetchOptions, - attempt: number - ): Promise { - if (!res.body) throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'SSE response has no body'); - let lastEventId: string | undefined; - let primed = false; - const reader = res.body - .pipeThrough(new TextDecoderStream() as unknown as ReadableWritablePair) - .pipeThrough(new EventSourceParserStream({ onRetry: ms => (this._serverRetryMs = ms) })) - .getReader(); - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - if (value.id) { - lastEventId = value.id; - primed = true; - } - if (!value.data) continue; - if (value.event && value.event !== 'message') continue; - const msg = JSONRPCMessageSchema.parse(JSON.parse(value.data)); - if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) { - return msg; - } - if (isJSONRPCNotification(msg)) this._routeNotification(msg, opts); - } - } catch { - // fallthrough to resume below - } finally { - try { - reader.releaseLock(); - } catch { - /* noop */ - } - } - if (primed && attempt < this._reconnect.maxRetries && !this._abort.signal.aborted && !opts.signal?.aborted) { - await new Promise(r => setTimeout(r, this._delay(attempt))); - return this._fetchOnce(request, opts, lastEventId, attempt + 1); - } - throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, 'SSE stream ended without a terminal response'); - } - - private _routeNotification(msg: JSONRPCNotification, opts: ClientFetchOptions): void { - if (msg.method === 'notifications/progress' && opts.onprogress) { - const { progressToken: _progressToken, ...progress } = (msg.params ?? {}) as Record; - void _progressToken; - opts.onprogress(progress as never); - return; - } - opts.onnotification?.(msg); - } -} - -type ReadableWritablePair = { readable: ReadableStream; writable: WritableStream }; diff --git a/packages/client/test/client/client.test.ts b/packages/client/test/client/client.test.ts index 95179bcf5..ddb68b1d2 100644 --- a/packages/client/test/client/client.test.ts +++ b/packages/client/test/client/client.test.ts @@ -90,7 +90,8 @@ describe('Client (V2)', () => { describe('typed RPC sugar', () => { async function connected(handler: (req: JSONRPCRequest, opts?: ClientFetchOptions) => FetchResp | Promise) { const m = mockTransport((req, opts) => { - if (req.method === 'server/discover') return ok(req.id, { capabilities: { tools: {}, prompts: {}, resources: {} } }); + if (req.method === 'server/discover') + return ok(req.id, { capabilities: { tools: {}, prompts: {}, resources: {} }, serverInfo: { name: 's', version: '1' } }); return handler(req, opts); }); const c = new Client({ name: 'c', version: '1' }); @@ -133,7 +134,9 @@ describe('Client (V2)', () => { it('list* return empty when capability missing and not strict', async () => { const { ct } = mockTransport(r => - r.method === 'server/discover' ? ok(r.id, { capabilities: {} }) : err(r.id, -32601, 'nope') + r.method === 'server/discover' + ? ok(r.id, { capabilities: {}, serverInfo: { name: 's', version: '1' } }) + : err(r.id, -32601, 'nope') ); const c = new Client({ name: 'c', version: '1' }); await c.connect(ct); @@ -166,7 +169,8 @@ describe('Client (V2)', () => { params: { message: 'q', requestedSchema: { type: 'object', properties: {} } } }; const { ct, sent } = mockTransport(r => { - if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'server/discover') + return ok(r.id, { capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } }); if (r.method === 'tools/call') { round++; if (round === 1) return ok(r.id, { ResultType: 'input_required', InputRequests: { ask: elicitArgs } }); @@ -188,7 +192,8 @@ describe('Client (V2)', () => { it('throws if no handler is registered for an InputRequest method', async () => { const { ct } = mockTransport(r => { - if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'server/discover') + return ok(r.id, { capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } }); if (r.method === 'tools/call') { return ok(r.id, { ResultType: 'input_required', InputRequests: { s: { method: 'sampling/createMessage' } } }); } @@ -201,7 +206,8 @@ describe('Client (V2)', () => { it('caps rounds at mrtrMaxRounds', async () => { const { ct } = mockTransport(r => { - if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'server/discover') + return ok(r.id, { capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } }); return ok(r.id, { ResultType: 'input_required', InputRequests: { p: { method: 'ping' } } }); }); const c = new Client({ name: 'c', version: '1' }, { mrtrMaxRounds: 3 }); @@ -253,7 +259,8 @@ describe('Client (V2)', () => { c.setRequestHandler('roots/list', handler); // Exercise via MRTR path: const { ct } = mockTransport(r => { - if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'server/discover') + return ok(r.id, { capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } }); if (r.method === 'tools/call') { return ok(r.id, { ResultType: 'input_required', InputRequests: { r: { method: 'roots/list' } } }); } @@ -272,7 +279,8 @@ describe('Client (V2)', () => { it('routes per-request notifications from transport to local notification handlers', async () => { const got: JSONRPCNotification[] = []; const { ct } = mockTransport(async (r, opts) => { - if (r.method === 'server/discover') return ok(r.id, { capabilities: { tools: {} } }); + if (r.method === 'server/discover') + return ok(r.id, { capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } }); opts?.onnotification?.({ jsonrpc: '2.0', method: 'notifications/message', params: { level: 'info', data: 'x' } }); return ok(r.id, { content: [] }); }); @@ -287,7 +295,11 @@ describe('Client (V2)', () => { describe('tasks (SEP-1686 / SEP-2557)', () => { async function connected(handler: (req: JSONRPCRequest) => FetchResp | Promise) { const m = mockTransport(req => { - if (req.method === 'server/discover') return ok(req.id, { capabilities: { tools: {}, tasks: { tools: { call: true } } } }); + if (req.method === 'server/discover') + return ok(req.id, { + capabilities: { tools: {}, tasks: { tools: { call: true } } }, + serverInfo: { name: 's', version: '1' } + }); return handler(req); }); const c = new Client({ name: 'c', version: '1' }); @@ -348,9 +360,9 @@ describe('Client (V2)', () => { expect(sent.map(r => r.method).filter(m => m.startsWith('tasks/'))).toEqual(['tasks/get', 'tasks/list', 'tasks/cancel']); }); - it('taskManager throws NotConnected when not connected via a pipe', async () => { + it('taskManager is available on the request-shaped path (Client-owned)', async () => { const { c } = await connected(r => ok(r.id, {})); - expect(() => c.taskManager).toThrow(/pipe-shaped Transport/); + expect(c.taskManager).toBeDefined(); }); }); }); From 33c504761076cbfcf7c5bd3ef8db1ab345def1ce Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 14:23:41 +0000 Subject: [PATCH 36/55] fix(client): open standalone GET SSE stream after connect (request-shaped path) Root cause of elicitation-sep1034-client-defaults: conformance server sends server-initiated requests (elicitation) on the standalone GET stream, not the POST response stream. v1 client auto-opened it; the gut removed it. _startStandaloneStream() opens subscribe(), routes inbound requests to _localDispatcher.dispatch, responses to taskManager, notifications to handlers. Conformance client: 312/318 -> 317/317. 1687 SDK + 422 integration. --- packages/client/src/client/client.ts | 35 +++++++++++++++++++ packages/client/src/client/clientTransport.ts | 11 +++--- packages/client/src/client/streamableHttp.ts | 20 ++++++++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 952d216e1..af3e5d166 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -298,6 +298,7 @@ export class Client { const setProtocolVersion = (v: string) => t.setProtocolVersion?.(v); if (t.sessionId !== undefined) { if (this._negotiatedProtocolVersion) setProtocolVersion(this._negotiatedProtocolVersion); + this._startStandaloneStream(); return; } try { @@ -306,6 +307,40 @@ export class Client { void this.close(); throw error; } + this._startStandaloneStream(); + } + + /** + * Open the optional standalone server→client stream (e.g. SHTTP GET SSE) so + * server-initiated requests (elicitation/sampling/roots) and unsolicited + * notifications reach this client when going through the request-shaped + * {@linkcode ClientTransport} path. No-op if the transport doesn't support it. + */ + private _startStandaloneStream(): void { + const ct = this._ct; + if (!ct?.subscribe) return; + void (async () => { + try { + const stream = ct.subscribe!({ + onrequest: async r => { + let resp: JSONRPCResultResponse | JSONRPCErrorResponse | undefined; + for await (const out of this._localDispatcher.dispatch(r)) { + if (out.kind === 'response') resp = out.message; + } + return resp ?? { jsonrpc: '2.0', id: r.id, error: { code: -32_601, message: 'Method not found' } }; + }, + onresponse: r => { + const consumed = this.taskManager.processInboundResponse(r, Number(r.id)).consumed; + if (!consumed) this.onerror?.(new Error(`Unmatched response on standalone stream: ${JSON.stringify(r)}`)); + } + }); + for await (const n of stream) { + void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)); + } + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + })(); } /** diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index 5e6ec6e9e..228562bc9 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -78,11 +78,14 @@ export interface ClientTransport { notify(notification: Notification): Promise; /** - * Open a server→client subscription stream for list-changed and other - * unsolicited notifications. Optional; transports that cannot stream - * (e.g. plain HTTP without SSE GET) omit this. + * Open a server→client subscription stream for unsolicited notifications, + * server-initiated requests (elicitation/sampling/roots), and queued task + * responses. Optional; transports that cannot stream (e.g. plain HTTP + * without SSE GET) omit this. The transport handles inbound requests via + * {@linkcode ClientFetchOptions.onrequest | opts.onrequest} (and POSTs the + * reply back itself); only notifications are yielded. */ - subscribe?(filter?: string[]): AsyncIterable; + subscribe?(opts?: Pick): AsyncIterable; /** * Close the transport and release resources. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 1c5cb59ef..69637b4ab 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -498,7 +498,10 @@ export class StreamableHTTPClientTransport implements ClientTransport, Transport } /** Handle a server-initiated request received on the SSE response stream and POST the reply back. */ - private async _serviceInboundRequest(inbound: JSONRPCRequest, opts: ClientFetchOptions): Promise { + private async _serviceInboundRequest( + inbound: JSONRPCRequest, + opts: Pick + ): Promise { if (!opts.onrequest) { opts.onnotification?.(inbound as unknown as JSONRPCNotification); return; @@ -551,9 +554,12 @@ export class StreamableHTTPClientTransport implements ClientTransport, Transport /** * Open the standalone GET SSE stream and yield server-initiated notifications. - * Best-effort: if the server replies 405 (no SSE GET), the iterable completes immediately. + * Inbound requests (elicitation/sampling/roots) are dispatched via + * {@linkcode ClientFetchOptions.onrequest | opts.onrequest} and the reply is + * POSTed back automatically. Best-effort: if the server replies 405 (no SSE + * GET), the iterable completes immediately. */ - async *subscribe(): AsyncIterable { + async *subscribe(opts: Pick = {}): AsyncIterable { this._abortController ??= new AbortController(); const res = await this._authedHttpFetch(headers => { this._setAccept(headers, 'text/event-stream'); @@ -570,7 +576,13 @@ export class StreamableHTTPClientTransport implements ClientTransport, Transport if (done) return; if (!value.data) continue; const msg = JSONRPCMessageSchema.parse(JSON.parse(value.data)); - if (isJSONRPCNotification(msg)) yield msg; + if (isJSONRPCNotification(msg)) { + yield msg; + } else if (isJSONRPCRequest(msg)) { + void this._serviceInboundRequest(msg, opts); + } else if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) { + opts.onresponse?.(msg); + } } } finally { reader.releaseLock(); From c0e35f4c6fde2029625ab5dacfb81584083da827 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 14:41:27 +0000 Subject: [PATCH 37/55] refactor(core): introduce OutboundChannel; McpServer/Protocol hold _outbound, not StreamDriver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpServer and Protocol no longer know about StreamDriver specifically. They hold an OutboundChannel — the minimal contract for sending requests/notifications to the connected peer. StreamDriver implements it (and gains setProtocolVersion/ sendRaw delegates). Request-shaped paths can supply their own. The deprecated transport getter still returns the underlying pipe when present. --- packages/core/src/shared/context.ts | 27 ++++++++++++ packages/core/src/shared/protocol.ts | 27 ++++++------ packages/core/src/shared/streamDriver.ts | 14 +++++- packages/server/src/server/mcpServer.ts | 56 ++++++++++++------------ 4 files changed, 82 insertions(+), 42 deletions(-) diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 09b0cae12..ab4a50efc 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -7,10 +7,12 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + JSONRPCMessage, LoggingLevel, Notification, Progress, RelatedTaskMetadata, + Request, RequestId, RequestMeta, RequestMethod, @@ -18,6 +20,7 @@ import type { ServerCapabilities, TaskCreationParams } from '../types/index.js'; +import type { AnySchema, SchemaOutput } from '../util/schema.js'; import type { TaskContext, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; import type { TransportSendOptions } from './transport.js'; @@ -133,6 +136,30 @@ export type NotificationOptions = { relatedTask?: RelatedTaskMetadata; }; +/** + * The minimal contract a {@linkcode Dispatcher} owner needs to send outbound + * requests/notifications to the connected peer. Decouples {@linkcode McpServer} + * (and the compat {@linkcode Protocol}) from any specific transport adapter: + * they hold an `OutboundChannel`, not a `StreamDriver`. + * + * {@linkcode StreamDriver} implements this for persistent pipes. Request-shaped + * paths can supply their own (e.g. routing through a backchannel). + */ +export interface OutboundChannel { + /** Send a request to the peer and resolve with the parsed result. */ + request(req: Request, resultSchema: T, options?: RequestOptions): Promise>; + /** Send a notification to the peer. */ + notification(notification: Notification, options?: NotificationOptions): Promise; + /** Close the underlying connection. */ + close(): Promise; + /** Clear a registered progress callback by its message id. Optional; pipe-channels expose this for {@linkcode TaskManager}. */ + removeProgressHandler?(messageId: number): void; + /** Inform the channel which protocol version was negotiated (for header echoing etc.). Optional. */ + setProtocolVersion?(version: string): void; + /** Write a raw JSON-RPC message on the same stream as a prior request. Optional; pipe-only. */ + sendRaw?(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise; +} + /** * Base context provided to all request handlers. */ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 21e12ec15..2d4b12862 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -20,7 +20,7 @@ import type { } from '../types/index.js'; import { getResultSchema, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; -import type { BaseContext, NotificationOptions, ProtocolOptions, RequestOptions } from './context.js'; +import type { BaseContext, NotificationOptions, OutboundChannel, ProtocolOptions, RequestOptions } from './context.js'; import type { DispatchEnv } from './dispatcher.js'; import { Dispatcher } from './dispatcher.js'; import { StreamDriver } from './streamDriver.js'; @@ -36,7 +36,7 @@ export * from './context.js'; * {@linkcode StreamDriver} (per-connection state) to preserve the v1 surface. */ export abstract class Protocol { - private _driver?: StreamDriver; + private _outbound?: OutboundChannel; private readonly _dispatcher: Dispatcher; protected _supportedProtocolVersions: string[]; @@ -75,10 +75,10 @@ export abstract class Protocol { request: (r, schema, opts) => this._requestWithSchema(r, schema, opts), notification: (n, opts) => this.notification(n, opts), reportError: e => this.onerror?.(e), - removeProgressHandler: t => this._driver?.removeProgressHandler(t), + removeProgressHandler: t => this._outbound?.removeProgressHandler?.(t), registerHandler: (method, handler) => this._dispatcher.setRawRequestHandler(method, handler), sendOnResponseStream: async (message, relatedRequestId) => { - await this._driver?.pipe.send(message, { relatedRequestId }); + await this._outbound?.sendRaw?.(message, { relatedRequestId }); }, enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, assertTaskCapability: m => this.assertTaskCapability(m), @@ -170,9 +170,9 @@ export abstract class Protocol { enforceStrictCapabilities: this._options?.enforceStrictCapabilities, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) }); - this._driver = driver; + this._outbound = driver; driver.onclose = () => { - if (this._driver === driver) this._driver = undefined; + if (this._outbound === driver) this._outbound = undefined; this.onclose?.(); }; driver.onerror = error => this.onerror?.(error); @@ -183,11 +183,12 @@ export abstract class Protocol { * Closes the connection. */ async close(): Promise { - await this._driver?.close(); + await this._outbound?.close(); } + /** @deprecated Protocol is no longer coupled to a specific transport. Returns the underlying pipe only when connected via {@linkcode StreamDriver}. */ get transport(): Transport | undefined { - return this._driver?.pipe; + return (this._outbound as { pipe?: Transport } | undefined)?.pipe; } get taskManager(): TaskManager { @@ -213,24 +214,24 @@ export abstract class Protocol { resultSchema: T, options?: RequestOptions ): Promise> { - if (!this._driver) { + if (!this._outbound) { return Promise.reject(new SdkError(SdkErrorCode.NotConnected, 'Not connected')); } if (this._options?.enforceStrictCapabilities === true) { this.assertCapabilityForMethod(request.method as RequestMethod); } - return this._driver.request(request, resultSchema, options); + return this._outbound.request(request, resultSchema, options); } /** * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - if (!this._driver) { + if (!this._outbound) { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } this.assertNotificationCapability(notification.method as NotificationMethod); - return this._driver.notification(notification, options); + return this._outbound.notification(notification, options); } // ─────────────────────────────────────────────────────────────────────── @@ -245,6 +246,6 @@ export abstract class Protocol { /** @internal v1 tests reach into this. */ protected get _responseHandlers(): Map void> | undefined { - return (this._driver as unknown as { _responseHandlers?: Map void> })?._responseHandlers; + return (this._outbound as unknown as { _responseHandlers?: Map void> })?._responseHandlers; } } diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 6fbadb317..e7db586ee 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -25,7 +25,7 @@ import { } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; -import type { NotificationOptions, ProgressCallback, RequestOptions } from './context.js'; +import type { NotificationOptions, OutboundChannel, ProgressCallback, RequestOptions } from './context.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; import type { DispatchEnv, Dispatcher } from './dispatcher.js'; import type { InboundContext, TaskManagerHost, TaskManagerOptions } from './taskManager.js'; @@ -72,7 +72,7 @@ export type StreamDriverOptions = { * * One driver per pipe. The dispatcher it wraps may be shared. */ -export class StreamDriver { +export class StreamDriver implements OutboundChannel { private _requestMessageId = 0; private _responseHandlers: Map void> = new Map(); private _progressHandlers: Map = new Map(); @@ -169,6 +169,16 @@ export class StreamDriver { await this.pipe.close(); } + /** {@linkcode OutboundChannel.setProtocolVersion} — delegates to the pipe. */ + setProtocolVersion(version: string): void { + this.pipe.setProtocolVersion?.(version); + } + + /** {@linkcode OutboundChannel.sendRaw} — write a raw JSON-RPC message to the pipe. */ + async sendRaw(message: Parameters[0], options?: { relatedRequestId?: RequestId }): Promise { + await this.pipe.send(message, options); + } + /** * Sends a request over the pipe and resolves with the parsed result. */ diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 361911ec6..f3a70fd4f 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -33,6 +33,7 @@ import type { Notification, NotificationMethod, NotificationOptions, + OutboundChannel, ProtocolOptions, Request, RequestId, @@ -142,7 +143,7 @@ export type ServerOptions = Omit & { * One instance can serve any number of concurrent requests. */ export class McpServer extends Dispatcher implements RegistriesHost { - private _driver?: StreamDriver; + private _outbound?: OutboundChannel; private readonly _registries = new ServerRegistries(this); private readonly _dispatchYielders = new Map void>(); private _dispatchOutboundId = 0; @@ -276,7 +277,7 @@ export class McpServer extends Dispatcher implements RegistriesHo }; // Queued task messages delivered via host.sendOnResponseStream are routed to this - // generator (instead of `_driver.pipe.send`) so they yield on the same stream. + // generator (instead of `_outbound.sendRaw`) so they yield on the same stream. const sideQueue: JSONRPCMessage[] = []; let wake: (() => void) | undefined; this._dispatchYielders.set(request.id, msg => { @@ -419,9 +420,9 @@ export class McpServer extends Dispatcher implements RegistriesHo buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) }; const driver = new StreamDriver(this, transport, driverOpts); - this._driver = driver; + this._outbound = driver; driver.onclose = () => { - if (this._driver === driver) this._driver = undefined; + if (this._outbound === driver) this._outbound = undefined; this.onclose?.(); }; driver.onerror = error => this.onerror?.(error); @@ -432,18 +433,19 @@ export class McpServer extends Dispatcher implements RegistriesHo * Closes the connection. */ async close(): Promise { - await this._driver?.close(); + await this._outbound?.close(); } /** * Checks if the server is connected to a transport. */ isConnected(): boolean { - return this._driver !== undefined; + return this._outbound !== undefined; } + /** @deprecated The server is no longer coupled to a specific transport. Returns the underlying pipe only when connected via {@linkcode StreamDriver}. */ get transport(): Transport | undefined { - return this._driver?.pipe; + return (this._outbound as { pipe?: Transport } | undefined)?.pipe; } /** @@ -472,10 +474,10 @@ export class McpServer extends Dispatcher implements RegistriesHo private _bindTaskManager(): void { const host: TaskManagerHost = { - request: (r, schema, opts) => this._driverRequest(r, schema as never, opts), + request: (r, schema, opts) => this._outboundRequest(r, schema as never, opts), notification: (n, opts) => this.notification(n, opts), reportError: e => (this.onerror ?? (() => {}))(e), - removeProgressHandler: t => this._driver?.removeProgressHandler(t), + removeProgressHandler: t => this._outbound?.removeProgressHandler?.(t), registerHandler: (m, h) => this.setRawRequestHandler(m, h as never), sendOnResponseStream: async (msg, relatedRequestId) => { const yielder = relatedRequestId === undefined ? undefined : this._dispatchYielders.get(relatedRequestId); @@ -483,7 +485,7 @@ export class McpServer extends Dispatcher implements RegistriesHo yielder(msg); return; } - await this._driver?.pipe.send(msg, { relatedRequestId }); + await this._outbound?.sendRaw?.(msg, { relatedRequestId }); }, enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, assertTaskCapability: m => assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, m, 'Client'), @@ -562,7 +564,7 @@ export class McpServer extends Dispatcher implements RegistriesHo ? requestedVersion : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); - this._driver?.pipe.setProtocolVersion?.(protocolVersion); + this._outbound?.setProtocolVersion?.(protocolVersion); return { protocolVersion, @@ -597,7 +599,7 @@ export class McpServer extends Dispatcher implements RegistriesHo * Registers new capabilities. Can only be called before connecting to a transport. */ registerCapabilities(capabilities: ServerCapabilities): void { - if (this._driver) { + if (this._outbound) { throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register capabilities after connecting to transport'); } const hadLogging = !!this._capabilities.logging; @@ -675,24 +677,24 @@ export class McpServer extends Dispatcher implements RegistriesHo } // ─────────────────────────────────────────────────────────────────────── - // Server→client requests (only work when connected via StreamDriver) + // Server→client requests (require a connected OutboundChannel) // ─────────────────────────────────────────────────────────────────────── - private _requireDriver(): StreamDriver { - if (!this._driver) { + private _requireOutbound(): OutboundChannel { + if (!this._outbound) { throw new SdkError( SdkErrorCode.NotConnected, - 'Server is not connected to a stream transport. Use ctx.mcpReq.* inside handlers, or the MRTR-native return form, or call connect().' + 'Server is not connected. Use ctx.mcpReq.* inside handlers, or the MRTR-native return form, or call connect().' ); } - return this._driver; + return this._outbound; } - private _driverRequest(req: Request, schema: { parse(v: unknown): T }, options?: RequestOptions): Promise { + private _outboundRequest(req: Request, schema: { parse(v: unknown): T }, options?: RequestOptions): Promise { if (this._options?.enforceStrictCapabilities === true) { assertCapabilityForMethod(req.method as RequestMethod, this._clientCapabilities); } - return this._requireDriver().request(req, schema as never, options) as Promise; + return this._requireOutbound().request(req, schema as never, options) as Promise; } /** @@ -703,11 +705,11 @@ export class McpServer extends Dispatcher implements RegistriesHo req: { method: M; params?: Record }, options?: RequestOptions ): Promise { - return this._driverRequest(req as Request, getResultSchema(req.method), options) as Promise; + return this._outboundRequest(req as Request, getResultSchema(req.method), options) as Promise; } async ping(): Promise { - return this._driverRequest({ method: 'ping' }, EmptyResultSchema); + return this._outboundRequest({ method: 'ping' }, EmptyResultSchema); } /** @@ -766,9 +768,9 @@ export class McpServer extends Dispatcher implements RegistriesHo } } if (params.tools) { - return this._driverRequest({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + return this._outboundRequest({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); } - return this._driverRequest({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + return this._outboundRequest({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } /** @@ -783,7 +785,7 @@ export class McpServer extends Dispatcher implements RegistriesHo throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); } const urlParams = params as ElicitRequestURLParams; - return this._driverRequest({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + return this._outboundRequest({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); } case 'form': { if (this._clientCapabilities && !this._clientCapabilities.elicitation?.form) { @@ -791,7 +793,7 @@ export class McpServer extends Dispatcher implements RegistriesHo } const formParams: ElicitRequestFormParams = params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; - const result = await this._driverRequest({ method: 'elicitation/create', params: formParams }, ElicitResultSchema, options); + const result = await this._outboundRequest({ method: 'elicitation/create', params: formParams }, ElicitResultSchema, options); return this._validateElicitResult(result, formParams); } } @@ -830,7 +832,7 @@ export class McpServer extends Dispatcher implements RegistriesHo } async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { - return this._driverRequest({ method: 'roots/list', params }, ListRootsResultSchema, options); + return this._outboundRequest({ method: 'roots/list', params }, ListRootsResultSchema, options); } // ─────────────────────────────────────────────────────────────────────── @@ -842,7 +844,7 @@ export class McpServer extends Dispatcher implements RegistriesHo */ async notification(notification: Notification, options?: NotificationOptions): Promise { assertNotificationCapability(notification.method as NotificationMethod, this._capabilities, this._clientCapabilities); - await this._driver?.notification(notification, options); + await this._outbound?.notification(notification, options); } async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise { From 56e1506bb11f57ccf1020e9c365d3705d9358338 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 14:53:31 +0000 Subject: [PATCH 38/55] refactor(core): remove TaskManager from StreamDriver; move inbound task processing to dispatch() StreamDriver no longer constructs, owns, or knows about TaskManager. It exposes a generic OutboundInterceptor hook (request/notification/response/close) at the correlation seam; callers wire their TaskManager through it. Inbound task processing (processInboundRequest) moves to where it belongs: the dispatch() override. McpServer already had this; Protocol's inner dispatcher and Client's _localDispatcher gain matching overrides. McpServer/Protocol/Client each construct and own their TaskManager and pass an interceptor to StreamDriver. dispatcherHandlesTasks/tasks/taskManager/ enforceStrictCapabilities options removed from StreamDriverOptions. --- packages/client/src/client/client.ts | 82 +++++++++++-- packages/core/src/shared/context.ts | 34 ++++++ packages/core/src/shared/protocol.ts | 59 +++++++++- packages/core/src/shared/streamDriver.ts | 144 +++++------------------ packages/server/src/server/mcpServer.ts | 17 ++- 5 files changed, 200 insertions(+), 136 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index af3e5d166..7b0ab61b4 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -11,10 +11,13 @@ import type { ClientResult, CompleteRequest, CreateTaskResult, + DispatchEnv, + DispatchOutput, GetPromptRequest, GetTaskRequest, GetTaskResult, Implementation, + InboundContext, JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResultResponse, @@ -213,7 +216,7 @@ export type ClientOptions = ProtocolOptions & { */ export class Client { private _ct?: ClientTransport; - private _localDispatcher: Dispatcher = new Dispatcher(); + private _localDispatcher: Dispatcher; private _capabilities: ClientCapabilities; private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; @@ -240,6 +243,56 @@ export class Client { private _clientInfo: Implementation, private _options?: ClientOptions ) { + // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment + const self = this; + this._localDispatcher = new (class extends Dispatcher { + override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { + const tm = self._taskManager; + if (!tm) { + yield* super.dispatch(request, env); + return; + } + const inboundCtx: InboundContext = { + sessionId: env.sessionId, + sendNotification: (n, opts) => self.notification(n, { ...opts, relatedRequestId: request.id }), + sendRequest: (r, schema, opts) => self._request(r, schema, { ...opts, relatedRequestId: request.id }) + }; + const tr = tm.processInboundRequest(request, inboundCtx); + if (tr.validateInbound) { + try { + tr.validateInbound(); + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + yield { + kind: 'response', + message: { + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + message: e?.message ?? 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + } + }; + return; + } + } + const taskEnv: DispatchEnv = { + ...env, + task: tr.taskContext ?? env.task, + send: (r, opts) => tr.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise + }; + for await (const out of super.dispatch(request, taskEnv)) { + if (out.kind === 'response') { + const routed = await tr.routeResponse(out.message); + if (!routed) yield out; + } else { + await tr.sendNotification({ method: out.message.method, params: out.message.params }); + } + } + } + })(); this._capabilities = _options?.capabilities ? { ..._options.capabilities } : {}; this._jsonSchemaValidator = _options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; @@ -267,12 +320,18 @@ export class Client { * is performed. */ async connect(transport: Transport | ClientTransport, options?: RequestOptions): Promise { + this._bindTaskManager(); if (isPipeTransport(transport)) { + const tm = this._taskManager!; const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - tasks: this._tasksOptions, - enforceStrictCapabilities: this._enforceStrictCapabilities + interceptor: { + request: (jr, opts, id, settle, reject) => tm.processOutboundRequest(jr, opts, id, settle, reject), + notification: (n, opts) => tm.processOutboundNotification(n, opts), + response: (r, id) => tm.processInboundResponse(r, id), + close: () => tm.onClose() + } }; this._ct = pipeAsClientTransport(transport, this._localDispatcher, driverOpts); this._ct.driver!.onclose = () => this.onclose?.(); @@ -293,7 +352,6 @@ export class Client { return; } this._ct = transport; - this._bindTaskManager(); const t = transport as { sessionId?: string; setProtocolVersion?: (v: string) => void }; const setProtocolVersion = (v: string) => t.setProtocolVersion?.(v); if (t.sessionId !== undefined) { @@ -344,8 +402,9 @@ export class Client { } /** - * Construct and bind a {@linkcode TaskManager} for the request-shaped {@linkcode ClientTransport} - * path. The pipe-shaped path uses the StreamDriver's TaskManager instead. + * Construct and bind this client's {@linkcode TaskManager}. Owned by the client + * (not the transport adapter); the pipe-shaped path threads it via + * {@linkcode StreamDriverOptions.interceptor}. */ private _bindTaskManager(): void { const tm = this._tasksOptions ? new TaskManager(this._tasksOptions) : new NullTaskManager(); @@ -353,7 +412,7 @@ export class Client { request: (r, schema, opts) => this._request(r, schema, opts), notification: (n, opts) => this.notification(n, opts), reportError: e => this.onerror?.(e), - removeProgressHandler: () => {}, + removeProgressHandler: t => this._ct?.driver?.removeProgressHandler(t), registerHandler: (method, handler) => this._localDispatcher.setRawRequestHandler(method, handler), sendOnResponseStream: async () => { throw new SdkError(SdkErrorCode.NotConnected, 'sendOnResponseStream is server-side only'); @@ -612,16 +671,13 @@ export class Client { } /** - * The connection's {@linkcode TaskManager}. Only present when connected over a - * pipe-shaped transport (the StreamDriver owns it). Request-shaped - * transports have no per-connection task buffer. + * This client's {@linkcode TaskManager}. Owned here (not by the transport adapter). */ get taskManager(): TaskManager { - const tm = this._ct?.driver?.taskManager ?? this._taskManager; - if (!tm) { + if (!this._taskManager) { throw new SdkError(SdkErrorCode.NotConnected, 'taskManager is unavailable: call connect() first.'); } - return tm; + return this._taskManager; } /** diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index ab4a50efc..4b81dba9c 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -7,7 +7,12 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + JSONRPCErrorResponse, JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + JSONRPCResultResponse, LoggingLevel, Notification, Progress, @@ -160,6 +165,35 @@ export interface OutboundChannel { sendRaw?(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise; } +/** + * Hooks an {@linkcode OutboundChannel} owner can supply to a transport adapter + * (e.g. {@linkcode StreamDriver}) to intercept outbound writes and inbound responses + * at the request-correlation seam. The adapter knows nothing about *why* a message + * is queued or consumed; it just calls these hooks. + * + * In practice this is how {@linkcode TaskManager} threads task augmentation through + * a pipe — but the adapter is agnostic to that. + */ +export interface OutboundInterceptor { + /** Called before each outbound request hits the wire. Return `queued: true` to suppress the send (caller resolves via `settle`). */ + request?( + jr: JSONRPCRequest, + options: RequestOptions | undefined, + messageId: number, + settle: (r: JSONRPCResultResponse | Error) => void, + reject: (e: unknown) => void + ): { queued: boolean }; + /** Called before each outbound notification. May suppress and/or rewrite. */ + notification?( + n: Notification, + options: NotificationOptions | undefined + ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }>; + /** Called for each inbound response before correlation. `consumed: true` swallows it. */ + response?(r: JSONRPCResponse | JSONRPCErrorResponse, messageId: number): { consumed: boolean; preserveProgress?: boolean }; + /** Called on connection close. */ + close?(): void; +} + /** * Base context provided to all request handlers. */ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 2d4b12862..9e936b405 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -18,12 +18,13 @@ import type { Result, ResultTypeMap } from '../types/index.js'; -import { getResultSchema, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; +import { getResultSchema, ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import type { BaseContext, NotificationOptions, OutboundChannel, ProtocolOptions, RequestOptions } from './context.js'; -import type { DispatchEnv } from './dispatcher.js'; +import type { DispatchEnv, DispatchOutput } from './dispatcher.js'; import { Dispatcher } from './dispatcher.js'; import { StreamDriver } from './streamDriver.js'; +import type { InboundContext } from './taskManager.js'; import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport } from './transport.js'; @@ -62,6 +63,50 @@ export abstract class Protocol { protected override buildContext(base: BaseContext, env: DispatchEnv & { _transportExtra?: MessageExtraInfo }): ContextT { return self.buildContext(base, env._transportExtra); } + + override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { + const inboundCtx: InboundContext = { + sessionId: env.sessionId, + sendNotification: (n, opts) => self.notification(n, { ...opts, relatedRequestId: request.id }), + sendRequest: (r, schema, opts) => self._requestWithSchema(r, schema, { ...opts, relatedRequestId: request.id }) + }; + const tr = self._ownTaskManager.processInboundRequest(request, inboundCtx); + if (tr.validateInbound) { + try { + tr.validateInbound(); + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + yield { + kind: 'response', + message: { + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + message: e?.message ?? 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + } + }; + return; + } + } + const taskEnv: DispatchEnv = { + ...env, + task: tr.taskContext ?? env.task, + send: (r, opts) => tr.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise + }; + for await (const out of super.dispatch(request, taskEnv)) { + if (out.kind === 'response') { + const routed = await tr.routeResponse(out.message); + if (!routed) yield out; + } else { + // Handler-emitted notifications go through TaskManager (queues when + // related-task; otherwise calls inboundCtx.sendNotification → wire). + await tr.sendNotification({ method: out.message.method, params: out.message.params }); + } + } + } })(); this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; this._ownTaskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); @@ -166,9 +211,13 @@ export abstract class Protocol { const driver = new StreamDriver(this._dispatcher, transport, { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - taskManager: this._ownTaskManager, - enforceStrictCapabilities: this._options?.enforceStrictCapabilities, - buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) + buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), + interceptor: { + request: (jr, opts, id, settle, reject) => this._ownTaskManager.processOutboundRequest(jr, opts, id, settle, reject), + notification: (n, opts) => this._ownTaskManager.processOutboundNotification(n, opts), + response: (r, id) => this._ownTaskManager.processInboundResponse(r, id), + close: () => this._ownTaskManager.onClose() + } }); this._outbound = driver; driver.onclose = () => { diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index e7db586ee..026e8c31a 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -25,11 +25,9 @@ import { } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; -import type { NotificationOptions, OutboundChannel, ProgressCallback, RequestOptions } from './context.js'; +import type { NotificationOptions, OutboundChannel, OutboundInterceptor, ProgressCallback, RequestOptions } from './context.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; import type { DispatchEnv, Dispatcher } from './dispatcher.js'; -import type { InboundContext, TaskManagerHost, TaskManagerOptions } from './taskManager.js'; -import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport } from './transport.js'; type TimeoutInfo = { @@ -50,19 +48,10 @@ export type StreamDriverOptions = { */ buildEnv?: (extra: MessageExtraInfo | undefined, base: DispatchEnv) => DispatchEnv; /** - * A pre-constructed and already-bound {@linkcode TaskManager}. When provided the - * driver uses it directly. When omitted, the driver constructs one from - * {@linkcode StreamDriverOptions.tasks | tasks} (or a {@linkcode NullTaskManager}) and binds it itself. + * Hooks invoked at the request-correlation seam (before each outbound write, + * for each inbound response, on close). The driver is agnostic to what they do. */ - taskManager?: TaskManager; - tasks?: TaskManagerOptions; - enforceStrictCapabilities?: boolean; - /** - * Set when the dispatcher's {@linkcode Dispatcher.dispatch | dispatch()} override handles - * {@linkcode TaskManager.processInboundRequest} itself (e.g. {@linkcode McpServer}). - * When true, the driver skips its own inbound task processing to avoid double-processing. - */ - dispatcherHandlesTasks?: boolean; + interceptor?: OutboundInterceptor; }; /** @@ -81,7 +70,6 @@ export class StreamDriver implements OutboundChannel { private _pendingDebouncedNotifications = new Set(); private _closed = false; private _supportedProtocolVersions: string[]; - private _taskManager: TaskManager; onclose?: () => void; onerror?: (error: Error) => void; @@ -93,40 +81,13 @@ export class StreamDriver implements OutboundChannel { private _options: StreamDriverOptions = {} ) { this._supportedProtocolVersions = _options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - if (_options.taskManager) { - this._taskManager = _options.taskManager; - } else { - this._taskManager = _options.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); - this._bindTaskManager(); - } } - get taskManager(): TaskManager { - return this._taskManager; - } - - /** Exposed so a {@linkcode TaskManagerHost} owned outside the driver can clear progress callbacks. */ + /** {@linkcode OutboundChannel.removeProgressHandler}. */ removeProgressHandler(token: number): void { this._progressHandlers.delete(token); } - private _bindTaskManager(): void { - const host: TaskManagerHost = { - request: (r, schema, opts) => this.request(r, schema, opts), - notification: (n, opts) => this.notification(n, opts), - reportError: e => this._onerror(e), - removeProgressHandler: t => this.removeProgressHandler(t), - registerHandler: (method, handler) => this.dispatcher.setRawRequestHandler(method, handler), - sendOnResponseStream: async (message, relatedRequestId) => { - await this.pipe.send(message, { relatedRequestId }); - }, - enforceStrictCapabilities: this._options.enforceStrictCapabilities === true, - assertTaskCapability: () => {}, - assertTaskHandlerCapability: () => {} - }; - this._taskManager.bind(host); - } - /** * Wires the pipe's callbacks and starts it. After this resolves, inbound * requests are dispatched and {@linkcode StreamDriver.request | request()} works. @@ -242,22 +203,24 @@ export class StreamDriver implements OutboundChannel { options?.resetTimeoutOnProgress ?? false ); - const sideChannelResponse = (resp: JSONRPCResultResponse | Error) => { - const h = this._responseHandlers.get(messageId); - if (h) h(resp); - else this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); - }; - let queued = false; - try { - queued = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, sideChannelResponse, error => { + const intercept = this._options.interceptor?.request; + if (intercept) { + const sideChannelResponse = (resp: JSONRPCResultResponse | Error) => { + const h = this._responseHandlers.get(messageId); + if (h) h(resp); + else this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); + }; + try { + queued = intercept(jsonrpcRequest, options, messageId, sideChannelResponse, error => { + this._progressHandlers.delete(messageId); + reject(error); + }).queued; + } catch (error) { this._progressHandlers.delete(messageId); reject(error); - }).queued; - } catch (error) { - this._progressHandlers.delete(messageId); - reject(error); - return; + return; + } } if (!queued) { @@ -279,9 +242,9 @@ export class StreamDriver implements OutboundChannel { * Sends a notification over the pipe. Supports debouncing per the constructor option. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - const taskResult = await this._taskManager.processOutboundNotification(notification, options); - if (taskResult.queued || this._closed) return; - const jsonrpc: JSONRPCNotification = taskResult.jsonrpcNotification ?? { + const intercepted = await this._options.interceptor?.notification?.(notification, options); + if (intercepted?.queued || this._closed) return; + const jsonrpc: JSONRPCNotification = intercepted?.jsonrpcNotification ?? { jsonrpc: '2.0', method: notification.method, params: notification.params @@ -307,68 +270,23 @@ export class StreamDriver implements OutboundChannel { const abort = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abort); - const directSend = (r: Request, opts?: RequestOptions) => - this.request(r, getResultSchema(r.method as RequestMethod), { ...opts, relatedRequestId: request.id }) as Promise; - - let task: DispatchEnv['task']; - let send = directSend; - // eslint-disable-next-line unicorn/consistent-function-scoping -- conditionally reassigned below - let routeResponse = async (_m: JSONRPCResponse | JSONRPCErrorResponse) => false; - let drainNotification = (n: Notification, opts?: NotificationOptions) => - this.notification(n, { ...opts, relatedRequestId: request.id }); - let validateInbound: (() => void) | undefined; - - if (!this._options.dispatcherHandlesTasks) { - const inboundCtx: InboundContext = { - sessionId: this.pipe.sessionId, - sendNotification: drainNotification, - sendRequest: (r, schema, opts) => this.request(r, schema, { ...opts, relatedRequestId: request.id }) - }; - const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); - task = taskResult.taskContext; - send = (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise; - routeResponse = taskResult.routeResponse; - drainNotification = taskResult.sendNotification; - validateInbound = taskResult.validateInbound; - } - const baseEnv: DispatchEnv = { signal: abort.signal, sessionId: this.pipe.sessionId, authInfo: extra?.authInfo, httpReq: extra?.request, - task, - send + send: (r, opts) => + this.request(r, getResultSchema(r.method as RequestMethod), { ...opts, relatedRequestId: request.id }) as Promise }; const env = this._options.buildEnv ? this._options.buildEnv(extra, baseEnv) : baseEnv; const drain = async () => { - if (validateInbound) { - try { - validateInbound(); - } catch (error) { - const e = error as { code?: number; message?: string; data?: unknown }; - const errResp: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(e?.code) ? (e.code as number) : -32_603, - message: e?.message ?? 'Internal error', - ...(e?.data !== undefined && { data: e.data }) - } - }; - const routed = await routeResponse(errResp); - if (!routed) await this.pipe.send(errResp, { relatedRequestId: request.id }); - return; - } - } for await (const out of this.dispatcher.dispatch(request, env)) { if (out.kind === 'notification') { - await drainNotification({ method: out.message.method, params: out.message.params }); + await this.notification({ method: out.message.method, params: out.message.params }, { relatedRequestId: request.id }); } else { if (abort.signal.aborted) return; - const routed = await routeResponse(out.message); - if (!routed) await this.pipe.send(out.message, { relatedRequestId: request.id }); + await this.pipe.send(out.message, { relatedRequestId: request.id }); } } }; @@ -423,8 +341,8 @@ export class StreamDriver implements OutboundChannel { private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); - const taskResult = this._taskManager.processInboundResponse(response, messageId); - if (taskResult.consumed) return; + const intercepted = this._options.interceptor?.response?.(response, messageId); + if (intercepted?.consumed) return; const handler = this._responseHandlers.get(messageId); if (handler === undefined) { @@ -433,7 +351,7 @@ export class StreamDriver implements OutboundChannel { } this._responseHandlers.delete(messageId); this._cleanupTimeout(messageId); - if (!taskResult.preserveProgress) { + if (!intercepted?.preserveProgress) { this._progressHandlers.delete(messageId); } if (isJSONRPCResultResponse(response)) { @@ -448,7 +366,7 @@ export class StreamDriver implements OutboundChannel { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); - this._taskManager.onClose(); + this._options.interceptor?.close?.(); this._pendingDebouncedNotifications.clear(); for (const info of this._timeoutInfo.values()) clearTimeout(info.timeoutId); this._timeoutInfo.clear(); diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index f3a70fd4f..206c1ddfa 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -414,10 +414,13 @@ export class McpServer extends Dispatcher implements RegistriesHo const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - taskManager: this._taskManager, - dispatcherHandlesTasks: true, - enforceStrictCapabilities: this._options?.enforceStrictCapabilities, - buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) + buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), + interceptor: { + request: (jr, opts, id, settle, reject) => this._taskManager.processOutboundRequest(jr, opts, id, settle, reject), + notification: (n, opts) => this._taskManager.processOutboundNotification(n, opts), + response: (r, id) => this._taskManager.processInboundResponse(r, id), + close: () => this._taskManager.onClose() + } }; const driver = new StreamDriver(this, transport, driverOpts); this._outbound = driver; @@ -793,7 +796,11 @@ export class McpServer extends Dispatcher implements RegistriesHo } const formParams: ElicitRequestFormParams = params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; - const result = await this._outboundRequest({ method: 'elicitation/create', params: formParams }, ElicitResultSchema, options); + const result = await this._outboundRequest( + { method: 'elicitation/create', params: formParams }, + ElicitResultSchema, + options + ); return this._validateElicitResult(result, formParams); } } From 3521004a36d57e0c0f5c34be54a885088edc2fc7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 14:56:55 +0000 Subject: [PATCH 39/55] refactor(core): typed RequestServerTransport replaces 'bind' duck-typing McpServer.connect() now takes Transport | (Transport & RequestServerTransport) and uses isRequestServerTransport() instead of probing for a 'bind' property. WebStandard/Node SHTTP server transports implement RequestServerTransport via attach(); the old bind() name is kept as a deprecated alias. --- packages/core/src/shared/transport.ts | 23 +++++++++++++++++++ .../middleware/node/src/streamableHttp.ts | 15 ++++++++---- packages/server/src/server/mcpServer.ts | 22 ++++++++++-------- packages/server/src/server/streamableHttp.ts | 22 ++++++++++++++---- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b..8ec0cf71e 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,4 +1,5 @@ import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; +import type { Dispatcher } from './dispatcher.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -132,3 +133,25 @@ export interface Transport { */ setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; } + +/** + * A request-shaped server transport: instead of feeding a persistent pipe to a + * {@linkcode StreamDriver}, it accepts requests one at a time (e.g. HTTP POSTs) + * and dispatches each via the attached {@linkcode Dispatcher}. + * + * Concrete implementations expose their own per-request entry point + * (e.g. `handleRequest(req, res)`); only {@linkcode attach} is part of this contract. + */ +export interface RequestServerTransport { + /** + * Gives the transport a reference to the dispatcher (typically a `McpServer`) + * so its per-request handler can call `dispatcher.dispatch()`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- concrete impls narrow the dispatcher type + attach(dispatcher: Dispatcher): void; +} + +/** Type guard for {@linkcode RequestServerTransport}. */ +export function isRequestServerTransport(t: unknown): t is RequestServerTransport { + return typeof (t as RequestServerTransport | undefined)?.attach === 'function'; +} diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 7760f30e4..b7d6f9ebf 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -10,7 +10,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, RequestServerTransport, Transport } from '@modelcontextprotocol/core'; import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; @@ -84,7 +84,7 @@ export function toNodeHttpHandler( * }); * ``` */ -export class NodeStreamableHTTPServerTransport implements Transport { +export class NodeStreamableHTTPServerTransport implements Transport, RequestServerTransport { private _webStandardTransport: WebStandardStreamableHTTPServerTransport; private _requestListener: ReturnType; // Store auth and parsedBody per request for passing through to handleRequest @@ -151,10 +151,15 @@ export class NodeStreamableHTTPServerTransport implements Transport { } /** - * Binds the underlying web-standard transport to a server. Called by `McpServer.connect()`. + * {@linkcode RequestServerTransport.attach} — called by `McpServer.connect()`. */ - bind(server: Parameters[0]): void { - this._webStandardTransport.bind(server); + attach(server: Parameters[0]): void { + this._webStandardTransport.attach(server); + } + + /** @deprecated Use {@linkcode attach}. */ + bind(server: Parameters[0]): void { + this.attach(server); } /** diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 206c1ddfa..c3512348e 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -39,6 +39,7 @@ import type { RequestId, RequestMethod, RequestOptions, + RequestServerTransport, RequestTypeMap, ResourceUpdatedNotification, Result, @@ -71,6 +72,7 @@ import { extractTaskManagerOptions, getResultSchema, isJSONRPCRequest, + isRequestServerTransport, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -399,17 +401,19 @@ export class McpServer extends Dispatcher implements RegistriesHo /** * Attaches to the given transport, starts it, and starts listening for messages. - * Builds a {@linkcode StreamDriver} internally. * - * Transports that expose a `bind(server)` method (request-shaped transports like - * {@linkcode WebStandardStreamableHTTPServerTransport}) are bound to this server - * first so their `handleRequest` can dispatch directly via {@linkcode shttpHandler}; - * the {@linkcode StreamDriver} is still built so outbound `notification()`/`request()` - * route through `transport.send()`. + * For pipe-shaped {@linkcode Transport}s (stdio, WebSocket, InMemory), builds a + * {@linkcode StreamDriver} internally and stores it as the {@linkcode OutboundChannel}. + * + * Transports that also implement {@linkcode RequestServerTransport} (e.g. + * {@linkcode WebStandardStreamableHTTPServerTransport}) are attached to this + * server first so their `handleRequest` can dispatch directly via + * {@linkcode shttpHandler}; the {@linkcode StreamDriver} is still built so + * outbound `notification()`/`request()` route through `transport.send()`. */ - async connect(transport: Transport): Promise { - if ('bind' in transport && typeof (transport as { bind: unknown }).bind === 'function') { - (transport as { bind: (server: McpServer) => void }).bind(this); + async connect(transport: Transport | (Transport & RequestServerTransport)): Promise { + if (isRequestServerTransport(transport)) { + transport.attach(this); } const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index a840aa5d8..354529d20 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -12,7 +12,14 @@ * which wraps this transport. */ -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, Transport, TransportSendOptions } from '@modelcontextprotocol/core'; +import type { + AuthInfo, + JSONRPCMessage, + MessageExtraInfo, + RequestServerTransport, + Transport, + TransportSendOptions +} from '@modelcontextprotocol/core'; import { isJSONRPCErrorResponse, isJSONRPCResultResponse, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { Backchannel2511 } from './backchannel2511.js'; @@ -145,7 +152,7 @@ export interface HandleRequestOptions { * {@linkcode Transport} interface methods route outbound messages through the * per-session {@linkcode Backchannel2511}. */ -export class WebStandardStreamableHTTPServerTransport implements Transport { +export class WebStandardStreamableHTTPServerTransport implements Transport, RequestServerTransport { private _options: WebStandardStreamableHTTPServerTransportOptions; private _session?: SessionCompat; private _backchannel = new Backchannel2511(); @@ -180,10 +187,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } /** - * Called by `McpServer.connect()` to bind this transport to a server. Builds the underlying - * {@linkcode shttpHandler} that {@linkcode handleRequest} delegates to. + * {@linkcode RequestServerTransport.attach} — called by `McpServer.connect()`. Builds the + * underlying {@linkcode shttpHandler} that {@linkcode handleRequest} delegates to. */ - bind(server: McpServerLike): void { + attach(server: McpServerLike): void { this._handler = shttpHandler(server, { session: this._session, backchannel: this._backchannel, @@ -195,6 +202,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { }); } + /** @deprecated Use {@linkcode attach}. */ + bind(server: McpServerLike): void { + this.attach(server); + } + /** * Handles an incoming Web-standard {@linkcode Request} and returns a Web-standard {@linkcode Response}. */ From f49458614669a30a5249cbac3bcb1847afa5100a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 15:00:25 +0000 Subject: [PATCH 40/55] refactor(client): Client extends Dispatcher (was: owns _localDispatcher) Mirrors McpServer's structure: both extend Dispatcher directly. The task-aware dispatch override moves from the anonymous inner class to Client.dispatch(). setRequestHandler becomes an override; the trivial delegate methods (removeRequestHandler/setNotificationHandler/removeNotificationHandler/ fallbackNotificationHandler) are removed in favor of inherited ones. --- packages/client/src/client/client.ts | 154 ++++++++++++--------------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 7b0ab61b4..7e57ddb8f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -35,7 +35,6 @@ import type { Notification, NotificationMethod, NotificationOptions, - NotificationTypeMap, ProtocolOptions, ReadResourceRequest, Request, @@ -214,9 +213,8 @@ export type ClientOptions = ProtocolOptions & { * - 2025-11-compat: {@linkcode connect} accepts the legacy pipe-shaped * {@linkcode Transport} and runs the initialize handshake. */ -export class Client { +export class Client extends Dispatcher { private _ct?: ClientTransport; - private _localDispatcher: Dispatcher; private _capabilities: ClientCapabilities; private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; @@ -243,56 +241,7 @@ export class Client { private _clientInfo: Implementation, private _options?: ClientOptions ) { - // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment - const self = this; - this._localDispatcher = new (class extends Dispatcher { - override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { - const tm = self._taskManager; - if (!tm) { - yield* super.dispatch(request, env); - return; - } - const inboundCtx: InboundContext = { - sessionId: env.sessionId, - sendNotification: (n, opts) => self.notification(n, { ...opts, relatedRequestId: request.id }), - sendRequest: (r, schema, opts) => self._request(r, schema, { ...opts, relatedRequestId: request.id }) - }; - const tr = tm.processInboundRequest(request, inboundCtx); - if (tr.validateInbound) { - try { - tr.validateInbound(); - } catch (error) { - const e = error as { code?: number; message?: string; data?: unknown }; - yield { - kind: 'response', - message: { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, - message: e?.message ?? 'Internal error', - ...(e?.data !== undefined && { data: e.data }) - } - } - }; - return; - } - } - const taskEnv: DispatchEnv = { - ...env, - task: tr.taskContext ?? env.task, - send: (r, opts) => tr.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise - }; - for await (const out of super.dispatch(request, taskEnv)) { - if (out.kind === 'response') { - const routed = await tr.routeResponse(out.message); - if (!routed) yield out; - } else { - await tr.sendNotification({ method: out.message.method, params: out.message.params }); - } - } - } - })(); + super(); this._capabilities = _options?.capabilities ? { ..._options.capabilities } : {}; this._jsonSchemaValidator = _options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; @@ -309,7 +258,59 @@ export class Client { this._capabilities.tasks = wireCapabilities; } - this._localDispatcher.setRequestHandler('ping', async () => ({})); + super.setRequestHandler('ping', async () => ({})); + } + + /** + * Task-aware dispatch for inbound server-initiated requests (sampling, + * elicitation, roots, ping). Threads {@linkcode TaskManager.processInboundRequest} + * so task-augmented requests and queueing work over both pipe and request-shaped paths. + */ + override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { + const tm = this._taskManager; + if (!tm) { + yield* super.dispatch(request, env); + return; + } + const inboundCtx: InboundContext = { + sessionId: env.sessionId, + sendNotification: (n, opts) => this.notification(n, { ...opts, relatedRequestId: request.id }), + sendRequest: (r, schema, opts) => this._request(r, schema, { ...opts, relatedRequestId: request.id }) + }; + const tr = tm.processInboundRequest(request, inboundCtx); + if (tr.validateInbound) { + try { + tr.validateInbound(); + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + yield { + kind: 'response', + message: { + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + message: e?.message ?? 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + } + }; + return; + } + } + const taskEnv: DispatchEnv = { + ...env, + task: tr.taskContext ?? env.task, + send: (r, opts) => tr.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise + }; + for await (const out of super.dispatch(request, taskEnv)) { + if (out.kind === 'response') { + const routed = await tr.routeResponse(out.message); + if (!routed) yield out; + } else { + await tr.sendNotification({ method: out.message.method, params: out.message.params }); + } + } } /** @@ -333,7 +334,7 @@ export class Client { close: () => tm.onClose() } }; - this._ct = pipeAsClientTransport(transport, this._localDispatcher, driverOpts); + this._ct = pipeAsClientTransport(transport, this, driverOpts); this._ct.driver!.onclose = () => this.onclose?.(); this._ct.driver!.onerror = e => this.onerror?.(e); const skipInit = transport.sessionId !== undefined; @@ -382,7 +383,7 @@ export class Client { const stream = ct.subscribe!({ onrequest: async r => { let resp: JSONRPCResultResponse | JSONRPCErrorResponse | undefined; - for await (const out of this._localDispatcher.dispatch(r)) { + for await (const out of this.dispatch(r)) { if (out.kind === 'response') resp = out.message; } return resp ?? { jsonrpc: '2.0', id: r.id, error: { code: -32_601, message: 'Method not found' } }; @@ -393,7 +394,7 @@ export class Client { } }); for await (const n of stream) { - void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)); + void this.dispatchNotification(n).catch(error => this.onerror?.(error)); } } catch (error) { this.onerror?.(error instanceof Error ? error : new Error(String(error))); @@ -413,7 +414,7 @@ export class Client { notification: (n, opts) => this.notification(n, opts), reportError: e => this.onerror?.(e), removeProgressHandler: t => this._ct?.driver?.removeProgressHandler(t), - registerHandler: (method, handler) => this._localDispatcher.setRawRequestHandler(method, handler), + registerHandler: (method, handler) => this.setRawRequestHandler(method, handler), sendOnResponseStream: async () => { throw new SdkError(SdkErrorCode.NotConnected, 'sendOnResponseStream is server-side only'); }, @@ -468,24 +469,24 @@ export class Client { * For `sampling/createMessage` and `elicitation/create`, the handler is automatically * wrapped with schema validation for both the incoming request and the returned result. */ - setRequestHandler( + override setRequestHandler( method: M, handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise ): void; - setRequestHandler( + override setRequestHandler( method: string, paramsSchema: S, handler: (params: StandardSchemaV1.InferOutput, ctx: ClientContext) => Result | Promise ): void; /** @deprecated Pass a method string instead of a Zod request schema. */ - setRequestHandler( + override setRequestHandler( schema: S, handler: ( request: S extends StandardSchemaV1 ? O : JSONRPCRequest, ctx: ClientContext ) => Result | Promise ): void; - setRequestHandler( + override setRequestHandler( methodOrSchema: string | { shape: { method: unknown } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any handlerOrSchema: any, @@ -494,7 +495,7 @@ export class Client { if (maybeHandler !== undefined) { const customMethod = methodOrSchema as string; this._assertRequestHandlerCapability(customMethod); - this._localDispatcher.setRequestHandler(customMethod, handlerOrSchema, maybeHandler); + super.setRequestHandler(customMethod, handlerOrSchema, maybeHandler); return; } const handler = handlerOrSchema; @@ -507,29 +508,14 @@ export class Client { this._assertRequestHandlerCapability(method); if (method === 'elicitation/create') { - this._localDispatcher.setRequestHandler(method, this._wrapElicitationHandler(handler)); + super.setRequestHandler(method, this._wrapElicitationHandler(handler)); return; } if (method === 'sampling/createMessage') { - this._localDispatcher.setRequestHandler(method, this._wrapSamplingHandler(handler)); + super.setRequestHandler(method, this._wrapSamplingHandler(handler)); return; } - this._localDispatcher.setRequestHandler(method, handler); - } - removeRequestHandler(method: string): void { - this._localDispatcher.removeRequestHandler(method); - } - setNotificationHandler( - method: M, - handler: (notification: NotificationTypeMap[M]) => void | Promise - ): void { - this._localDispatcher.setNotificationHandler(method, handler); - } - removeNotificationHandler(method: string): void { - this._localDispatcher.removeNotificationHandler(method); - } - set fallbackNotificationHandler(h: ((n: Notification) => Promise) | undefined) { - this._localDispatcher.fallbackNotificationHandler = h; + super.setRequestHandler(method, handler); } /** Low-level: send one typed request. Runs the MRTR loop. */ @@ -772,14 +758,14 @@ export class Client { relatedTask: options?.relatedTask, resumptionToken: options?.resumptionToken, onresumptiontoken: options?.onresumptiontoken, - onnotification: n => void this._localDispatcher.dispatchNotification(n).catch(error => this.onerror?.(error)), + onnotification: n => void this.dispatchNotification(n).catch(error => this.onerror?.(error)), onresponse: r => { const consumed = this.taskManager.processInboundResponse(r, Number(r.id)).consumed; if (!consumed) this.onerror?.(new Error(`Unmatched response on stream: ${JSON.stringify(r)}`)); }, onrequest: async r => { let resp: JSONRPCResultResponse | JSONRPCErrorResponse | undefined; - for await (const out of this._localDispatcher.dispatch(r)) { + for await (const out of this.dispatch(r)) { if (out.kind === 'response') resp = out.message; } return resp ?? { jsonrpc: '2.0', id: r.id, error: { code: -32_601, message: 'Method not found' } }; @@ -805,7 +791,7 @@ export class Client { const out: Record = {}; for (const [key, ir] of Object.entries(reqs)) { const synthetic: JSONRPCRequest = { jsonrpc: '2.0', id: `mrtr:${key}`, method: ir.method, params: ir.params }; - const resp = await this._localDispatcher.dispatchToResponse(synthetic); + const resp = await this.dispatchToResponse(synthetic); if (isJSONRPCErrorResponse(resp)) { throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); } From edff5ba3ff8193e6abe6afad14a74117be0a8631 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 15:19:30 +0000 Subject: [PATCH 41/55] =?UTF-8?q?refactor:=20AttachableTransport.attach(d,?= =?UTF-8?q?=20opts)=20=E2=86=92=20OutboundChannel;=20McpServer.connect=20d?= =?UTF-8?q?rops=20StreamDriver=20knowledge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpServer.connect() is now: build AttachOptions, then outbound = transport.attach?.(this, opts) ?? attachPipeTransport(transport, this, opts) No StreamDriver import, no isRequestServerTransport, no shape discrimination beyond 'does it have attach'. attachPipeTransport (in streamDriver.ts) is the back-compat helper that wraps a plain pipe Transport. WebStandard/Node SHTTP transports' attach() build shttpHandler for inbound and call attachPipeTransport(this, server, opts) for outbound (their Transport.send() routes via the standalone GET stream). RequestServerTransport renamed AttachableTransport (deprecated alias kept). Client side not unified in this commit: Client uses ClientTransport (fetch-based, per-request hooks for onprogress/onrequest/onresponse) which is genuinely a different contract from OutboundChannel. _startStandaloneStream stays on Client. --- packages/core/src/shared/streamDriver.ts | 28 +++++++++- packages/core/src/shared/transport.ts | 44 ++++++++++----- .../middleware/node/src/streamableHttp.ts | 21 +++++--- packages/server/src/server/mcpServer.ts | 53 ++++++++----------- packages/server/src/server/streamableHttp.ts | 25 ++++++--- 5 files changed, 114 insertions(+), 57 deletions(-) diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 026e8c31a..828c727c7 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -28,7 +28,7 @@ import { parseSchema } from '../util/schema.js'; import type { NotificationOptions, OutboundChannel, OutboundInterceptor, ProgressCallback, RequestOptions } from './context.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; import type { DispatchEnv, Dispatcher } from './dispatcher.js'; -import type { Transport } from './transport.js'; +import type { AttachOptions, Transport } from './transport.js'; type TimeoutInfo = { timeoutId: ReturnType; @@ -420,3 +420,29 @@ export class StreamDriver implements OutboundChannel { } } } + +/** + * Wraps a plain pipe-shaped {@linkcode Transport} in a {@linkcode StreamDriver} + * and starts it. This is the back-compat path for transports that don't implement + * `attach()`: callers (`McpServer.connect`, `Client.connect`) use this helper + * instead of importing `StreamDriver` themselves. + */ +export async function attachPipeTransport( + pipe: Transport, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- adapter is context-agnostic + dispatcher: Dispatcher, + options?: AttachOptions +): Promise { + const driver = new StreamDriver(dispatcher, pipe, { + supportedProtocolVersions: options?.supportedProtocolVersions, + debouncedNotificationMethods: options?.debouncedNotificationMethods, + interceptor: options?.interceptor, + buildEnv: options?.buildEnv + }); + if (options?.onclose || options?.onerror) { + driver.onclose = options.onclose; + driver.onerror = options.onerror; + } + await driver.start(); + return driver; +} diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 8ec0cf71e..94038adba 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,5 +1,6 @@ import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; -import type { Dispatcher } from './dispatcher.js'; +import type { OutboundChannel, OutboundInterceptor } from './context.js'; +import type { DispatchEnv, Dispatcher } from './dispatcher.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -135,23 +136,40 @@ export interface Transport { } /** - * A request-shaped server transport: instead of feeding a persistent pipe to a - * {@linkcode StreamDriver}, it accepts requests one at a time (e.g. HTTP POSTs) - * and dispatches each via the attached {@linkcode Dispatcher}. + * Options threaded through {@linkcode AttachableTransport.attach} so the transport + * can wire itself without the caller knowing what kind of adapter it builds. + */ +export type AttachOptions = { + supportedProtocolVersions?: string[]; + debouncedNotificationMethods?: string[]; + interceptor?: OutboundInterceptor; + buildEnv?: (extra: MessageExtraInfo | undefined, base: DispatchEnv) => DispatchEnv; + onclose?: () => void; + onerror?: (error: Error) => void; +}; + +/** + * A transport that knows how to wire itself to a {@linkcode Dispatcher}. Unifies + * pipe-shaped and request-shaped transports: `connect()` calls `attach()` and uses + * whatever {@linkcode OutboundChannel} it returns (or none). * - * Concrete implementations expose their own per-request entry point - * (e.g. `handleRequest(req, res)`); only {@linkcode attach} is part of this contract. + * Transports that don't implement this are wrapped by {@linkcode attachPipeTransport} + * (back-compat for plain {@linkcode Transport}). */ -export interface RequestServerTransport { +export interface AttachableTransport { /** - * Gives the transport a reference to the dispatcher (typically a `McpServer`) - * so its per-request handler can call `dispatcher.dispatch()`. + * Wire this transport to the given dispatcher. The transport routes inbound + * messages to `dispatcher.dispatch()`; returns an {@linkcode OutboundChannel} + * for outbound calls (or `undefined` if there's no outbound path). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- concrete impls narrow the dispatcher type - attach(dispatcher: Dispatcher): void; + attach(dispatcher: Dispatcher, options?: AttachOptions): Promise; } -/** Type guard for {@linkcode RequestServerTransport}. */ -export function isRequestServerTransport(t: unknown): t is RequestServerTransport { - return typeof (t as RequestServerTransport | undefined)?.attach === 'function'; +/** @deprecated Use {@linkcode AttachableTransport}. Kept for one release for migration. */ +export type RequestServerTransport = AttachableTransport; + +/** @deprecated Check `typeof t.attach === 'function'` directly. */ +export function isRequestServerTransport(t: unknown): t is AttachableTransport { + return typeof (t as AttachableTransport | undefined)?.attach === 'function'; } diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index b7d6f9ebf..69f2e51eb 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -10,7 +10,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, RequestServerTransport, Transport } from '@modelcontextprotocol/core'; +import type { + AttachableTransport, + AttachOptions, + AuthInfo, + JSONRPCMessage, + MessageExtraInfo, + OutboundChannel, + RequestId, + Transport +} from '@modelcontextprotocol/core'; import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; @@ -84,7 +93,7 @@ export function toNodeHttpHandler( * }); * ``` */ -export class NodeStreamableHTTPServerTransport implements Transport, RequestServerTransport { +export class NodeStreamableHTTPServerTransport implements Transport, AttachableTransport { private _webStandardTransport: WebStandardStreamableHTTPServerTransport; private _requestListener: ReturnType; // Store auth and parsedBody per request for passing through to handleRequest @@ -151,15 +160,15 @@ export class NodeStreamableHTTPServerTransport implements Transport, RequestServ } /** - * {@linkcode RequestServerTransport.attach} — called by `McpServer.connect()`. + * {@linkcode AttachableTransport.attach} — called by `McpServer.connect()`. */ - attach(server: Parameters[0]): void { - this._webStandardTransport.attach(server); + attach(server: Parameters[0], options?: AttachOptions): Promise { + return this._webStandardTransport.attach(server, options); } /** @deprecated Use {@linkcode attach}. */ bind(server: Parameters[0]): void { - this.attach(server); + void this.attach(server); } /** diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index c3512348e..48affd43f 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -33,13 +33,14 @@ import type { Notification, NotificationMethod, NotificationOptions, + AttachableTransport, + AttachOptions, OutboundChannel, ProtocolOptions, Request, RequestId, RequestMethod, RequestOptions, - RequestServerTransport, RequestTypeMap, ResourceUpdatedNotification, Result, @@ -49,7 +50,6 @@ import type { ServerResult, StandardSchemaV1, StandardSchemaWithJSON, - StreamDriverOptions, TaskManagerHost, TaskManagerOptions, ToolAnnotations, @@ -70,9 +70,9 @@ import { ElicitResultSchema, EmptyResultSchema, extractTaskManagerOptions, + attachPipeTransport, getResultSchema, isJSONRPCRequest, - isRequestServerTransport, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -84,7 +84,6 @@ import { RELATED_TASK_META_KEY, SdkError, SdkErrorCode, - StreamDriver, SUPPORTED_PROTOCOL_VERSIONS, TaskManager } from '@modelcontextprotocol/core'; @@ -400,40 +399,34 @@ export class McpServer extends Dispatcher implements RegistriesHo // ─────────────────────────────────────────────────────────────────────── /** - * Attaches to the given transport, starts it, and starts listening for messages. - * - * For pipe-shaped {@linkcode Transport}s (stdio, WebSocket, InMemory), builds a - * {@linkcode StreamDriver} internally and stores it as the {@linkcode OutboundChannel}. - * - * Transports that also implement {@linkcode RequestServerTransport} (e.g. - * {@linkcode WebStandardStreamableHTTPServerTransport}) are attached to this - * server first so their `handleRequest` can dispatch directly via - * {@linkcode shttpHandler}; the {@linkcode StreamDriver} is still built so - * outbound `notification()`/`request()` route through `transport.send()`. + * Attaches to the given transport. The transport handles its own wiring via + * {@linkcode AttachableTransport.attach | attach()}; for plain pipe-shaped + * {@linkcode Transport}s without `attach()`, {@linkcode attachPipeTransport} + * provides the back-compat wrapping. McpServer itself is agnostic to which + * adapter is used — it just stores whatever {@linkcode OutboundChannel} is returned. */ - async connect(transport: Transport | (Transport & RequestServerTransport)): Promise { - if (isRequestServerTransport(transport)) { - transport.attach(this); - } - const driverOpts: StreamDriverOptions = { + async connect(transport: Transport | AttachableTransport): Promise { + const opts: AttachOptions = { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), interceptor: { - request: (jr, opts, id, settle, reject) => this._taskManager.processOutboundRequest(jr, opts, id, settle, reject), - notification: (n, opts) => this._taskManager.processOutboundNotification(n, opts), + request: (jr, o, id, settle, reject) => this._taskManager.processOutboundRequest(jr, o, id, settle, reject), + notification: (n, o) => this._taskManager.processOutboundNotification(n, o), response: (r, id) => this._taskManager.processInboundResponse(r, id), close: () => this._taskManager.onClose() - } - }; - const driver = new StreamDriver(this, transport, driverOpts); - this._outbound = driver; - driver.onclose = () => { - if (this._outbound === driver) this._outbound = undefined; - this.onclose?.(); + }, + onclose: () => { + if (this._outbound === outbound) this._outbound = undefined; + this.onclose?.(); + }, + onerror: e => this.onerror?.(e) }; - driver.onerror = error => this.onerror?.(error); - await driver.start(); + const outbound = + typeof (transport as AttachableTransport).attach === 'function' + ? await (transport as AttachableTransport).attach(this, opts) + : await attachPipeTransport(transport as Transport, this, opts); + this._outbound = outbound; } /** diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 354529d20..b1a908d65 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -13,14 +13,21 @@ */ import type { + AttachableTransport, + AttachOptions, AuthInfo, JSONRPCMessage, MessageExtraInfo, - RequestServerTransport, + OutboundChannel, Transport, TransportSendOptions } from '@modelcontextprotocol/core'; -import { isJSONRPCErrorResponse, isJSONRPCResultResponse, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { + attachPipeTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; import { Backchannel2511 } from './backchannel2511.js'; import { SessionCompat } from './sessionCompat.js'; @@ -152,7 +159,7 @@ export interface HandleRequestOptions { * {@linkcode Transport} interface methods route outbound messages through the * per-session {@linkcode Backchannel2511}. */ -export class WebStandardStreamableHTTPServerTransport implements Transport, RequestServerTransport { +export class WebStandardStreamableHTTPServerTransport implements Transport, AttachableTransport { private _options: WebStandardStreamableHTTPServerTransportOptions; private _session?: SessionCompat; private _backchannel = new Backchannel2511(); @@ -187,10 +194,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport, Requ } /** - * {@linkcode RequestServerTransport.attach} — called by `McpServer.connect()`. Builds the - * underlying {@linkcode shttpHandler} that {@linkcode handleRequest} delegates to. + * {@linkcode AttachableTransport.attach} — called by `McpServer.connect()`. Builds the + * underlying {@linkcode shttpHandler} for inbound request handling, and an outbound + * channel (via this transport's pipe-shaped {@linkcode Transport.send | send()}) so + * server-initiated requests/notifications reach connected clients. */ - attach(server: McpServerLike): void { + async attach(server: McpServerLike, options?: AttachOptions): Promise { this._handler = shttpHandler(server, { session: this._session, backchannel: this._backchannel, @@ -200,11 +209,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport, Requ supportedProtocolVersions: this._supportedProtocolVersions, onerror: e => this.onerror?.(e) }); + // McpServerLike is structurally what StreamDriver needs (dispatch + dispatchNotification). + return attachPipeTransport(this, server as Parameters[1], options); } /** @deprecated Use {@linkcode attach}. */ bind(server: McpServerLike): void { - this.attach(server); + void this.attach(server); } /** From 1a658e2442d17135f1c04d18970d411cabfc5ac1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 17:09:16 +0000 Subject: [PATCH 42/55] refactor(core): callback-based RequestTransport; rename Transport -> ChannelTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transports no longer reference Dispatcher. McpServer.connect() sets the transport's onrequest/onnotification/onresponse callback slots (mirroring v1's onmessage pattern). The pair is now ChannelTransport (pipe: stdio/WS/InMemory) and RequestTransport (req/response: Streamable HTTP). - ChannelTransport: same shape as the old Transport interface; Transport kept as deprecated alias. - RequestTransport: { onrequest?, onnotification?, onresponse?, close, notify? (2025-11 standalone-GET back-compat), request? (same) }. - isRequestTransport(t) discriminates by 'onrequest' in t (transports declare the property so it's present before connect()). - shttpHandler signature: (cb: ShttpCallbacks, opts) — McpServerLike kept as deprecated alias. - WebStandard/NodeStreamableHTTPServerTransport drop attach(d)/bind(); keep the ChannelTransport costume for back-compat. - AttachableTransport, RequestServerTransport, isRequestServerTransport removed. - attachPipeTransport remains as the internal helper for the channel path. --- docs/migration-SKILL.md | 7 +- docs/migration.md | 16 +++ packages/core/src/exports/public/index.ts | 4 +- packages/core/src/shared/transport.ts | 95 +++++++++++---- .../middleware/node/src/streamableHttp.ts | 46 +++++-- packages/server/src/server/mcpServer.ts | 113 +++++++++++++----- packages/server/src/server/shttpHandler.ts | 43 ++++--- packages/server/src/server/streamableHttp.ts | 106 ++++++++-------- .../server/test/server/shttpHandler.test.ts | 23 ++-- 9 files changed, 306 insertions(+), 147 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index a37b5e206..ad15c5014 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -75,9 +75,10 @@ Notes: ## 4. Renamed Symbols -| v1 symbol | v2 symbol | v2 package | -| ------------------------------- | ----------------------------------- | ---------------------------- | -| `StreamableHTTPServerTransport` | `NodeStreamableHTTPServerTransport` | `@modelcontextprotocol/node` | +| v1 symbol | v2 symbol | v2 package | +| ------------------------------- | ----------------------------------- | ------------------------------------- | +| `StreamableHTTPServerTransport` | `NodeStreamableHTTPServerTransport` | `@modelcontextprotocol/node` | +| `Transport` (interface) | `ChannelTransport` | `@modelcontextprotocol/{client,server}` (deprecated alias `Transport` kept) | ## 5. Removed / Renamed Type Aliases and Symbols diff --git a/docs/migration.md b/docs/migration.md index 7cb7d58f6..129c90883 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,6 +86,22 @@ npm install @modelcontextprotocol/express # Express npm install @modelcontextprotocol/hono # Hono ``` +### `Transport` interface renamed to `ChannelTransport`; `RequestTransport` added + +The pipe-shaped `Transport` interface has been renamed `ChannelTransport`. A new `RequestTransport` interface (callback-based, for request/response transports like Streamable HTTP) sits alongside it. `connect()` accepts either. + +`Transport` is kept as a deprecated type alias of `ChannelTransport`, so existing code compiles unchanged. + +```typescript +// Before (v1) +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; + +// After (v2) +import type { ChannelTransport, RequestTransport } from '@modelcontextprotocol/server'; +``` + +If you implemented a custom transport by implementing `Transport`, switch to `implements ChannelTransport` (same shape) or, for HTTP-style transports, `implements RequestTransport`. + ### `StreamableHTTPServerTransport` renamed `StreamableHTTPServerTransport` has been renamed to `NodeStreamableHTTPServerTransport` and moved to `@modelcontextprotocol/node`. diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 2d88fa44e..d3438cc74 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -72,8 +72,8 @@ export { takeResult, toArrayAsync } from '../../shared/responseMessage.js'; export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; // Transport types (NOT normalizeHeaders) -export type { FetchLike, Transport, TransportSendOptions } from '../../shared/transport.js'; -export { createFetchWithInit } from '../../shared/transport.js'; +export type { ChannelTransport, FetchLike, RequestTransport, Transport, TransportSendOptions } from '../../shared/transport.js'; +export { createFetchWithInit, isRequestTransport } from '../../shared/transport.js'; // URI Template export type { Variables } from '../../shared/uriTemplate.js'; diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 94038adba..8711b80ee 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,6 +1,14 @@ -import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; -import type { OutboundChannel, OutboundInterceptor } from './context.js'; -import type { DispatchEnv, Dispatcher } from './dispatcher.js'; +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + MessageExtraInfo, + RequestId +} from '../types/index.js'; +import type { OutboundInterceptor } from './context.js'; +import type { DispatchEnv } from './dispatcher.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -71,9 +79,13 @@ export type TransportSendOptions = { onresumptiontoken?: ((token: string) => void) | undefined; }; /** - * Describes the minimal contract for an MCP transport that a client or server can communicate over. + * Describes the minimal contract for a persistent, bidirectional MCP message channel + * (stdio, WebSocket, in-memory). The SDK wraps this in a {@linkcode StreamDriver} to + * do request/response correlation. + * + * For request/response-shaped transports (Streamable HTTP), see {@linkcode RequestTransport}. */ -export interface Transport { +export interface ChannelTransport { /** * Starts processing messages on the transport, including any connection steps that might need to be taken. * @@ -135,9 +147,12 @@ export interface Transport { setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; } +/** @deprecated Use {@linkcode ChannelTransport}. Renamed for clarity alongside {@linkcode RequestTransport}; kept as an alias. */ +export type Transport = ChannelTransport; + /** - * Options threaded through {@linkcode AttachableTransport.attach} so the transport - * can wire itself without the caller knowing what kind of adapter it builds. + * Options McpServer passes when wiring a {@linkcode ChannelTransport} via {@linkcode attachPipeTransport}. + * @internal */ export type AttachOptions = { supportedProtocolVersions?: string[]; @@ -149,27 +164,59 @@ export type AttachOptions = { }; /** - * A transport that knows how to wire itself to a {@linkcode Dispatcher}. Unifies - * pipe-shaped and request-shaped transports: `connect()` calls `attach()` and uses - * whatever {@linkcode OutboundChannel} it returns (or none). + * A request/response-shaped server transport (e.g. Streamable HTTP). Unlike + * {@linkcode ChannelTransport}, there is no persistent pipe: the transport receives + * one HTTP request at a time and calls {@linkcode onrequest} for each, streaming the + * yielded messages back as the HTTP response. * - * Transports that don't implement this are wrapped by {@linkcode attachPipeTransport} - * (back-compat for plain {@linkcode Transport}). + * The `on*` callback slots are set by `McpServer.connect()`; the transport calls them + * per inbound message. The transport itself never imports or references a `Dispatcher`. */ -export interface AttachableTransport { +export interface RequestTransport { /** - * Wire this transport to the given dispatcher. The transport routes inbound - * messages to `dispatcher.dispatch()`; returns an {@linkcode OutboundChannel} - * for outbound calls (or `undefined` if there's no outbound path). + * Callback slot for inbound JSON-RPC requests. Set by `McpServer.connect()`. + * The transport calls this per request and writes the yielded messages + * (notifications + one terminal response) to the HTTP response stream. + * + * Transports MUST declare this property (initialised to `undefined`) so + * {@linkcode isRequestTransport} can discriminate before `connect()` runs. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- concrete impls narrow the dispatcher type - attach(dispatcher: Dispatcher, options?: AttachOptions): Promise; -} + onrequest?: ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined; + + /** Callback slot for inbound notifications (e.g. `notifications/initialized`). */ + onnotification?: (n: JSONRPCNotification) => void | Promise; + + /** + * Callback slot for inbound JSON-RPC responses (a client POSTing back the answer to + * a server-initiated request). Returns `true` if the response was claimed. + */ + onresponse?: (r: JSONRPCResultResponse | JSONRPCErrorResponse) => boolean; + + /** Aborts in-flight handlers and releases resources (open SSE streams, session map). */ + close(): Promise; -/** @deprecated Use {@linkcode AttachableTransport}. Kept for one release for migration. */ -export type RequestServerTransport = AttachableTransport; + /** + * 2025-11 back-compat: write an unsolicited notification to the session's standalone + * GET subscription stream. In 2026-06+ clients open `subscriptions/listen` instead. + */ + notify?(n: JSONRPCNotification): Promise; + + /** + * 2025-11 back-compat: send an unsolicited server→client request via the standalone + * GET stream and await the client's POSTed-back response. In 2026-06+ server→client + * requests are per-inbound-request via `env.send` (MRTR). + */ + request?(r: JSONRPCRequest): Promise; + + /** Callback for when the transport is closed for any reason. */ + onclose?: (() => void) | undefined; + /** Callback for transport-level errors. */ + onerror?: ((error: Error) => void) | undefined; + /** Session id (single-session compat mode). */ + sessionId?: string | undefined; +} -/** @deprecated Check `typeof t.attach === 'function'` directly. */ -export function isRequestServerTransport(t: unknown): t is AttachableTransport { - return typeof (t as AttachableTransport | undefined)?.attach === 'function'; +/** Type guard distinguishing {@linkcode RequestTransport} from {@linkcode ChannelTransport}. */ +export function isRequestTransport(t: ChannelTransport | RequestTransport): t is RequestTransport { + return 'onrequest' in t; } diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 69f2e51eb..74fe404a8 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -11,14 +11,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; import type { - AttachableTransport, - AttachOptions, AuthInfo, + ChannelTransport, + DispatchEnv, + JSONRPCErrorResponse, JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, MessageExtraInfo, - OutboundChannel, RequestId, - Transport + RequestTransport } from '@modelcontextprotocol/core'; import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; @@ -93,7 +96,7 @@ export function toNodeHttpHandler( * }); * ``` */ -export class NodeStreamableHTTPServerTransport implements Transport, AttachableTransport { +export class NodeStreamableHTTPServerTransport implements ChannelTransport, RequestTransport { private _webStandardTransport: WebStandardStreamableHTTPServerTransport; private _requestListener: ReturnType; // Store auth and parsedBody per request for passing through to handleRequest @@ -159,16 +162,33 @@ export class NodeStreamableHTTPServerTransport implements Transport, AttachableT return this._webStandardTransport.onmessage; } - /** - * {@linkcode AttachableTransport.attach} — called by `McpServer.connect()`. - */ - attach(server: Parameters[0], options?: AttachOptions): Promise { - return this._webStandardTransport.attach(server, options); + // RequestTransport callback slots — delegate to the wrapped web-standard transport. + get onrequest(): ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined { + return this._webStandardTransport.onrequest; + } + set onrequest(h: ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined) { + this._webStandardTransport.onrequest = h; + } + get onnotification(): ((n: JSONRPCNotification) => void | Promise) | undefined { + return this._webStandardTransport.onnotification; + } + set onnotification(h: ((n: JSONRPCNotification) => void | Promise) | undefined) { + this._webStandardTransport.onnotification = h; + } + get onresponse(): ((r: JSONRPCResultResponse | JSONRPCErrorResponse) => boolean) | undefined { + return this._webStandardTransport.onresponse; + } + set onresponse(h: ((r: JSONRPCResultResponse | JSONRPCErrorResponse) => boolean) | undefined) { + this._webStandardTransport.onresponse = h; } - /** @deprecated Use {@linkcode attach}. */ - bind(server: Parameters[0]): void { - void this.attach(server); + /** {@linkcode RequestTransport.notify} — delegates to the wrapped transport. */ + notify(n: JSONRPCNotification): Promise { + return this._webStandardTransport.notify(n); + } + /** {@linkcode RequestTransport.request} — delegates to the wrapped transport. */ + request(r: JSONRPCRequest): Promise { + return this._webStandardTransport.request(r); } /** diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 48affd43f..310a9c8ca 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -3,6 +3,7 @@ import type { BaseContext, CallToolRequest, CallToolResult, + ChannelTransport, ClientCapabilities, CreateMessageRequest, CreateMessageRequestParamsBase, @@ -33,18 +34,18 @@ import type { Notification, NotificationMethod, NotificationOptions, - AttachableTransport, - AttachOptions, OutboundChannel, ProtocolOptions, Request, RequestId, RequestMethod, RequestOptions, + RequestTransport, RequestTypeMap, ResourceUpdatedNotification, Result, ResultTypeMap, + SchemaOutput, ServerCapabilities, ServerContext, ServerResult, @@ -61,6 +62,7 @@ import type { import { assertClientRequestTaskCapability, assertToolsCallTaskCapability, + attachPipeTransport, CallToolRequestSchema, CallToolResultSchema, CreateMessageResultSchema, @@ -70,9 +72,9 @@ import { ElicitResultSchema, EmptyResultSchema, extractTaskManagerOptions, - attachPipeTransport, getResultSchema, isJSONRPCRequest, + isRequestTransport, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -395,40 +397,95 @@ export class McpServer extends Dispatcher implements RegistriesHo } // ─────────────────────────────────────────────────────────────────────── - // Persistent-pipe transport (compat: builds a StreamDriver) + // Transport wiring // ─────────────────────────────────────────────────────────────────────── /** - * Attaches to the given transport. The transport handles its own wiring via - * {@linkcode AttachableTransport.attach | attach()}; for plain pipe-shaped - * {@linkcode Transport}s without `attach()`, {@linkcode attachPipeTransport} - * provides the back-compat wrapping. McpServer itself is agnostic to which - * adapter is used — it just stores whatever {@linkcode OutboundChannel} is returned. + * Wires this server to the given transport. + * + * - For {@linkcode RequestTransport} (Streamable HTTP): sets the transport's + * `onrequest`/`onnotification`/`onresponse` callback slots so it can route inbound + * messages here, and builds an {@linkcode OutboundChannel} from the transport's + * optional `notify`/`request` methods. + * - For {@linkcode ChannelTransport} (stdio/WebSocket/InMemory): wraps it in a + * {@linkcode StreamDriver} via {@linkcode attachPipeTransport}. */ - async connect(transport: Transport | AttachableTransport): Promise { - const opts: AttachOptions = { - supportedProtocolVersions: this._supportedProtocolVersions, - debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), - interceptor: { - request: (jr, o, id, settle, reject) => this._taskManager.processOutboundRequest(jr, o, id, settle, reject), - notification: (n, o) => this._taskManager.processOutboundNotification(n, o), - response: (r, id) => this._taskManager.processInboundResponse(r, id), - close: () => this._taskManager.onClose() - }, - onclose: () => { + async connect(transport: ChannelTransport | RequestTransport): Promise { + let outbound: OutboundChannel | undefined; + if (isRequestTransport(transport)) { + transport.onrequest = (req, env) => this.handle(req, env); + transport.onnotification = n => this.dispatchNotification(n); + transport.onresponse = r => this.dispatchInboundResponse(r); + + const prevClose = transport.onclose; + transport.onclose = () => { + prevClose?.(); if (this._outbound === outbound) this._outbound = undefined; this.onclose?.(); - }, - onerror: e => this.onerror?.(e) - }; - const outbound = - typeof (transport as AttachableTransport).attach === 'function' - ? await (transport as AttachableTransport).attach(this, opts) - : await attachPipeTransport(transport as Transport, this, opts); + }; + const prevErr = transport.onerror; + transport.onerror = e => { + prevErr?.(e); + this.onerror?.(e); + }; + + const noOutbound = (kind: string) => () => + Promise.reject( + new SdkError( + SdkErrorCode.NotConnected, + `Transport does not support out-of-band ${kind}; use ctx.mcpReq inside a handler.` + ) + ); + outbound = { + close: () => transport.close(), + notification: transport.notify + ? async (n, opts) => { + const out = await this._taskManager.processOutboundNotification( + { jsonrpc: '2.0', ...n } as JSONRPCNotification, + opts + ); + if (!out.queued && out.jsonrpcNotification) await transport.notify!(out.jsonrpcNotification); + } + : noOutbound('notifications'), + request: transport.request + ? async (r, schema, _opts) => { + const id = this._nextOutboundId++; + const resp = await transport.request!({ + jsonrpc: '2.0', + id, + method: r.method, + params: r.params + } as JSONRPCRequest); + if ('error' in resp) throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); + const parsed = parseSchema(schema, resp.result); + if (!parsed.success) throw parsed.error; + return parsed.data as SchemaOutput; + } + : noOutbound('requests') + }; + } else { + outbound = await attachPipeTransport(transport, this, { + supportedProtocolVersions: this._supportedProtocolVersions, + debouncedNotificationMethods: this._options?.debouncedNotificationMethods, + buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), + interceptor: { + request: (jr, o, id, settle, reject) => this._taskManager.processOutboundRequest(jr, o, id, settle, reject), + notification: (n, o) => this._taskManager.processOutboundNotification(n, o), + response: (r, id) => this._taskManager.processInboundResponse(r, id), + close: () => this._taskManager.onClose() + }, + onclose: () => { + if (this._outbound === outbound) this._outbound = undefined; + this.onclose?.(); + }, + onerror: e => this.onerror?.(e) + }); + } this._outbound = outbound; } + private _nextOutboundId = 0; + /** * Closes the connection. */ diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index ab26dcedd..16c272346 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -1,7 +1,6 @@ import type { AuthInfo, DispatchEnv, - DispatchOutput, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, @@ -55,16 +54,23 @@ export interface EventStore { } /** - * Structural interface for the server passed to {@linkcode shttpHandler}. Matches the - * {@linkcode Dispatcher} surface; `McpServer` (which extends `Dispatcher`) satisfies it. + * Callback bundle {@linkcode shttpHandler} uses to route inbound messages. Matches the + * inbound side of {@linkcode @modelcontextprotocol/core!shared/transport.RequestTransport | RequestTransport}; + * the handler reads these slots at call time, so a transport can pass `this` and have + * `connect()` set them later. */ -export interface McpServerLike { - dispatch(request: JSONRPCRequest, env?: DispatchEnv): AsyncIterable; - dispatchNotification(notification: JSONRPCNotification): Promise; - /** Optional: route incoming JSON-RPC responses to a task-aware resolver. Returns true if handled. */ - dispatchInboundResponse?(response: JSONRPCResultResponse | JSONRPCErrorResponse): boolean; +export interface ShttpCallbacks { + /** Called per inbound JSON-RPC request; yields notifications then one terminal response. */ + onrequest?: ((request: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined; + /** Called per inbound JSON-RPC notification. */ + onnotification?: (notification: JSONRPCNotification) => void | Promise; + /** Called per inbound JSON-RPC response (client POSTing back to a server-initiated request). Returns `true` if claimed. */ + onresponse?: (response: JSONRPCResultResponse | JSONRPCErrorResponse) => boolean; } +/** @deprecated Use {@linkcode ShttpCallbacks}. */ +export type McpServerLike = ShttpCallbacks; + /** * Options for {@linkcode shttpHandler}. */ @@ -166,14 +172,14 @@ const SSE_HEADERS: Record = { /** * Creates a Web-standard `(Request) => Promise` handler for the MCP Streamable HTTP - * transport, driven by {@linkcode McpServerLike.dispatch} per request. + * transport, driven by {@linkcode ShttpCallbacks.onrequest} per request. * * No `_streamMapping`, `_requestToStreamMapping`, or `relatedRequestId` routing — the response * stream is in lexical scope of the request that opened it. Session lifecycle (when enabled) * lives in the supplied {@linkcode SessionCompat}, not on this handler. */ export function shttpHandler( - server: McpServerLike, + cb: ShttpCallbacks, options: ShttpHandlerOptions = {} ): (req: Request, extra?: ShttpRequestExtra) => Promise { const enableJsonResponse = options.enableJsonResponse ?? false; @@ -278,11 +284,11 @@ export function shttpHandler( ); for (const n of notifications) { - void server.dispatchNotification(n).catch(error => onerror?.(error as Error)); + void Promise.resolve(cb.onnotification?.(n)).catch(error => onerror?.(error as Error)); } for (const r of responses) { - if (server.dispatchInboundResponse?.(r)) continue; + if (cb.onresponse?.(r)) continue; if (backchannel && sessionId !== undefined) backchannel.handleResponse(sessionId, r); } @@ -290,6 +296,11 @@ export function shttpHandler( return new Response(null, { status: 202 }); } + const onrequest = cb.onrequest; + if (!onrequest) { + return jsonError(500, -32_603, 'Transport not connected — call mcp.connect(transport) first.'); + } + const initReq = messages.find(m => isInitializeRequest(m)); const clientProtocolVersion = initReq && isInitializeRequest(initReq) @@ -302,8 +313,8 @@ export function shttpHandler( if (enableJsonResponse) { const out: JSONRPCMessage[] = []; for (const r of requests) { - for await (const item of server.dispatch(r, baseEnv)) { - if (item.kind === 'response') out.push(item.message); + for await (const msg of onrequest(r, baseEnv)) { + if (isJSONRPCResultResponse(msg) || isJSONRPCErrorResponse(msg)) out.push(msg); } } const headers: Record = { 'Content-Type': 'application/json' }; @@ -351,8 +362,8 @@ export function shttpHandler( try { await writePrimingEvent(controller, encoder, streamId, clientProtocolVersion); for (const r of requests) { - for await (const out of server.dispatch(r, env)) { - await emit(controller, encoder, streamId, out.message); + for await (const msg of onrequest(r, env)) { + await emit(controller, encoder, streamId, msg); } } } catch (error) { diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index b1a908d65..f5e707872 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -13,25 +13,23 @@ */ import type { - AttachableTransport, - AttachOptions, AuthInfo, + ChannelTransport, + DispatchEnv, + JSONRPCErrorResponse, JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, MessageExtraInfo, - OutboundChannel, - Transport, + RequestTransport, TransportSendOptions } from '@modelcontextprotocol/core'; -import { - attachPipeTransport, - isJSONRPCErrorResponse, - isJSONRPCResultResponse, - SUPPORTED_PROTOCOL_VERSIONS -} from '@modelcontextprotocol/core'; +import { isJSONRPCErrorResponse, isJSONRPCResultResponse, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { Backchannel2511 } from './backchannel2511.js'; import { SessionCompat } from './sessionCompat.js'; -import type { McpServerLike, ShttpRequestExtra } from './shttpHandler.js'; +import type { ShttpRequestExtra } from './shttpHandler.js'; import { shttpHandler, STATELESS_GET_KEY } from './shttpHandler.js'; export type { EventId, EventStore, StreamId } from './shttpHandler.js'; @@ -159,11 +157,11 @@ export interface HandleRequestOptions { * {@linkcode Transport} interface methods route outbound messages through the * per-session {@linkcode Backchannel2511}. */ -export class WebStandardStreamableHTTPServerTransport implements Transport, AttachableTransport { +export class WebStandardStreamableHTTPServerTransport implements ChannelTransport, RequestTransport { private _options: WebStandardStreamableHTTPServerTransportOptions; private _session?: SessionCompat; private _backchannel = new Backchannel2511(); - private _handler?: (req: Request, extra?: ShttpRequestExtra) => Promise; + private _handler: (req: Request, extra?: ShttpRequestExtra) => Promise; private _started = false; private _closed = false; private _supportedProtocolVersions: string[]; @@ -173,6 +171,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport, Atta onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + /** {@linkcode RequestTransport.onrequest} — set by `McpServer.connect()`. Declared so {@linkcode isRequestTransport} matches. */ + onrequest: ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined = undefined; + /** {@linkcode RequestTransport.onnotification} — set by `McpServer.connect()`. */ + onnotification?: (n: JSONRPCNotification) => void | Promise; + /** {@linkcode RequestTransport.onresponse} — set by `McpServer.connect()`. */ + onresponse?: (r: JSONRPCResultResponse | JSONRPCErrorResponse) => boolean; + constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { this._options = options; this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; @@ -191,16 +196,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport, Atta } }); } - } - - /** - * {@linkcode AttachableTransport.attach} — called by `McpServer.connect()`. Builds the - * underlying {@linkcode shttpHandler} for inbound request handling, and an outbound - * channel (via this transport's pipe-shaped {@linkcode Transport.send | send()}) so - * server-initiated requests/notifications reach connected clients. - */ - async attach(server: McpServerLike, options?: AttachOptions): Promise { - this._handler = shttpHandler(server, { + // shttpHandler reads onrequest/onnotification/onresponse from `this` at call time, + // so connect() can set them after construction. + this._handler = shttpHandler(this, { session: this._session, backchannel: this._backchannel, eventStore: this._options.eventStore, @@ -209,29 +207,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport, Atta supportedProtocolVersions: this._supportedProtocolVersions, onerror: e => this.onerror?.(e) }); - // McpServerLike is structurally what StreamDriver needs (dispatch + dispatchNotification). - return attachPipeTransport(this, server as Parameters[1], options); - } - - /** @deprecated Use {@linkcode attach}. */ - bind(server: McpServerLike): void { - void this.attach(server); } /** * Handles an incoming Web-standard {@linkcode Request} and returns a Web-standard {@linkcode Response}. */ async handleRequest(req: Request, options: HandleRequestOptions = {}): Promise { - if (!this._handler) { - return Response.json( - { - jsonrpc: '2.0', - error: { code: -32_603, message: 'Transport not bound to a server. Call server.connect(transport) first.' }, - id: null - }, - { status: 500 } - ); - } if (this._options.enableDnsRebindingProtection) { const err = this._validateDnsRebinding(req); if (err) return err; @@ -263,13 +244,45 @@ export class WebStandardStreamableHTTPServerTransport implements Transport, Atta } /** - * Sends a message over the transport. Outbound responses are routed to the - * {@linkcode Backchannel2511} resolver map (the inverse direction of `env.send`); - * notifications and server-initiated requests go on the session's standalone GET stream. + * {@linkcode RequestTransport.notify} — write an unsolicited notification to the + * session's standalone GET subscription stream (2025-11 back-compat). + */ + async notify(n: JSONRPCNotification): Promise { + if (this._closed) return; + const sessionId = this.sessionId ?? STATELESS_GET_KEY; + const written = this._backchannel.writeStandalone(sessionId, n); + if (!written && this._options.eventStore) { + await this._options.eventStore.storeEvent('_GET_stream', n); + } + } + + /** + * {@linkcode RequestTransport.request} — send an unsolicited server→client request via + * the standalone GET stream and await the client's POSTed-back response (2025-11 back-compat). + */ + request(r: JSONRPCRequest): Promise { + const sessionId = this.sessionId ?? STATELESS_GET_KEY; + const send = this._backchannel.makeEnvSend(sessionId, msg => void this._backchannel.writeStandalone(sessionId, msg)); + return send({ method: r.method, params: r.params }, {}).then( + result => ({ jsonrpc: '2.0', id: r.id, result }) as JSONRPCResultResponse, + (error: { code?: number; message?: string; data?: unknown }) => ({ + jsonrpc: '2.0', + id: r.id, + error: { + code: error.code ?? -32_603, + message: error.message ?? String(error), + ...(error.data !== undefined && { data: error.data }) + } + }) + ); + } + + /** + * {@linkcode ChannelTransport.send} (back-compat costume). Outbound responses route to the + * {@linkcode Backchannel2511} resolver map; notifications and server-initiated requests go + * on the session's standalone GET stream. * - * `relatedRequestId` is ignored: in the new model the dispatch generator yields - * directly into the originating POST's SSE stream, so the only `send()` callers are - * `StreamDriver` for unsolicited notifications/requests. + * @deprecated Use {@linkcode notify} / {@linkcode request} (the {@linkcode RequestTransport} surface). */ async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise { if (this._closed) return; @@ -280,7 +293,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport, Atta } const written = this._backchannel.writeStandalone(sessionId, message); if (!written && this._options.eventStore) { - // Store for replay even when no GET stream is open (matches v1 send()). await this._options.eventStore.storeEvent('_GET_stream', message); } } diff --git a/packages/server/test/server/shttpHandler.test.ts b/packages/server/test/server/shttpHandler.test.ts index 2377db9e3..f21d50e25 100644 --- a/packages/server/test/server/shttpHandler.test.ts +++ b/packages/server/test/server/shttpHandler.test.ts @@ -1,32 +1,27 @@ import { describe, expect, it } from 'vitest'; -import type { DispatchEnv, DispatchOutput, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { DispatchEnv, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; import { SessionCompat } from '../../src/server/sessionCompat.js'; -import type { McpServerLike } from '../../src/server/shttpHandler.js'; +import type { ShttpCallbacks } from '../../src/server/shttpHandler.js'; import { shttpHandler } from '../../src/server/shttpHandler.js'; -/** Minimal in-test dispatcher: maps method name → result, with optional pre-yield notification. */ +/** Minimal in-test callback bundle: maps method name → result, with optional pre-yield notification. */ function fakeServer( handlers: Record unknown>, opts: { preNotify?: JSONRPCNotification } = {} -): McpServerLike { +): ShttpCallbacks { return { - async *dispatch(req: JSONRPCRequest, _env?: DispatchEnv): AsyncIterable { - if (opts.preNotify) { - yield { kind: 'notification', message: opts.preNotify }; - } + async *onrequest(req: JSONRPCRequest, _env?: DispatchEnv): AsyncIterable { + if (opts.preNotify) yield opts.preNotify; const h = handlers[req.method]; if (!h) { - yield { - kind: 'response', - message: { jsonrpc: '2.0', id: req.id, error: { code: -32_601, message: 'Method not found' } } - }; + yield { jsonrpc: '2.0', id: req.id, error: { code: -32_601, message: 'Method not found' } }; return; } - yield { kind: 'response', message: { jsonrpc: '2.0', id: req.id, result: h(req) as Record } }; + yield { jsonrpc: '2.0', id: req.id, result: h(req) as Record }; }, - async dispatchNotification(_n: JSONRPCNotification): Promise { + async onnotification(_n: JSONRPCNotification): Promise { return; } }; From 94c540371ce3ac875662858cc968c385639cf4b1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 19:05:21 +0000 Subject: [PATCH 43/55] refactor(core): generic dispatch/outbound middleware; Tasks as opt-in middleware Dispatcher gains `use(mw: DispatchMiddleware)` and McpServer/Client/Protocol gain `useOutbound(mw: OutboundMiddleware)`. TaskManager is no longer a bound host vtable: `attachTo(d, hooks)` registers itself via `d.use()`, installs the `tasks/*` handlers, and returns the OutboundMiddleware the caller registers. McpServer/StreamDriver/Client no longer import TaskManager-specific types; the `_dispatchYielders`/`_dispatchOutboundId` state and the McpServer dispatch override are gone (the per-dispatch sideQueue lives inside the middleware via `TaskContext.sendOnResponseStream`). Renames (no aliases; rebuild-only names): `OutboundChannel`->`Outbound`, `OutboundInterceptor`->`OutboundMiddleware`, `attachPipeTransport`-> `attachChannelTransport`, `isPipeTransport`->`isChannelTransport`, `pipeAsClientTransport`->`channelAsClientTransport`. `DispatchEnv` moved to context.ts and renamed `RequestEnv` (deprecated alias kept) so transport.ts no longer type-imports from dispatcher.ts. --- packages/client/src/client/client.ts | 134 ++---- packages/client/src/client/clientTransport.ts | 6 +- packages/client/test/client/client.test.ts | 8 +- packages/core/src/index.ts | 10 +- packages/core/src/shared/context.ts | 88 +++- packages/core/src/shared/dispatcher.ts | 78 ++-- packages/core/src/shared/protocol.ts | 102 ++--- packages/core/src/shared/streamDriver.ts | 52 +-- packages/core/src/shared/taskManager.ts | 406 +++++++++++------- packages/core/src/shared/transport.ts | 11 +- packages/core/test/shared/protocol.test.ts | 8 +- .../middleware/node/src/streamableHttp.ts | 6 +- packages/server/src/server/mcpServer.ts | 226 ++-------- packages/server/src/server/shttpHandler.ts | 10 +- packages/server/src/server/streamableHttp.ts | 4 +- .../server/test/server/shttpHandler.test.ts | 4 +- 16 files changed, 531 insertions(+), 622 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 7e57ddb8f..7dfe7f5cb 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -11,13 +11,10 @@ import type { ClientResult, CompleteRequest, CreateTaskResult, - DispatchEnv, - DispatchOutput, GetPromptRequest, GetTaskRequest, GetTaskResult, Implementation, - InboundContext, JSONRPCErrorResponse, JSONRPCRequest, JSONRPCResultResponse, @@ -35,6 +32,7 @@ import type { Notification, NotificationMethod, NotificationOptions, + OutboundMiddleware, ProtocolOptions, ReadResourceRequest, Request, @@ -48,7 +46,6 @@ import type { StandardSchemaV1, StreamDriverOptions, SubscribeRequest, - TaskManagerHost, TaskManagerOptions, Tool, Transport, @@ -58,6 +55,7 @@ import { CallToolResultSchema, CancelTaskResultSchema, CompleteResultSchema, + composeOutboundMiddleware, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -93,7 +91,7 @@ import { import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; import type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; -import { isJSONRPCErrorResponse, isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; +import { channelAsClientTransport, isChannelTransport, isJSONRPCErrorResponse } from './clientTransport.js'; /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. @@ -231,8 +229,8 @@ export class Client extends Dispatcher { private _pendingListChangedConfig?: ListChangedHandlers; private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); - private _tasksOptions?: TaskManagerOptions; - private _taskManager?: TaskManager; + private _taskManager: TaskManager; + private readonly _outboundMw: OutboundMiddleware[] = []; onclose?: () => void; onerror?: (error: Error) => void; @@ -248,7 +246,25 @@ export class Client extends Dispatcher { this._enforceStrictCapabilities = _options?.enforceStrictCapabilities ?? false; this._mrtrMaxRounds = _options?.mrtrMaxRounds ?? DEFAULT_MRTR_MAX_ROUNDS; this._pendingListChangedConfig = _options?.listChanged; - this._tasksOptions = extractTaskManagerOptions(_options?.capabilities?.tasks); + + const tasksOpts = extractTaskManagerOptions(_options?.capabilities?.tasks); + this._taskManager = tasksOpts ? new TaskManager(tasksOpts) : new NullTaskManager(); + const tasksOutbound = this._taskManager.attachTo(this, { + channel: () => + this._ct + ? { + request: (r, schema, opts) => this._request(r, schema, opts), + notification: (n, opts) => this.notification(n, opts), + close: () => this.close(), + removeProgressHandler: t => this._ct?.driver?.removeProgressHandler(t) + } + : undefined, + reportError: e => this.onerror?.(e), + enforceStrictCapabilities: this._enforceStrictCapabilities, + assertTaskCapability: () => {}, + assertTaskHandlerCapability: () => {} + }); + this.useOutbound(tasksOutbound); // Strip runtime-only fields from advertised capabilities if (_options?.capabilities?.tasks) { @@ -262,79 +278,28 @@ export class Client extends Dispatcher { } /** - * Task-aware dispatch for inbound server-initiated requests (sampling, - * elicitation, roots, ping). Threads {@linkcode TaskManager.processInboundRequest} - * so task-augmented requests and queueing work over both pipe and request-shaped paths. + * Register an {@linkcode OutboundMiddleware} applied at the request-correlation seam. */ - override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { - const tm = this._taskManager; - if (!tm) { - yield* super.dispatch(request, env); - return; - } - const inboundCtx: InboundContext = { - sessionId: env.sessionId, - sendNotification: (n, opts) => this.notification(n, { ...opts, relatedRequestId: request.id }), - sendRequest: (r, schema, opts) => this._request(r, schema, { ...opts, relatedRequestId: request.id }) - }; - const tr = tm.processInboundRequest(request, inboundCtx); - if (tr.validateInbound) { - try { - tr.validateInbound(); - } catch (error) { - const e = error as { code?: number; message?: string; data?: unknown }; - yield { - kind: 'response', - message: { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, - message: e?.message ?? 'Internal error', - ...(e?.data !== undefined && { data: e.data }) - } - } - }; - return; - } - } - const taskEnv: DispatchEnv = { - ...env, - task: tr.taskContext ?? env.task, - send: (r, opts) => tr.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise - }; - for await (const out of super.dispatch(request, taskEnv)) { - if (out.kind === 'response') { - const routed = await tr.routeResponse(out.message); - if (!routed) yield out; - } else { - await tr.sendNotification({ method: out.message.method, params: out.message.params }); - } - } + useOutbound(mw: OutboundMiddleware): this { + this._outboundMw.push(mw); + return this; } /** * Connects to a server. Accepts either a {@linkcode ClientTransport} * (2026-06-native, request-shaped) or a legacy pipe {@linkcode Transport} * (stdio, SSE, the v1 SHTTP class). Pipe transports are adapted via - * {@linkcode pipeAsClientTransport} and the 2025-11 initialize handshake + * {@linkcode channelAsClientTransport} and the 2025-11 initialize handshake * is performed. */ async connect(transport: Transport | ClientTransport, options?: RequestOptions): Promise { - this._bindTaskManager(); - if (isPipeTransport(transport)) { - const tm = this._taskManager!; + if (isChannelTransport(transport)) { const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - interceptor: { - request: (jr, opts, id, settle, reject) => tm.processOutboundRequest(jr, opts, id, settle, reject), - notification: (n, opts) => tm.processOutboundNotification(n, opts), - response: (r, id) => tm.processInboundResponse(r, id), - close: () => tm.onClose() - } + outboundMw: this._outboundMw }; - this._ct = pipeAsClientTransport(transport, this, driverOpts); + this._ct = channelAsClientTransport(transport, this, driverOpts); this._ct.driver!.onclose = () => this.onclose?.(); this._ct.driver!.onerror = e => this.onerror?.(e); const skipInit = transport.sessionId !== undefined; @@ -389,7 +354,8 @@ export class Client extends Dispatcher { return resp ?? { jsonrpc: '2.0', id: r.id, error: { code: -32_601, message: 'Method not found' } }; }, onresponse: r => { - const consumed = this.taskManager.processInboundResponse(r, Number(r.id)).consumed; + const mw = composeOutboundMiddleware(this._outboundMw); + const consumed = mw.response?.(r, Number(r.id))?.consumed ?? false; if (!consumed) this.onerror?.(new Error(`Unmatched response on standalone stream: ${JSON.stringify(r)}`)); } }); @@ -402,30 +368,6 @@ export class Client extends Dispatcher { })(); } - /** - * Construct and bind this client's {@linkcode TaskManager}. Owned by the client - * (not the transport adapter); the pipe-shaped path threads it via - * {@linkcode StreamDriverOptions.interceptor}. - */ - private _bindTaskManager(): void { - const tm = this._tasksOptions ? new TaskManager(this._tasksOptions) : new NullTaskManager(); - const host: TaskManagerHost = { - request: (r, schema, opts) => this._request(r, schema, opts), - notification: (n, opts) => this.notification(n, opts), - reportError: e => this.onerror?.(e), - removeProgressHandler: t => this._ct?.driver?.removeProgressHandler(t), - registerHandler: (method, handler) => this.setRawRequestHandler(method, handler), - sendOnResponseStream: async () => { - throw new SdkError(SdkErrorCode.NotConnected, 'sendOnResponseStream is server-side only'); - }, - enforceStrictCapabilities: this._enforceStrictCapabilities, - assertTaskCapability: () => {}, - assertTaskHandlerCapability: () => {} - }; - tm.bind(host); - this._taskManager = tm; - } - async close(): Promise { const ct = this._ct; this._ct = undefined; @@ -660,9 +602,6 @@ export class Client extends Dispatcher { * This client's {@linkcode TaskManager}. Owned here (not by the transport adapter). */ get taskManager(): TaskManager { - if (!this._taskManager) { - throw new SdkError(SdkErrorCode.NotConnected, 'taskManager is unavailable: call connect() first.'); - } return this._taskManager; } @@ -760,7 +699,8 @@ export class Client extends Dispatcher { onresumptiontoken: options?.onresumptiontoken, onnotification: n => void this.dispatchNotification(n).catch(error => this.onerror?.(error)), onresponse: r => { - const consumed = this.taskManager.processInboundResponse(r, Number(r.id)).consumed; + const mw = composeOutboundMiddleware(this._outboundMw); + const consumed = mw.response?.(r, Number(r.id))?.consumed ?? false; if (!consumed) this.onerror?.(new Error(`Unmatched response on stream: ${JSON.stringify(r)}`)); }, onrequest: async r => { @@ -1099,4 +1039,4 @@ function formatErr(e: unknown): string { } export type { ClientFetchOptions, ClientTransport } from './clientTransport.js'; -export { isPipeTransport, pipeAsClientTransport } from './clientTransport.js'; +export { channelAsClientTransport, isChannelTransport } from './clientTransport.js'; diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index 228562bc9..74ea1fb09 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -62,7 +62,7 @@ export type ClientFetchOptions = { * version) but the contract is per-call. * * This is the 2026-06-native shape. The legacy pipe {@linkcode Transport} - * interface is adapted via {@linkcode pipeAsClientTransport}. + * interface is adapted via {@linkcode channelAsClientTransport}. */ export interface ClientTransport { /** @@ -103,7 +103,7 @@ export interface ClientTransport { * {@linkcode ClientTransport} so {@linkcode Client.connect} uses the * request-shaped path. */ -export function isPipeTransport(t: Transport | ClientTransport): t is Transport { +export function isChannelTransport(t: Transport | ClientTransport): t is Transport { if (typeof (t as ClientTransport).fetch === 'function') return false; return typeof (t as Transport).start === 'function' && typeof (t as Transport).send === 'function'; } @@ -117,7 +117,7 @@ export function isPipeTransport(t: Transport | ClientTransport): t is Transport * server-initiated requests (sampling, elicitation, roots) that arrive on the pipe. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- adapter is context-agnostic; the caller's Dispatcher subclass owns ContextT -export function pipeAsClientTransport(pipe: Transport, dispatcher: Dispatcher, options?: StreamDriverOptions): ClientTransport { +export function channelAsClientTransport(pipe: Transport, dispatcher: Dispatcher, options?: StreamDriverOptions): ClientTransport { const driver = new StreamDriver(dispatcher, pipe, options); let started = false; const subscribers: Set<(n: JSONRPCNotification) => void> = new Set(); diff --git a/packages/client/test/client/client.test.ts b/packages/client/test/client/client.test.ts index ddb68b1d2..eab4ee3b6 100644 --- a/packages/client/test/client/client.test.ts +++ b/packages/client/test/client/client.test.ts @@ -11,7 +11,7 @@ import { InMemoryTransport, LATEST_PROTOCOL_VERSION, ProtocolError, ProtocolErro import { describe, expect, it, vi } from 'vitest'; import type { ClientFetchOptions, ClientTransport } from '../../src/client/clientTransport.js'; -import { isPipeTransport } from '../../src/client/clientTransport.js'; +import { isChannelTransport } from '../../src/client/clientTransport.js'; import { Client } from '../../src/client/client.js'; type FetchResp = JSONRPCResultResponse | JSONRPCErrorResponse; @@ -79,11 +79,11 @@ describe('Client (V2)', () => { expect(c.getServerVersion()?.name).toBe('d'); }); - it('isPipeTransport correctly distinguishes the two shapes', () => { + it('isChannelTransport correctly distinguishes the two shapes', () => { const [a] = InMemoryTransport.createLinkedPair(); const { ct } = mockTransport(r => ok(r.id, {})); - expect(isPipeTransport(a)).toBe(true); - expect(isPipeTransport(ct)).toBe(false); + expect(isChannelTransport(a)).toBe(true); + expect(isChannelTransport(ct)).toBe(false); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22ad04387..13db3e569 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,15 +8,7 @@ export * from './shared/protocol.js'; export * from './shared/responseMessage.js'; export * from './shared/stdio.js'; export * from './shared/streamDriver.js'; -export type { - InboundContext, - InboundResult, - RequestTaskStore, - TaskContext, - TaskManagerHost, - TaskManagerOptions, - TaskRequestOptions -} from './shared/taskManager.js'; +export type { RequestTaskStore, TaskAttachHooks, TaskContext, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 4b81dba9c..1b095699b 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -21,6 +21,7 @@ import type { RequestId, RequestMeta, RequestMethod, + Result, ResultTypeMap, ServerCapabilities, TaskCreationParams @@ -34,6 +35,34 @@ import type { TransportSendOptions } from './transport.js'; */ export type ProgressCallback = (progress: Progress) => void; +/** + * Per-request environment a transport adapter passes to {@linkcode Dispatcher.dispatch}. + * Everything is optional; a bare `dispatch()` call works with no transport at all. + */ +export type RequestEnv = { + /** + * Sends a request back to the peer (server→client elicitation/sampling, or + * client→server nested calls). Supplied by {@linkcode StreamDriver} when running + * over a persistent pipe. Defaults to throwing {@linkcode SdkErrorCode.NotConnected}. + */ + send?: (request: Request, options?: RequestOptions) => Promise; + + /** Session identifier from the transport, if any. Surfaced as {@linkcode BaseContext.sessionId}. */ + sessionId?: string; + + /** Validated auth token info for HTTP transports. */ + authInfo?: AuthInfo; + + /** Original HTTP {@linkcode globalThis.Request | Request}, if any. */ + httpReq?: globalThis.Request; + + /** Abort signal for the inbound request. If omitted, a fresh controller is created. */ + signal?: AbortSignal; + + /** Task context, if task storage is configured by the caller. */ + task?: TaskContext; +}; + /** * Additional initialization options. */ @@ -145,12 +174,12 @@ export type NotificationOptions = { * The minimal contract a {@linkcode Dispatcher} owner needs to send outbound * requests/notifications to the connected peer. Decouples {@linkcode McpServer} * (and the compat {@linkcode Protocol}) from any specific transport adapter: - * they hold an `OutboundChannel`, not a `StreamDriver`. + * they hold an `Outbound`, not a `StreamDriver`. * * {@linkcode StreamDriver} implements this for persistent pipes. Request-shaped * paths can supply their own (e.g. routing through a backchannel). */ -export interface OutboundChannel { +export interface Outbound { /** Send a request to the peer and resolve with the parsed result. */ request(req: Request, resultSchema: T, options?: RequestOptions): Promise>; /** Send a notification to the peer. */ @@ -166,15 +195,17 @@ export interface OutboundChannel { } /** - * Hooks an {@linkcode OutboundChannel} owner can supply to a transport adapter - * (e.g. {@linkcode StreamDriver}) to intercept outbound writes and inbound responses - * at the request-correlation seam. The adapter knows nothing about *why* a message - * is queued or consumed; it just calls these hooks. + * Middleware around the request-correlation seam of an {@linkcode Outbound}. + * Registered via `useOutbound()` on {@linkcode McpServer} / {@linkcode Client}; + * the transport adapter (e.g. {@linkcode StreamDriver}) calls each hook without + * knowing why a message is queued or consumed. * - * In practice this is how {@linkcode TaskManager} threads task augmentation through - * a pipe — but the adapter is agnostic to that. + * Unlike {@linkcode DispatchMiddleware} (a single function wrapping `next`), the + * outbound seam has four distinct call sites, so this is a record of optional hooks. + * Multiple middleware are composed with {@linkcode composeOutboundMiddleware} — + * first to claim wins for `request`/`notification`/`response`; all `close` run. */ -export interface OutboundInterceptor { +export interface OutboundMiddleware { /** Called before each outbound request hits the wire. Return `queued: true` to suppress the send (caller resolves via `settle`). */ request?( jr: JSONRPCRequest, @@ -194,6 +225,45 @@ export interface OutboundInterceptor { close?(): void; } +/** + * Composes a list of {@linkcode OutboundMiddleware} into one, registration-order. + * For `request`/`notification`/`response` the first middleware to claim (queued/consumed) + * short-circuits the rest; `close` runs all. + */ +export function composeOutboundMiddleware(mws: OutboundMiddleware[]): OutboundMiddleware { + if (mws.length <= 1) return mws[0] ?? {}; + return { + request(jr, opts, id, settle, reject) { + for (const mw of mws) { + const r = mw.request?.(jr, opts, id, settle, reject); + if (r?.queued) return r; + } + return { queued: false }; + }, + async notification(n, opts) { + let rewritten: JSONRPCNotification | undefined; + for (const mw of mws) { + const r = await mw.notification?.(n, opts); + if (r?.queued) return r; + if (r?.jsonrpcNotification) rewritten = r.jsonrpcNotification; + } + return { queued: false, jsonrpcNotification: rewritten }; + }, + response(r, id) { + let preserveProgress = false; + for (const mw of mws) { + const out = mw.response?.(r, id); + if (out?.consumed) return out; + if (out?.preserveProgress) preserveProgress = true; + } + return { consumed: false, preserveProgress }; + }, + close() { + for (const mw of mws) mw.close?.(); + } + }; +} + /** * Base context provided to all request handlers. */ diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 857e12a8e..a0b93092f 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -1,6 +1,5 @@ import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import type { - AuthInfo, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -17,36 +16,10 @@ import type { } from '../types/index.js'; import { getNotificationSchema, getRequestSchema, ProtocolError, ProtocolErrorCode } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; -import type { BaseContext, RequestOptions } from './context.js'; -import type { TaskContext } from './taskManager.js'; +import type { BaseContext, RequestEnv, RequestOptions } from './context.js'; -/** - * Per-dispatch environment provided by the caller (driver). Everything is optional; - * a bare {@linkcode Dispatcher.dispatch} call works with no transport at all. - */ -export type DispatchEnv = { - /** - * Sends a request back to the peer (server→client elicitation/sampling, or - * client→server nested calls). Supplied by {@linkcode StreamDriver} when running - * over a persistent pipe. Defaults to throwing {@linkcode SdkErrorCode.NotConnected}. - */ - send?: (request: Request, options?: RequestOptions) => Promise; - - /** Session identifier from the transport, if any. Surfaced as {@linkcode BaseContext.sessionId}. */ - sessionId?: string; - - /** Validated auth token info for HTTP transports. */ - authInfo?: AuthInfo; - - /** Original HTTP {@linkcode globalThis.Request | Request}, if any. */ - httpReq?: globalThis.Request; - - /** Abort signal for the inbound request. If omitted, a fresh controller is created. */ - signal?: AbortSignal; - - /** Task context, if task storage is configured by the caller. */ - task?: TaskContext; -}; +/** @deprecated Renamed to {@linkcode RequestEnv} (now in `context.ts`). */ +export type DispatchEnv = RequestEnv; /** * One yielded item from {@linkcode Dispatcher.dispatch}. A dispatch yields zero or more @@ -66,6 +39,18 @@ export type RawDispatchOutput = type RawHandler = (request: JSONRPCRequest, ctx: ContextT) => Promise; +/** Signature of {@linkcode Dispatcher.dispatch}. Target type for {@linkcode DispatchMiddleware}. */ +export type DispatchFn = (req: JSONRPCRequest, env?: RequestEnv) => AsyncGenerator; + +/** + * Onion-style middleware around {@linkcode Dispatcher.dispatch}. Registered via + * {@linkcode Dispatcher.use}; composed outermost-first (registration order). + * + * A middleware may transform `req`/`env` before delegating, transform or filter + * yielded outputs, or short-circuit by yielding a response without calling `next`. + */ +export type DispatchMiddleware = (next: DispatchFn) => DispatchFn; + /** * Stateless JSON-RPC handler registry with a request-in / messages-out * {@linkcode Dispatcher.dispatch | dispatch()} entry point. @@ -76,6 +61,7 @@ type RawHandler = (request: JSONRPCRequest, ctx: ContextT) => Promise< export class Dispatcher { protected _requestHandlers: Map> = new Map(); protected _notificationHandlers: Map Promise> = new Map(); + private _dispatchMw: DispatchMiddleware[] = []; /** * A handler to invoke for any request types that do not have their own handler installed. @@ -90,18 +76,40 @@ export class Dispatcher { /** * Subclasses override to enrich the context (e.g. {@linkcode ServerContext}). Default returns base unchanged. */ - protected buildContext(base: BaseContext, _env: DispatchEnv): ContextT { + protected buildContext(base: BaseContext, _env: RequestEnv): ContextT { return base as ContextT; } /** - * Dispatch one inbound request. Yields any notifications the handler emits via + * Registers a {@linkcode DispatchMiddleware}. Registration order is outer-to-inner: + * the first middleware registered sees the rawest request and the final yields. + */ + use(mw: DispatchMiddleware): this { + this._dispatchMw.push(mw); + return this; + } + + /** + * Dispatch one inbound request through the registered middleware chain, then the + * core handler lookup. Yields any notifications the handler emits via * `ctx.mcpReq.notify()`, then yields exactly one terminal response. * * Never throws for handler errors; they are wrapped as JSON-RPC error responses. * May throw if iteration itself is misused. */ - async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { + dispatch(request: JSONRPCRequest, env: RequestEnv = {}): AsyncGenerator { + // eslint-disable-next-line unicorn/consistent-function-scoping -- closes over `this` + let chain: DispatchFn = (r, e) => this._dispatchCore(r, e); + // eslint-disable-next-line unicorn/no-array-reverse -- toReversed() requires ES2023 lib; consumers may target ES2022 + for (const mw of [...this._dispatchMw].reverse()) chain = mw(chain); + return chain(request, env); + } + + /** + * The handler lookup + invocation. Middleware composes around this; subclasses do + * not override `dispatch()` directly — use {@linkcode Dispatcher.use | use()} instead. + */ + private async *_dispatchCore(request: JSONRPCRequest, env: RequestEnv = {}): AsyncGenerator { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; if (handler === undefined) { @@ -194,7 +202,7 @@ export class Dispatcher { async *dispatchRaw( method: string, params: Record | undefined, - env: DispatchEnv = {} + env: RequestEnv = {} ): AsyncGenerator { const synthetic: JSONRPCRequest = { jsonrpc: '2.0', id: 0, method, params }; for await (const out of this.dispatch(synthetic, env)) { @@ -304,7 +312,7 @@ export class Dispatcher { } /** Convenience: collect a full dispatch into a single response, discarding notifications. */ - async dispatchToResponse(request: JSONRPCRequest, env?: DispatchEnv): Promise { + async dispatchToResponse(request: JSONRPCRequest, env?: RequestEnv): Promise { let resp: JSONRPCResponse | JSONRPCErrorResponse | undefined; for await (const out of this.dispatch(request, env)) { if (out.kind === 'response') resp = out.message; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 9e936b405..c5ab25547 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -18,13 +18,20 @@ import type { Result, ResultTypeMap } from '../types/index.js'; -import { getResultSchema, ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; +import { getResultSchema, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; -import type { BaseContext, NotificationOptions, OutboundChannel, ProtocolOptions, RequestOptions } from './context.js'; -import type { DispatchEnv, DispatchOutput } from './dispatcher.js'; +import type { + BaseContext, + NotificationOptions, + Outbound, + OutboundMiddleware, + ProtocolOptions, + RequestEnv, + RequestOptions +} from './context.js'; +import type { DispatchMiddleware } from './dispatcher.js'; import { Dispatcher } from './dispatcher.js'; import { StreamDriver } from './streamDriver.js'; -import type { InboundContext } from './taskManager.js'; import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport } from './transport.js'; @@ -37,7 +44,7 @@ export * from './context.js'; * {@linkcode StreamDriver} (per-connection state) to preserve the v1 surface. */ export abstract class Protocol { - private _outbound?: OutboundChannel; + private _outbound?: Outbound; private readonly _dispatcher: Dispatcher; protected _supportedProtocolVersions: string[]; @@ -60,75 +67,35 @@ export abstract class Protocol { // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment const self = this; this._dispatcher = new (class extends Dispatcher { - protected override buildContext(base: BaseContext, env: DispatchEnv & { _transportExtra?: MessageExtraInfo }): ContextT { + protected override buildContext(base: BaseContext, env: RequestEnv & { _transportExtra?: MessageExtraInfo }): ContextT { return self.buildContext(base, env._transportExtra); } - - override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { - const inboundCtx: InboundContext = { - sessionId: env.sessionId, - sendNotification: (n, opts) => self.notification(n, { ...opts, relatedRequestId: request.id }), - sendRequest: (r, schema, opts) => self._requestWithSchema(r, schema, { ...opts, relatedRequestId: request.id }) - }; - const tr = self._ownTaskManager.processInboundRequest(request, inboundCtx); - if (tr.validateInbound) { - try { - tr.validateInbound(); - } catch (error) { - const e = error as { code?: number; message?: string; data?: unknown }; - yield { - kind: 'response', - message: { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, - message: e?.message ?? 'Internal error', - ...(e?.data !== undefined && { data: e.data }) - } - } - }; - return; - } - } - const taskEnv: DispatchEnv = { - ...env, - task: tr.taskContext ?? env.task, - send: (r, opts) => tr.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise - }; - for await (const out of super.dispatch(request, taskEnv)) { - if (out.kind === 'response') { - const routed = await tr.routeResponse(out.message); - if (!routed) yield out; - } else { - // Handler-emitted notifications go through TaskManager (queues when - // related-task; otherwise calls inboundCtx.sendNotification → wire). - await tr.sendNotification({ method: out.message.method, params: out.message.params }); - } - } - } })(); this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; this._ownTaskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); - this._bindTaskManager(); - } - - private readonly _ownTaskManager: TaskManager; - - private _bindTaskManager(): void { - this._ownTaskManager.bind({ - request: (r, schema, opts) => this._requestWithSchema(r, schema, opts), - notification: (n, opts) => this.notification(n, opts), + const omw = this._ownTaskManager.attachTo(this._dispatcher, { + channel: () => this._outbound, reportError: e => this.onerror?.(e), - removeProgressHandler: t => this._outbound?.removeProgressHandler?.(t), - registerHandler: (method, handler) => this._dispatcher.setRawRequestHandler(method, handler), - sendOnResponseStream: async (message, relatedRequestId) => { - await this._outbound?.sendRaw?.(message, { relatedRequestId }); - }, enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, assertTaskCapability: m => this.assertTaskCapability(m), assertTaskHandlerCapability: m => this.assertTaskHandlerCapability(m) }); + this._outboundMw.push(omw); + } + + private readonly _ownTaskManager: TaskManager; + private readonly _outboundMw: OutboundMiddleware[] = []; + + /** Register a {@linkcode DispatchMiddleware} on the inner dispatcher. */ + use(mw: DispatchMiddleware): this { + this._dispatcher.use(mw); + return this; + } + + /** Register an {@linkcode OutboundMiddleware} applied at the request-correlation seam. */ + useOutbound(mw: OutboundMiddleware): this { + this._outboundMw.push(mw); + return this; } // ─────────────────────────────────────────────────────────────────────── @@ -212,12 +179,7 @@ export abstract class Protocol { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), - interceptor: { - request: (jr, opts, id, settle, reject) => this._ownTaskManager.processOutboundRequest(jr, opts, id, settle, reject), - notification: (n, opts) => this._ownTaskManager.processOutboundNotification(n, opts), - response: (r, id) => this._ownTaskManager.processInboundResponse(r, id), - close: () => this._ownTaskManager.onClose() - } + outboundMw: this._outboundMw }); this._outbound = driver; driver.onclose = () => { diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 828c727c7..c614e73fe 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -25,9 +25,9 @@ import { } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; -import type { NotificationOptions, OutboundChannel, OutboundInterceptor, ProgressCallback, RequestOptions } from './context.js'; -import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; -import type { DispatchEnv, Dispatcher } from './dispatcher.js'; +import type { NotificationOptions, Outbound, OutboundMiddleware, ProgressCallback, RequestEnv, RequestOptions } from './context.js'; +import { composeOutboundMiddleware, DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; +import type { Dispatcher } from './dispatcher.js'; import type { AttachOptions, Transport } from './transport.js'; type TimeoutInfo = { @@ -43,15 +43,15 @@ export type StreamDriverOptions = { supportedProtocolVersions?: string[]; debouncedNotificationMethods?: string[]; /** - * Hook to enrich the per-request {@linkcode DispatchEnv} from transport-supplied + * Hook to enrich the per-request {@linkcode RequestEnv} from transport-supplied * {@linkcode MessageExtraInfo} (e.g. auth, http req). */ - buildEnv?: (extra: MessageExtraInfo | undefined, base: DispatchEnv) => DispatchEnv; + buildEnv?: (extra: MessageExtraInfo | undefined, base: RequestEnv) => RequestEnv; /** - * Hooks invoked at the request-correlation seam (before each outbound write, - * for each inbound response, on close). The driver is agnostic to what they do. + * {@linkcode OutboundMiddleware} hooks invoked at the request-correlation seam. + * Composed in registration order via {@linkcode composeOutboundMiddleware}. */ - interceptor?: OutboundInterceptor; + outboundMw?: OutboundMiddleware[]; }; /** @@ -61,7 +61,7 @@ export type StreamDriverOptions = { * * One driver per pipe. The dispatcher it wraps may be shared. */ -export class StreamDriver implements OutboundChannel { +export class StreamDriver implements Outbound { private _requestMessageId = 0; private _responseHandlers: Map void> = new Map(); private _progressHandlers: Map = new Map(); @@ -70,6 +70,7 @@ export class StreamDriver implements OutboundChannel { private _pendingDebouncedNotifications = new Set(); private _closed = false; private _supportedProtocolVersions: string[]; + private _mw: OutboundMiddleware; onclose?: () => void; onerror?: (error: Error) => void; @@ -81,9 +82,10 @@ export class StreamDriver implements OutboundChannel { private _options: StreamDriverOptions = {} ) { this._supportedProtocolVersions = _options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + this._mw = composeOutboundMiddleware(_options.outboundMw ?? []); } - /** {@linkcode OutboundChannel.removeProgressHandler}. */ + /** {@linkcode Outbound.removeProgressHandler}. */ removeProgressHandler(token: number): void { this._progressHandlers.delete(token); } @@ -130,12 +132,12 @@ export class StreamDriver implements OutboundChannel { await this.pipe.close(); } - /** {@linkcode OutboundChannel.setProtocolVersion} — delegates to the pipe. */ + /** {@linkcode Outbound.setProtocolVersion} — delegates to the pipe. */ setProtocolVersion(version: string): void { this.pipe.setProtocolVersion?.(version); } - /** {@linkcode OutboundChannel.sendRaw} — write a raw JSON-RPC message to the pipe. */ + /** {@linkcode Outbound.sendRaw} — write a raw JSON-RPC message to the pipe. */ async sendRaw(message: Parameters[0], options?: { relatedRequestId?: RequestId }): Promise { await this.pipe.send(message, options); } @@ -204,15 +206,14 @@ export class StreamDriver implements OutboundChannel { ); let queued = false; - const intercept = this._options.interceptor?.request; - if (intercept) { + if (this._mw.request) { const sideChannelResponse = (resp: JSONRPCResultResponse | Error) => { const h = this._responseHandlers.get(messageId); if (h) h(resp); else this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); }; try { - queued = intercept(jsonrpcRequest, options, messageId, sideChannelResponse, error => { + queued = this._mw.request(jsonrpcRequest, options, messageId, sideChannelResponse, error => { this._progressHandlers.delete(messageId); reject(error); }).queued; @@ -242,7 +243,7 @@ export class StreamDriver implements OutboundChannel { * Sends a notification over the pipe. Supports debouncing per the constructor option. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - const intercepted = await this._options.interceptor?.notification?.(notification, options); + const intercepted = await this._mw.notification?.(notification, options); if (intercepted?.queued || this._closed) return; const jsonrpc: JSONRPCNotification = intercepted?.jsonrpcNotification ?? { jsonrpc: '2.0', @@ -270,7 +271,7 @@ export class StreamDriver implements OutboundChannel { const abort = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abort); - const baseEnv: DispatchEnv = { + const baseEnv: RequestEnv = { signal: abort.signal, sessionId: this.pipe.sessionId, authInfo: extra?.authInfo, @@ -341,7 +342,7 @@ export class StreamDriver implements OutboundChannel { private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); - const intercepted = this._options.interceptor?.response?.(response, messageId); + const intercepted = this._mw.response?.(response, messageId); if (intercepted?.consumed) return; const handler = this._responseHandlers.get(messageId); @@ -366,7 +367,7 @@ export class StreamDriver implements OutboundChannel { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); - this._options.interceptor?.close?.(); + this._mw.close?.(); this._pendingDebouncedNotifications.clear(); for (const info of this._timeoutInfo.values()) clearTimeout(info.timeoutId); this._timeoutInfo.clear(); @@ -422,21 +423,20 @@ export class StreamDriver implements OutboundChannel { } /** - * Wraps a plain pipe-shaped {@linkcode Transport} in a {@linkcode StreamDriver} - * and starts it. This is the back-compat path for transports that don't implement - * `attach()`: callers (`McpServer.connect`, `Client.connect`) use this helper - * instead of importing `StreamDriver` themselves. + * Wraps a {@linkcode ChannelTransport} in a {@linkcode StreamDriver} and starts it. + * Callers (`McpServer.connect`, `Client.connect`) use this helper instead of + * importing `StreamDriver` themselves. */ -export async function attachPipeTransport( +export async function attachChannelTransport( pipe: Transport, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- adapter is context-agnostic dispatcher: Dispatcher, options?: AttachOptions -): Promise { +): Promise { const driver = new StreamDriver(dispatcher, pipe, { supportedProtocolVersions: options?.supportedProtocolVersions, debouncedNotificationMethods: options?.debouncedNotificationMethods, - interceptor: options?.interceptor, + outboundMw: options?.outboundMw, buildEnv: options?.buildEnv }); if (options?.onclose || options?.onerror) { diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts index ace92bbee..dac3309ee 100644 --- a/packages/core/src/shared/taskManager.ts +++ b/packages/core/src/shared/taskManager.ts @@ -32,56 +32,27 @@ import { TaskStatusNotificationSchema } from '../types/index.js'; import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; -import type { BaseContext, NotificationOptions, RequestOptions } from './context.js'; +import type { NotificationOptions, Outbound, OutboundMiddleware, RequestEnv, RequestOptions } from './context.js'; +import type { Dispatcher, DispatchFn, DispatchMiddleware, DispatchOutput } from './dispatcher.js'; import type { ResponseMessage } from './responseMessage.js'; /** - * Host interface for TaskManager to call back into Protocol. @internal + * Hooks {@linkcode TaskManager.attachTo} needs from its owner. The owner is whoever + * holds the {@linkcode Outbound} (McpServer/Client/Protocol). Replaces the + * previous wider host vtable: most of what the vtable provided is reachable via + * `channel()` or via the {@linkcode Dispatcher} passed to `attachTo`. + * @internal */ -export interface TaskManagerHost { - request(request: Request, resultSchema: T, options?: RequestOptions): Promise>; - notification(notification: Notification, options?: NotificationOptions): Promise; +export interface TaskAttachHooks { + /** Current outbound channel (may be undefined before connect). */ + channel(): Outbound | undefined; + /** Surface non-fatal errors. */ reportError(error: Error): void; - removeProgressHandler(token: number): void; - registerHandler(method: string, handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise): void; - sendOnResponseStream(message: JSONRPCNotification | JSONRPCRequest, relatedRequestId: RequestId): Promise; enforceStrictCapabilities: boolean; assertTaskCapability(method: string): void; assertTaskHandlerCapability(method: string): void; } -/** - * Context provided to TaskManager when processing an inbound request. - * @internal - */ -export interface InboundContext { - sessionId?: string; - sendNotification: (notification: Notification, options?: NotificationOptions) => Promise; - sendRequest: (request: Request, resultSchema: U, options?: RequestOptions) => Promise>; -} - -/** - * Result returned by TaskManager after processing an inbound request. - * @internal - */ -export interface InboundResult { - taskContext?: BaseContext['task']; - sendNotification: (notification: Notification) => Promise; - sendRequest: ( - request: Request, - resultSchema: U, - options?: Omit - ) => Promise>; - routeResponse: (message: JSONRPCResponse | JSONRPCErrorResponse) => Promise; - hasTaskCreationParams: boolean; - /** - * Optional validation to run inside the async handler chain (before the request handler). - * Throwing here produces a proper JSON-RPC error response, matching the behavior of - * capability checks on main. - */ - validateInbound?: () => void; -} - /** * Options that can be given per request. */ @@ -152,6 +123,13 @@ export type TaskContext = { id?: string; store: RequestTaskStore; requestedTtl?: number; + /** + * Yield a queued task message on the *current* dispatch's response stream. + * Set by the dispatch middleware; used by the `tasks/result` handler so queued + * messages flow on the same stream as that handler's terminal response. + * @internal + */ + sendOnResponseStream?: (message: JSONRPCNotification | JSONRPCRequest) => void; }; export type TaskManagerOptions = { @@ -195,10 +173,12 @@ export function extractTaskManagerOptions(tasksCapability: TaskManagerOptions | export class TaskManager { private _taskStore?: TaskStore; private _taskMessageQueue?: TaskMessageQueue; + /** @internal id allocator for dispatch-middleware-queued requests (independent of any transport's id space). */ + _dispatchOutboundId = 0; private _taskProgressTokens: Map = new Map(); private _requestResolvers: Map void> = new Map(); private _options: TaskManagerOptions; - private _host?: TaskManagerHost; + private _hooks?: TaskAttachHooks; constructor(options: TaskManagerOptions) { this._options = options; @@ -206,46 +186,199 @@ export class TaskManager { this._taskMessageQueue = options.taskMessageQueue; } - bind(host: TaskManagerHost): void { - this._host = host; + /** + * Attaches this manager to a {@linkcode Dispatcher}: registers the dispatch middleware + * via `d.use()`, installs `tasks/*` request handlers when a store is configured, and + * stores the {@linkcode TaskAttachHooks}. Returns the {@linkcode OutboundMiddleware} + * the caller registers via `useOutbound()` (kept separate so callers control ordering). + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- attach is context-agnostic + attachTo(d: Dispatcher, hooks: TaskAttachHooks): OutboundMiddleware { + this._hooks = hooks; + d.use(this.dispatchMiddleware); if (this._taskStore) { - host.registerHandler('tasks/get', async (request, ctx) => { + d.setRawRequestHandler('tasks/get', async (request, ctx) => { const params = request.params as { taskId: string }; - const task = await this.handleGetTask(params.taskId, ctx.sessionId); - // Per spec: tasks/get responses SHALL NOT include related-task metadata - // as the taskId parameter is the source of truth - return { - ...task - } as Result; + return (await this.handleGetTask(params.taskId, ctx.sessionId)) as Result; }); - host.registerHandler('tasks/result', async (request, ctx) => { + d.setRawRequestHandler('tasks/result', async (request, ctx) => { const params = request.params as { taskId: string }; - return await this.handleGetTaskPayload(params.taskId, ctx.sessionId, ctx.mcpReq.signal, async message => { - // Send the message on the response stream by passing the relatedRequestId - // This tells the transport to write the message to the tasks/result response stream - await host.sendOnResponseStream(message, ctx.mcpReq.id); + return this.handleGetTaskPayload(params.taskId, ctx.sessionId, ctx.mcpReq.signal, async message => { + const sink = + ctx.task?.sendOnResponseStream ?? + ((m: JSONRPCNotification | JSONRPCRequest) => { + void hooks.channel()?.sendRaw?.(m, { relatedRequestId: ctx.mcpReq.id }); + }); + sink(message); }); }); - host.registerHandler('tasks/list', async (request, ctx) => { + d.setRawRequestHandler('tasks/list', async (request, ctx) => { const params = request.params as { cursor?: string } | undefined; return (await this.handleListTasks(params?.cursor, ctx.sessionId)) as Result; }); - host.registerHandler('tasks/cancel', async (request, ctx) => { + d.setRawRequestHandler('tasks/cancel', async (request, ctx) => { const params = request.params as { taskId: string }; - return await this.handleCancelTask(params.taskId, ctx.sessionId); + return this.handleCancelTask(params.taskId, ctx.sessionId); }); } + + return this.outboundMiddleware; } - protected get _requireHost(): TaskManagerHost { - if (!this._host) { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskManager is not bound to a Protocol host — call bind() first'); + protected get _requireHooks(): TaskAttachHooks { + if (!this._hooks) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskManager is not attached to a Dispatcher — call attachTo() first'); } - return this._host; + return this._hooks; + } + + /** + * The {@linkcode DispatchMiddleware}: detects task-augmented inbound requests, builds + * `env.task` (with the request-scoped store + side-channel sink), wraps `env.send` to + * carry `relatedTask`, intercepts yielded notifications/response for queueing. + */ + get dispatchMiddleware(): DispatchMiddleware { + // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment + const tm = this; + return next => + async function* (request, env = {}) { + const taskInfo = tm.extractInboundTaskContext(request, env.sessionId); + const relatedTaskId = taskInfo?.relatedTaskId; + const hasTaskCreationParams = !!taskInfo?.taskCreationParams; + + if (hasTaskCreationParams) { + try { + tm._requireHooks.assertTaskHandlerCapability(request.method); + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + yield { + kind: 'response', + message: { + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + message: e?.message ?? 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + } + }; + return; + } + } + + // Side-channel sink so `tasks/result` (and any handler) can yield arbitrary + // queued messages on this dispatch's stream. Drained interleaved with `next()`. + const sideQueue: (JSONRPCNotification | JSONRPCRequest)[] = []; + let wake: (() => void) | undefined; + const sendOnResponseStream = (m: JSONRPCNotification | JSONRPCRequest) => { + sideQueue.push(m); + wake?.(); + }; + const drain = function* (): Generator { + while (sideQueue.length > 0) { + const m = sideQueue.shift()!; + yield { kind: 'notification', message: m as JSONRPCNotification }; + } + }; + + const wrappedSend: NonNullable = async (r, opts) => { + const relatedTask = relatedTaskId && !opts?.relatedTask ? { taskId: relatedTaskId } : opts?.relatedTask; + const effectiveTaskId = relatedTask?.taskId; + if (effectiveTaskId && taskInfo?.taskContext?.store) { + await taskInfo.taskContext.store.updateTaskStatus(effectiveTaskId, 'input_required'); + } + if (effectiveTaskId) { + // Queue to the task message queue (delivered via tasks/result), don't hit env.send. + return new Promise((resolve, reject) => { + const messageId = tm._dispatchOutboundId++; + const wire: JSONRPCRequest = { jsonrpc: '2.0', id: messageId, method: r.method, params: r.params }; + const settle = (resp: { result: Result } | Error) => + resp instanceof Error ? reject(resp) : resolve(resp.result); + const { queued } = tm.processOutboundRequest(wire, { ...opts, relatedTask }, messageId, settle, reject); + if (queued) return; + if (env.send) { + env.send(r, { ...opts, relatedTask }).then(result => settle({ result }), reject); + } else { + reject(new ProtocolError(ProtocolErrorCode.InternalError, 'env.send unavailable')); + } + }); + } + if (env.send) return env.send(r, { ...opts, relatedTask }); + throw new ProtocolError(ProtocolErrorCode.InternalError, 'env.send unavailable'); + }; + + const taskCtx: TaskContext | undefined = taskInfo?.taskContext + ? { ...taskInfo.taskContext, sendOnResponseStream } + : tm._taskStore + ? { store: tm.createRequestTaskStore(request, env.sessionId), sendOnResponseStream } + : undefined; + + const taskEnv: RequestEnv = { + ...env, + task: taskCtx ?? env.task, + send: relatedTaskId || taskInfo?.taskContext ? wrappedSend : env.send + }; + + const inner = next(request, taskEnv); + let pending: Promise> | undefined; + while (true) { + yield* drain(); + pending ??= inner.next(); + const wakeP = new Promise<'side'>(resolve => { + wake = () => resolve('side'); + }); + if (sideQueue.length > 0) { + wake = undefined; + continue; + } + const r = await Promise.race([pending, wakeP]); + wake = undefined; + if (r === 'side') continue; + pending = undefined; + if (r.done) break; + const out = r.value; + if (out.kind === 'response') { + const routed = relatedTaskId ? await tm.routeResponse(relatedTaskId, out.message, env.sessionId) : false; + if (!routed) { + yield* drain(); + yield out; + } + } else if (relatedTaskId === undefined) { + yield out; + } else { + // Handler-emitted notifications inside a related-task request are queued + // (not yielded) so they deliver via tasks/result, avoiding duplicate + // delivery on bidirectional transports. + const result = await tm.processOutboundNotification( + { method: out.message.method, params: out.message.params }, + { relatedTask: { taskId: relatedTaskId } } + ); + if (!result.queued && result.jsonrpcNotification) { + yield { kind: 'notification', message: result.jsonrpcNotification }; + } + } + } + yield* drain(); + } as DispatchFn; + } + + /** + * The {@linkcode OutboundMiddleware}: adds `task`/`relatedTask` to outbound params, + * queues to the task-message-queue when `relatedTask` is set, consumes/correlates + * responses for queued requests, tracks progress-token lifetime for `CreateTaskResult`s. + */ + get outboundMiddleware(): OutboundMiddleware { + return { + request: (jr, opts, id, settle, reject) => this.processOutboundRequest(jr, opts, id, settle, reject), + notification: (n, opts) => this.processOutboundNotification(n, opts), + response: (r, id) => this.processInboundResponse(r, id), + close: () => this.onClose() + }; } get taskStore(): TaskStore | undefined { @@ -263,18 +396,23 @@ export class TaskManager { return this._taskMessageQueue; } + private _outboundRequest(req: Request, schema: T, opts?: RequestOptions): Promise> { + const ch = this._requireHooks.channel(); + if (!ch) throw new ProtocolError(ProtocolErrorCode.InternalError, 'Not connected'); + return ch.request(req, schema, opts); + } + // -- Public API (client-facing) -- async *requestStream( request: Request, resultSchema: T, options?: RequestOptions ): AsyncGenerator>, void, void> { - const host = this._requireHost; const { task } = options ?? {}; if (!task) { try { - const result = await host.request(request, resultSchema, options); + const result = await this._outboundRequest(request, resultSchema, options); yield { type: 'result', result }; } catch (error) { yield { @@ -287,7 +425,7 @@ export class TaskManager { let taskId: string | undefined; try { - const createResult = await host.request(request, CreateTaskResultSchema, options); + const createResult = await this._outboundRequest(request, CreateTaskResultSchema, options); if (createResult.task) { taskId = createResult.task.taskId; @@ -341,7 +479,7 @@ export class TaskManager { } async getTask(params: GetTaskRequest['params'], options?: RequestOptions): Promise { - return this._requireHost.request({ method: 'tasks/get', params }, GetTaskResultSchema, options); + return this._outboundRequest({ method: 'tasks/get', params }, GetTaskResultSchema, options); } async getTaskResult( @@ -349,15 +487,15 @@ export class TaskManager { resultSchema: T, options?: RequestOptions ): Promise> { - return this._requireHost.request({ method: 'tasks/result', params }, resultSchema, options); + return this._outboundRequest({ method: 'tasks/result', params }, resultSchema, options); } async listTasks(params?: { cursor?: string }, options?: RequestOptions): Promise> { - return this._requireHost.request({ method: 'tasks/list', params }, ListTasksResultSchema, options); + return this._outboundRequest({ method: 'tasks/list', params }, ListTasksResultSchema, options); } async cancelTask(params: { taskId: string }, options?: RequestOptions): Promise> { - return this._requireHost.request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); + return this._outboundRequest({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); } // -- Handler bodies (delegated from Protocol's registered handlers) -- @@ -395,7 +533,7 @@ export class TaskManager { } } else { const messageType = queuedMessage.type === 'response' ? 'Response' : 'Error'; - this._host?.reportError(new Error(`${messageType} handler missing for request ${requestId}`)); + this._hooks?.reportError(new Error(`${messageType} handler missing for request ${requestId}`)); } continue; } @@ -553,36 +691,6 @@ export class TaskManager { }; } - private wrapSendNotification( - relatedTaskId: string, - originalSendNotification: (notification: Notification, options?: NotificationOptions) => Promise - ): (notification: Notification) => Promise { - return async (notification: Notification) => { - const notificationOptions: NotificationOptions = { relatedTask: { taskId: relatedTaskId } }; - await originalSendNotification(notification, notificationOptions); - }; - } - - private wrapSendRequest( - relatedTaskId: string, - taskStore: RequestTaskStore | undefined, - originalSendRequest: (request: Request, resultSchema: V, options?: RequestOptions) => Promise> - ): (request: Request, resultSchema: V, options?: TaskRequestOptions) => Promise> { - return async (request: Request, resultSchema: V, options?: TaskRequestOptions) => { - const requestOptions: RequestOptions = { ...options }; - if (relatedTaskId && !requestOptions.relatedTask) { - requestOptions.relatedTask = { taskId: relatedTaskId }; - } - - const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; - if (effectiveTaskId && taskStore) { - await taskStore.updateTaskStatus(effectiveTaskId, 'input_required'); - } - - return await originalSendRequest(request, resultSchema, requestOptions); - }; - } - private handleResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean { const messageId = Number(response.id); const resolver = this._requestResolvers.get(messageId); @@ -656,7 +764,7 @@ export class TaskManager { private createRequestTaskStore(request?: JSONRPCRequest, sessionId?: string): RequestTaskStore { const taskStore = this._requireTaskStore; - const host = this._host; + const hooks = this._hooks; return { createTask: async taskParams => { @@ -676,7 +784,7 @@ export class TaskManager { method: 'notifications/tasks/status', params: task }); - await host?.notification(notification as Notification); + await hooks?.channel()?.notification(notification as Notification); if (isTerminal(task.status)) { this._cleanupTaskProgressHandler(taskId); } @@ -701,7 +809,7 @@ export class TaskManager { method: 'notifications/tasks/status', params: updatedTask }); - await host?.notification(notification as Notification); + await hooks?.channel()?.notification(notification as Notification); if (isTerminal(updatedTask.status)) { this._cleanupTaskProgressHandler(taskId); } @@ -711,40 +819,7 @@ export class TaskManager { }; } - // -- Lifecycle methods (called by Protocol directly) -- - - processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { - const taskInfo = this.extractInboundTaskContext(request, ctx.sessionId); - const relatedTaskId = taskInfo?.relatedTaskId; - - const sendNotification = relatedTaskId - ? this.wrapSendNotification(relatedTaskId, ctx.sendNotification) - : (notification: Notification) => ctx.sendNotification(notification); - - const sendRequest = relatedTaskId - ? this.wrapSendRequest(relatedTaskId, taskInfo?.taskContext?.store, ctx.sendRequest) - : taskInfo?.taskContext - ? this.wrapSendRequest('', taskInfo.taskContext.store, ctx.sendRequest) - : ctx.sendRequest; - - const hasTaskCreationParams = !!taskInfo?.taskCreationParams; - - return { - taskContext: taskInfo?.taskContext, - sendNotification, - sendRequest, - routeResponse: async (message: JSONRPCResponse | JSONRPCErrorResponse) => { - if (relatedTaskId) { - return this.routeResponse(relatedTaskId, message, ctx.sessionId); - } - return false; - }, - hasTaskCreationParams, - // Deferred validation: runs inside the async handler chain so errors - // produce proper JSON-RPC error responses (matching main's behavior). - validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined - }; - } + // -- OutboundMiddleware lifecycle methods -- processOutboundRequest( jsonrpcRequest: JSONRPCRequest, @@ -753,9 +828,8 @@ export class TaskManager { responseHandler: (response: JSONRPCResultResponse | Error) => void, onError: (error: unknown) => void ): { queued: boolean } { - // Check task capability when sending a task-augmented request (matches main's enforceStrictCapabilities gate) - if (this._requireHost.enforceStrictCapabilities && options?.task) { - this._requireHost.assertTaskCapability(jsonrpcRequest.method); + if (this._requireHooks.enforceStrictCapabilities && options?.task) { + this._requireHooks.assertTaskCapability(jsonrpcRequest.method); } const queued = this.prepareOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, onError); @@ -824,7 +898,7 @@ export class TaskManager { resolver(new ProtocolError(ProtocolErrorCode.InternalError, 'Task cancelled or completed')); this._requestResolvers.delete(requestId); } else { - this._host?.reportError(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); + this._hooks?.reportError(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); } } } @@ -854,7 +928,7 @@ export class TaskManager { private _cleanupTaskProgressHandler(taskId: string): void { const progressToken = this._taskProgressTokens.get(taskId); if (progressToken !== undefined) { - this._host?.removeProgressHandler(progressToken); + this._hooks?.channel()?.removeProgressHandler?.(progressToken); this._taskProgressTokens.delete(taskId); } } @@ -862,32 +936,44 @@ export class TaskManager { /** * No-op TaskManager used when tasks capability is not configured. - * Provides passthrough implementations for the hot paths, avoiding - * unnecessary task extraction logic on every request. + * Its middleware getters return identity / no-op so registering it costs nothing. */ export class NullTaskManager extends TaskManager { constructor() { super({}); } - override processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { - const hasTaskCreationParams = isTaskAugmentedRequestParams(request.params) && !!request.params.task; - return { - taskContext: undefined, - sendNotification: (notification: Notification) => ctx.sendNotification(notification), - sendRequest: ctx.sendRequest, - routeResponse: async () => false, - hasTaskCreationParams, - validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined - }; + override get dispatchMiddleware(): DispatchMiddleware { + // No store → identity middleware. Only validate task-creation capability so the + // "client sent params.task but server has no tasks capability" error path matches. + // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment + const tm = this; + return next => + async function* (req, env) { + if (isTaskAugmentedRequestParams(req.params) && req.params.task) { + try { + tm._requireHooks.assertTaskHandlerCapability(req.method); + } catch (error) { + const e = error as { code?: number; message?: string; data?: unknown }; + yield { + kind: 'response', + message: { + jsonrpc: '2.0', + id: req.id, + error: { + code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + message: e?.message ?? 'Internal error', + ...(e?.data !== undefined && { data: e.data }) + } + } + }; + return; + } + } + yield* next(req, env); + } as DispatchFn; } - // processOutboundRequest is inherited - it handles task/relatedTask augmentation - // and only queues if relatedTask is set (which won't happen without a task store) - - // processInboundResponse is inherited - it checks _requestResolvers (empty for NullTaskManager) - // and _taskProgressTokens (empty for NullTaskManager) - override async processOutboundNotification( notification: Notification, _options?: NotificationOptions diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 8711b80ee..33921a36b 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -7,8 +7,7 @@ import type { MessageExtraInfo, RequestId } from '../types/index.js'; -import type { OutboundInterceptor } from './context.js'; -import type { DispatchEnv } from './dispatcher.js'; +import type { OutboundMiddleware, RequestEnv } from './context.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -151,14 +150,14 @@ export interface ChannelTransport { export type Transport = ChannelTransport; /** - * Options McpServer passes when wiring a {@linkcode ChannelTransport} via {@linkcode attachPipeTransport}. + * Options McpServer passes when wiring a {@linkcode ChannelTransport} via {@linkcode attachChannelTransport}. * @internal */ export type AttachOptions = { supportedProtocolVersions?: string[]; debouncedNotificationMethods?: string[]; - interceptor?: OutboundInterceptor; - buildEnv?: (extra: MessageExtraInfo | undefined, base: DispatchEnv) => DispatchEnv; + outboundMw?: OutboundMiddleware[]; + buildEnv?: (extra: MessageExtraInfo | undefined, base: RequestEnv) => RequestEnv; onclose?: () => void; onerror?: (error: Error) => void; }; @@ -181,7 +180,7 @@ export interface RequestTransport { * Transports MUST declare this property (initialised to `undefined`) so * {@linkcode isRequestTransport} can discriminate before `connect()` runs. */ - onrequest?: ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined; + onrequest?: ((req: JSONRPCRequest, env?: RequestEnv) => AsyncIterable) | undefined; /** Callback slot for inbound notifications (e.g. `notifications/initialized`). */ onnotification?: (n: JSONRPCNotification) => void | Promise; diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 64378e27b..6a91b9f83 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -5624,12 +5624,12 @@ describe('TaskManager lifecycle via Protocol', () => { protocol = new TestProtocolImpl(); }); - test('bind() is called during Protocol construction', () => { - const bindSpy = vi.spyOn(TaskManager.prototype, 'bind'); + test('attachTo() is called during Protocol construction', () => { + const attachSpy = vi.spyOn(TaskManager.prototype, 'attachTo'); const p = new TestProtocolImpl({ tasks: {} }); - expect(bindSpy).toHaveBeenCalled(); + expect(attachSpy).toHaveBeenCalled(); expect(p.taskManager).toBeInstanceOf(TaskManager); - bindSpy.mockRestore(); + attachSpy.mockRestore(); }); test('NullTaskManager is created when no tasks config is provided', () => { diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 74fe404a8..d0c4f1139 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -13,13 +13,13 @@ import { getRequestListener } from '@hono/node-server'; import type { AuthInfo, ChannelTransport, - DispatchEnv, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResultResponse, MessageExtraInfo, + RequestEnv, RequestId, RequestTransport } from '@modelcontextprotocol/core'; @@ -163,10 +163,10 @@ export class NodeStreamableHTTPServerTransport implements ChannelTransport, Requ } // RequestTransport callback slots — delegate to the wrapped web-standard transport. - get onrequest(): ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined { + get onrequest(): ((req: JSONRPCRequest, env?: RequestEnv) => AsyncIterable) | undefined { return this._webStandardTransport.onrequest; } - set onrequest(h: ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined) { + set onrequest(h: ((req: JSONRPCRequest, env?: RequestEnv) => AsyncIterable) | undefined) { this._webStandardTransport.onrequest = h; } get onnotification(): ((n: JSONRPCNotification) => void | Promise) | undefined { diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 310a9c8ca..f95586f6c 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -11,13 +11,10 @@ import type { CreateMessageResult, CreateMessageResultWithTools, CreateTaskResult, - DispatchEnv, - DispatchOutput, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, Implementation, - InboundContext, InitializeRequest, InitializeResult, JSONRPCErrorResponse, @@ -34,10 +31,11 @@ import type { Notification, NotificationMethod, NotificationOptions, - OutboundChannel, + Outbound, + OutboundMiddleware, ProtocolOptions, Request, - RequestId, + RequestEnv, RequestMethod, RequestOptions, RequestTransport, @@ -51,7 +49,6 @@ import type { ServerResult, StandardSchemaV1, StandardSchemaWithJSON, - TaskManagerHost, TaskManagerOptions, ToolAnnotations, ToolExecution, @@ -62,9 +59,10 @@ import type { import { assertClientRequestTaskCapability, assertToolsCallTaskCapability, - attachPipeTransport, + attachChannelTransport, CallToolRequestSchema, CallToolResultSchema, + composeOutboundMiddleware, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, @@ -83,7 +81,6 @@ import { parseSchema, ProtocolError, ProtocolErrorCode, - RELATED_TASK_META_KEY, SdkError, SdkErrorCode, SUPPORTED_PROTOCOL_VERSIONS, @@ -146,10 +143,9 @@ export type ServerOptions = Omit & { * One instance can serve any number of concurrent requests. */ export class McpServer extends Dispatcher implements RegistriesHost { - private _outbound?: OutboundChannel; + private _outbound?: Outbound; private readonly _registries = new ServerRegistries(this); - private readonly _dispatchYielders = new Map void>(); - private _dispatchOutboundId = 0; + private readonly _outboundMw: OutboundMiddleware[] = []; private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; @@ -197,7 +193,14 @@ export class McpServer extends Dispatcher implements RegistriesHo const tasksOpts = extractTaskManagerOptions(_options?.capabilities?.tasks); this._taskManager = tasksOpts ? new TaskManager(tasksOpts) : new NullTaskManager(); - this._bindTaskManager(); + const tasksOutbound = this._taskManager.attachTo(this, { + channel: () => this._outbound, + reportError: e => (this.onerror ?? (() => {}))(e), + enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, + assertTaskCapability: m => assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, m, 'Client'), + assertTaskHandlerCapability: m => assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, m, 'Server') + }); + this.useOutbound(tasksOutbound); this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setRequestHandler('ping', () => ({})); @@ -209,159 +212,36 @@ export class McpServer extends Dispatcher implements RegistriesHo } // ─────────────────────────────────────────────────────────────────────── - // Direct dispatch + // Middleware + direct dispatch // ─────────────────────────────────────────────────────────────────────── /** - * Task-aware dispatch. Threads {@linkcode TaskManager.processInboundRequest} so - * `tasks/*` methods, task-augmented `tools/call`, and `routeResponse` queueing all - * work for callers that bypass {@linkcode StreamDriver} (e.g. {@linkcode shttpHandler}). + * Register an {@linkcode OutboundMiddleware} applied at the request-correlation seam + * (before each outbound write, for each inbound response, on close). */ - override async *dispatch(request: JSONRPCRequest, env: DispatchEnv = {}): AsyncGenerator { - const sendOnStream = env.send; - const inboundCtx: InboundContext = { - sessionId: env.sessionId, - sendNotification: async () => {}, - sendRequest: (r, schema, opts) => - new Promise((resolve, reject) => { - const messageId = this._dispatchOutboundId++; - const wire: JSONRPCRequest = { jsonrpc: '2.0', id: messageId, method: r.method, params: r.params }; - const settle = (resp: { result: Result } | Error) => { - if (resp instanceof Error) return reject(resp); - const parsed = parseSchema(schema, resp.result); - if (parsed.success) { - resolve(parsed.data); - } else { - reject(parsed.error); - } - }; - const { queued } = this._taskManager.processOutboundRequest(wire, opts, messageId, settle, reject); - if (queued) return; - if (!sendOnStream) { - reject( - new SdkError( - SdkErrorCode.NotConnected, - 'ctx.mcpReq.send is unavailable: no peer channel. Use the MRTR-native return form for elicitation/sampling, or run via connect()/StreamDriver.' - ) - ); - return; - } - sendOnStream({ method: wire.method, params: wire.params }, opts).then(result => settle({ result }), reject); - }) - }; - const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); - - if (taskResult.validateInbound) { - try { - taskResult.validateInbound(); - } catch (error) { - const e = error as { code?: number; message?: string; data?: unknown }; - yield { - kind: 'response', - message: { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(e?.code) ? (e.code as number) : ProtocolErrorCode.InternalError, - message: e?.message ?? 'Internal error', - ...(e?.data !== undefined && { data: e.data }) - } - } - }; - return; - } - } - - const relatedTaskId = taskResult.taskContext?.id; - const taskEnv: DispatchEnv = { - ...env, - task: taskResult.taskContext ?? env.task, - send: (r, opts) => taskResult.sendRequest(r, getResultSchema(r.method as RequestMethod), opts) as Promise - }; - - // Queued task messages delivered via host.sendOnResponseStream are routed to this - // generator (instead of `_outbound.sendRaw`) so they yield on the same stream. - const sideQueue: JSONRPCMessage[] = []; - let wake: (() => void) | undefined; - this._dispatchYielders.set(request.id, msg => { - sideQueue.push(msg); - wake?.(); - }); - - const drain = function* (): Generator { - while (sideQueue.length > 0) { - const msg = sideQueue.shift()!; - yield 'method' in msg - ? { kind: 'notification', message: msg as JSONRPCNotification } - : { kind: 'response', message: msg as JSONRPCResponse | JSONRPCErrorResponse }; - } - }; - - try { - const inner = super.dispatch(request, taskEnv); - let pending: Promise> | undefined; - - while (true) { - yield* drain(); - pending ??= inner.next(); - const wakeP = new Promise<'side'>(resolve => { - wake = () => resolve('side'); - }); - if (sideQueue.length > 0) { - wake = undefined; - continue; - } - const r = await Promise.race([pending, wakeP]); - wake = undefined; - if (r === 'side') continue; - pending = undefined; - if (r.done) break; - const out = r.value; - if (out.kind === 'response') { - const routed = await taskResult.routeResponse(out.message); - if (!routed) { - yield* drain(); - yield out; - } - } else if (relatedTaskId === undefined) { - yield out; - } else { - const params = (out.message.params ?? {}) as Record; - yield { - kind: 'notification', - message: { - ...out.message, - params: { - ...params, - _meta: { ...(params._meta as object), [RELATED_TASK_META_KEY]: { taskId: relatedTaskId } } - } - } - }; - } - } - yield* drain(); - } finally { - this._dispatchYielders.delete(request.id); - } + useOutbound(mw: OutboundMiddleware): this { + this._outboundMw.push(mw); + return this; } /** * Routes an incoming JSON-RPC response (e.g. a client's reply to an `elicitation/create` - * request the server issued) to the {@linkcode TaskManager} resolver chain. + * request the server issued) through the registered {@linkcode OutboundMiddleware} chain. * Called by {@linkcode shttpHandler} for response-typed POST bodies. * - * @returns true if the response was consumed. + * @returns true if a middleware consumed it. */ dispatchInboundResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean { const id = typeof response.id === 'number' ? response.id : Number(response.id); - return this._taskManager.processInboundResponse(response, id).consumed; + const mw = composeOutboundMiddleware(this._outboundMw); + return mw.response?.(response, id)?.consumed ?? false; } /** * Handle one inbound request without a transport. Yields any notifications the handler * emits via `ctx.mcpReq.notify()`, then yields exactly one terminal response. */ - async *handle(request: JSONRPCRequest, env?: DispatchEnv): AsyncGenerator { + async *handle(request: JSONRPCRequest, env?: RequestEnv): AsyncGenerator { for await (const out of this.dispatch(request, env)) { yield out.message; } @@ -379,7 +259,7 @@ export class McpServer extends Dispatcher implements RegistriesHo return jsonResponse(400, { jsonrpc: '2.0', id: null, error: { code: ProtocolErrorCode.ParseError, message: 'Parse error' } }); } const messages = Array.isArray(body) ? body : [body]; - const env: DispatchEnv = { authInfo: opts?.authInfo, httpReq: req }; + const env: RequestEnv = { authInfo: opts?.authInfo, httpReq: req }; const responses: JSONRPCMessage[] = []; for (const m of messages) { if (!isJSONRPCRequest(m)) { @@ -405,13 +285,13 @@ export class McpServer extends Dispatcher implements RegistriesHo * * - For {@linkcode RequestTransport} (Streamable HTTP): sets the transport's * `onrequest`/`onnotification`/`onresponse` callback slots so it can route inbound - * messages here, and builds an {@linkcode OutboundChannel} from the transport's + * messages here, and builds an {@linkcode Outbound} from the transport's * optional `notify`/`request` methods. * - For {@linkcode ChannelTransport} (stdio/WebSocket/InMemory): wraps it in a - * {@linkcode StreamDriver} via {@linkcode attachPipeTransport}. + * {@linkcode StreamDriver} via {@linkcode attachChannelTransport}. */ async connect(transport: ChannelTransport | RequestTransport): Promise { - let outbound: OutboundChannel | undefined; + let outbound: Outbound | undefined; if (isRequestTransport(transport)) { transport.onrequest = (req, env) => this.handle(req, env); transport.onnotification = n => this.dispatchNotification(n); @@ -436,15 +316,14 @@ export class McpServer extends Dispatcher implements RegistriesHo `Transport does not support out-of-band ${kind}; use ctx.mcpReq inside a handler.` ) ); + const mw = composeOutboundMiddleware(this._outboundMw); outbound = { close: () => transport.close(), notification: transport.notify ? async (n, opts) => { - const out = await this._taskManager.processOutboundNotification( - { jsonrpc: '2.0', ...n } as JSONRPCNotification, - opts - ); - if (!out.queued && out.jsonrpcNotification) await transport.notify!(out.jsonrpcNotification); + const out = (await mw.notification?.(n, opts)) ?? { queued: false }; + if (!out.queued) + await transport.notify!(out.jsonrpcNotification ?? ({ jsonrpc: '2.0', ...n } as JSONRPCNotification)); } : noOutbound('notifications'), request: transport.request @@ -464,16 +343,11 @@ export class McpServer extends Dispatcher implements RegistriesHo : noOutbound('requests') }; } else { - outbound = await attachPipeTransport(transport, this, { + outbound = await attachChannelTransport(transport, this, { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), - interceptor: { - request: (jr, o, id, settle, reject) => this._taskManager.processOutboundRequest(jr, o, id, settle, reject), - notification: (n, o) => this._taskManager.processOutboundNotification(n, o), - response: (r, id) => this._taskManager.processInboundResponse(r, id), - close: () => this._taskManager.onClose() - }, + outboundMw: this._outboundMw, onclose: () => { if (this._outbound === outbound) this._outbound = undefined; this.onclose?.(); @@ -529,33 +403,11 @@ export class McpServer extends Dispatcher implements RegistriesHo return this._taskManager; } - private _bindTaskManager(): void { - const host: TaskManagerHost = { - request: (r, schema, opts) => this._outboundRequest(r, schema as never, opts), - notification: (n, opts) => this.notification(n, opts), - reportError: e => (this.onerror ?? (() => {}))(e), - removeProgressHandler: t => this._outbound?.removeProgressHandler?.(t), - registerHandler: (m, h) => this.setRawRequestHandler(m, h as never), - sendOnResponseStream: async (msg, relatedRequestId) => { - const yielder = relatedRequestId === undefined ? undefined : this._dispatchYielders.get(relatedRequestId); - if (yielder) { - yielder(msg); - return; - } - await this._outbound?.sendRaw?.(msg, { relatedRequestId }); - }, - enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, - assertTaskCapability: m => assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, m, 'Client'), - assertTaskHandlerCapability: m => assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, m, 'Server') - }; - this._taskManager.bind(host); - } - // ─────────────────────────────────────────────────────────────────────── // Context building // ─────────────────────────────────────────────────────────────────────── - protected override buildContext(base: BaseContext, env: DispatchEnv & { _transportExtra?: MessageExtraInfo }): ServerContext { + protected override buildContext(base: BaseContext, env: RequestEnv & { _transportExtra?: MessageExtraInfo }): ServerContext { const extra = env._transportExtra; const hasHttpInfo = base.http || env.httpReq || extra?.closeSSEStream || extra?.closeStandaloneSSEStream; const ctx: ServerContext = { @@ -734,10 +586,10 @@ export class McpServer extends Dispatcher implements RegistriesHo } // ─────────────────────────────────────────────────────────────────────── - // Server→client requests (require a connected OutboundChannel) + // Server→client requests (require a connected Outbound) // ─────────────────────────────────────────────────────────────────────── - private _requireOutbound(): OutboundChannel { + private _requireOutbound(): Outbound { if (!this._outbound) { throw new SdkError( SdkErrorCode.NotConnected, diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index 16c272346..a8b2ecfc5 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -1,12 +1,12 @@ import type { AuthInfo, - DispatchEnv, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResultResponse, - MessageExtraInfo + MessageExtraInfo, + RequestEnv } from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, @@ -61,7 +61,7 @@ export interface EventStore { */ export interface ShttpCallbacks { /** Called per inbound JSON-RPC request; yields notifications then one terminal response. */ - onrequest?: ((request: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined; + onrequest?: ((request: JSONRPCRequest, env?: RequestEnv) => AsyncIterable) | undefined; /** Called per inbound JSON-RPC notification. */ onnotification?: (notification: JSONRPCNotification) => void | Promise; /** Called per inbound JSON-RPC response (client POSTing back to a server-initiated request). Returns `true` if claimed. */ @@ -307,7 +307,7 @@ export function shttpHandler( ? initReq.params.protocolVersion : (req.headers.get('mcp-protocol-version') ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - const baseEnv: DispatchEnv = { sessionId, authInfo: extra?.authInfo, httpReq: req }; + const baseEnv: RequestEnv = { sessionId, authInfo: extra?.authInfo, httpReq: req }; const useBackchannel = backchannelEnabled(sessionId, clientProtocolVersion); if (enableJsonResponse) { @@ -351,7 +351,7 @@ export function shttpHandler( } : undefined }; - const env: DispatchEnv & { _transportExtra?: MessageExtraInfo } = { + const env: RequestEnv & { _transportExtra?: MessageExtraInfo } = { ...baseEnv, _transportExtra: transportExtra, ...(useBackchannel && backchannel && sessionId !== undefined diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index f5e707872..201e5e2c3 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -15,13 +15,13 @@ import type { AuthInfo, ChannelTransport, - DispatchEnv, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResultResponse, MessageExtraInfo, + RequestEnv, RequestTransport, TransportSendOptions } from '@modelcontextprotocol/core'; @@ -172,7 +172,7 @@ export class WebStandardStreamableHTTPServerTransport implements ChannelTranspor onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; /** {@linkcode RequestTransport.onrequest} — set by `McpServer.connect()`. Declared so {@linkcode isRequestTransport} matches. */ - onrequest: ((req: JSONRPCRequest, env?: DispatchEnv) => AsyncIterable) | undefined = undefined; + onrequest: ((req: JSONRPCRequest, env?: RequestEnv) => AsyncIterable) | undefined = undefined; /** {@linkcode RequestTransport.onnotification} — set by `McpServer.connect()`. */ onnotification?: (n: JSONRPCNotification) => void | Promise; /** {@linkcode RequestTransport.onresponse} — set by `McpServer.connect()`. */ diff --git a/packages/server/test/server/shttpHandler.test.ts b/packages/server/test/server/shttpHandler.test.ts index f21d50e25..2dd3b9712 100644 --- a/packages/server/test/server/shttpHandler.test.ts +++ b/packages/server/test/server/shttpHandler.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { DispatchEnv, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { RequestEnv, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; import { SessionCompat } from '../../src/server/sessionCompat.js'; import type { ShttpCallbacks } from '../../src/server/shttpHandler.js'; @@ -12,7 +12,7 @@ function fakeServer( opts: { preNotify?: JSONRPCNotification } = {} ): ShttpCallbacks { return { - async *onrequest(req: JSONRPCRequest, _env?: DispatchEnv): AsyncIterable { + async *onrequest(req: JSONRPCRequest, _env?: RequestEnv): AsyncIterable { if (opts.preNotify) yield opts.preNotify; const h = handlers[req.method]; if (!h) { From fc9885bebe67d753ed7668ff7b85b3d408974785 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 19:46:58 +0000 Subject: [PATCH 44/55] simplify: remove dispatchRaw + RawDispatchOutput + grpc-integration doc No consumer; synthesizes id:0 which breaks correlation/cancellation; SEP-2598 (which would govern the mapping) is still draft. Re-add when a real gRPC transport is in scope. --- docs/grpc-integration.md | 75 -------------------- packages/core/src/shared/dispatcher.ts | 33 --------- packages/core/test/shared/dispatcher.test.ts | 32 --------- 3 files changed, 140 deletions(-) delete mode 100644 docs/grpc-integration.md diff --git a/docs/grpc-integration.md b/docs/grpc-integration.md deleted file mode 100644 index 6d669c53d..000000000 --- a/docs/grpc-integration.md +++ /dev/null @@ -1,75 +0,0 @@ -# gRPC Integration via `Dispatcher.dispatchRaw` - -Status: experimental. The `dispatchRaw` entry point lets a non-JSON-RPC driver (gRPC, REST, protobuf) call MCP handlers without constructing JSON-RPC envelopes. - -## The seam - -`McpServer extends Dispatcher`, so any server has: - -```ts -mcpServer.dispatchRaw(method: string, params: unknown, env?: DispatchEnv): AsyncIterable - -type RawDispatchOutput = - | { kind: 'notification'; method: string; params?: unknown } - | { kind: 'result'; result: Result } - | { kind: 'error'; code: number; message: string; data?: unknown }; -``` - -No `{jsonrpc: '2.0', id}` wrapping in or out. Handlers registered via `registerTool`/`setRequestHandler` work unchanged — `dispatchRaw` synthesizes the envelope internally. - -## gRPC service binding (sketch) - -Given a `.proto` with per-method RPCs (per SEP-1319's named param/result types): - -```proto -service Mcp { - rpc CallTool(CallToolRequestParams) returns (stream CallToolStreamItem); - rpc ListTools(ListToolsRequestParams) returns (ListToolsResult); - // ... -} -``` - -The adapter is one function per method: - -```ts -import * as grpc from '@grpc/grpc-js'; -import { McpServer } from '@modelcontextprotocol/server'; - -export function bindMcpToGrpc(mcpServer: McpServer): grpc.UntypedServiceImplementation { - return { - async CallTool(call: grpc.ServerWritableStream) { - const env = { authInfo: extractAuth(call.metadata) }; - for await (const out of mcpServer.dispatchRaw('tools/call', protoToObj(call.request), env)) { - if (out.kind === 'notification') call.write({ notification: objToProto(out) }); - else if (out.kind === 'result') call.write({ result: objToProto(out.result) }); - else call.destroy(new grpc.StatusBuilder().withCode(grpcCodeFor(out.code)).withDetails(out.message).build()); - } - call.end(); - }, - async ListTools(call, callback) { - for await (const out of mcpServer.dispatchRaw('tools/list', protoToObj(call.request))) { - if (out.kind === 'result') return callback(null, objToProto(out.result)); - if (out.kind === 'error') return callback({ code: grpcCodeFor(out.code), details: out.message }); - } - }, - // ... one binding per method - }; -} -``` - -`protoToObj`/`objToProto` are mechanical (protobuf message ↔ plain object). The `.proto` itself can be generated from `spec.types.ts` since SEP-1319 gives every params/result a named top-level type. - -## Server→client (elicitation/sampling) - -gRPC unary has no back-channel. Two options: - -1. **MRTR (recommended):** handler returns `IncompleteResult{InputRequests}`; `dispatchRaw` yields it as the result; the gRPC client re-calls with `inputResponses`. This is the SEP-2322 model and works without bidi streaming. -2. **Bidi stream:** make `tools/call` a bidi RPC; the server writes elicitation requests to the stream, client writes responses. Pass `env.send` that writes to the stream and awaits a matching reply. - -`dispatchRaw` supports both: with no `env.send`, `ctx.mcpReq.elicitInput()` throws (handler must use MRTR-native form); with `env.send` provided, it works inline. - -## What's not in the SDK - -- The `.proto` file (separate artifact, ideally generated) -- The `@modelcontextprotocol/grpc` adapter package (the binding above) -- protobuf↔object conversion helpers diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index a0b93092f..817fca86a 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -29,14 +29,6 @@ export type DispatchOutput = | { kind: 'notification'; message: JSONRPCNotification } | { kind: 'response'; message: JSONRPCResponse | JSONRPCErrorResponse }; -/** - * Envelope-agnostic output from {@linkcode Dispatcher.dispatchRaw}. No JSON-RPC `{jsonrpc, id}` wrapping. - */ -export type RawDispatchOutput = - | { kind: 'notification'; method: string; params?: Record } - | { kind: 'result'; result: Result } - | { kind: 'error'; code: number; message: string; data?: unknown }; - type RawHandler = (request: JSONRPCRequest, ctx: ContextT) => Promise; /** Signature of {@linkcode Dispatcher.dispatch}. Target type for {@linkcode DispatchMiddleware}. */ @@ -191,31 +183,6 @@ export class Dispatcher { yield { kind: 'response', message: final! }; } - /** - * Envelope-agnostic dispatch for non-JSON-RPC drivers (gRPC, protobuf, REST). - * Takes `{method, params}` directly and yields unwrapped notifications and a terminal - * result/error. The JSON-RPC `{jsonrpc, id}` envelope is synthesized internally so - * registered handlers (which receive `JSONRPCRequest`) work unchanged. - * - * @experimental Shape may change to align with SEP-1319 named param/result types. - */ - async *dispatchRaw( - method: string, - params: Record | undefined, - env: RequestEnv = {} - ): AsyncGenerator { - const synthetic: JSONRPCRequest = { jsonrpc: '2.0', id: 0, method, params }; - for await (const out of this.dispatch(synthetic, env)) { - if (out.kind === 'notification') { - yield { kind: 'notification', method: out.message.method, params: out.message.params }; - } else if ('result' in out.message) { - yield { kind: 'result', result: out.message.result }; - } else { - yield { kind: 'error', ...out.message.error }; - } - } - } - /** * Dispatch one inbound notification to its handler. Errors are reported via the * returned promise; unknown methods are silently ignored. diff --git a/packages/core/test/shared/dispatcher.test.ts b/packages/core/test/shared/dispatcher.test.ts index 0aee6040b..86ee1f5a4 100644 --- a/packages/core/test/shared/dispatcher.test.ts +++ b/packages/core/test/shared/dispatcher.test.ts @@ -212,35 +212,3 @@ describe('Dispatcher.setRequestHandler 3-arg (custom method + paramsSchema)', () expect(r.error.message).toMatch(/Invalid params for acme\/search/); }); }); - -describe('Dispatcher.dispatchRaw (envelope-agnostic)', () => { - test('yields result without JSON-RPC envelope', async () => { - const d = new Dispatcher(); - d.setRawRequestHandler('greet', async r => ({ hello: (r.params as { name: string }).name }) as Result); - const out = []; - for await (const o of d.dispatchRaw('greet', { name: 'proto' })) out.push(o); - expect(out).toEqual([{ kind: 'result', result: { hello: 'proto' } }]); - }); - - test('yields error without envelope', async () => { - const d = new Dispatcher(); - d.setRawRequestHandler('boom', async () => { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad'); - }); - const out = []; - for await (const o of d.dispatchRaw('boom', {})) out.push(o); - expect(out).toEqual([{ kind: 'error', code: ProtocolErrorCode.InvalidParams, message: 'bad' }]); - }); - - test('yields notifications then result', async () => { - const d = new Dispatcher(); - d.setRawRequestHandler('work', async (_r, ctx) => { - await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 't', progress: 1 } }); - return { done: true } as Result; - }); - const out = []; - for await (const o of d.dispatchRaw('work', {})) out.push(o); - expect(out[0]).toMatchObject({ kind: 'notification', method: 'notifications/progress' }); - expect(out[1]).toEqual({ kind: 'result', result: { done: true } }); - }); -}); From c823def2effbd0be61d5f96e09fab21336a3edfa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 19:56:18 +0000 Subject: [PATCH 45/55] simplify: remove OutboundMiddleware/useOutbound; StreamDriver calls TaskManager directly OutboundMiddleware was a 4-hook record (request/notification/response/close) with first-claim-wins composition, registered via useOutbound() on McpServer/ Client/Protocol. Its only consumer was TaskManager, and it created a second, incompatible middleware pattern alongside Dispatcher.use(). Now: StreamDriver takes `taskManager?: TaskManager` and calls processOutbound*/ processInboundResponse/onClose at explicit call sites. McpServer/Client/Protocol pass their TaskManager via attach options. TaskManager.attachTo() returns void (inbound use() registration + handler install only). Dispatcher.use() (the standard (next)=>fn inbound pattern) is unchanged. --- packages/client/src/client/client.ts | 22 ++----- packages/core/src/shared/context.ts | 75 ------------------------ packages/core/src/shared/protocol.ts | 22 +------ packages/core/src/shared/streamDriver.ts | 37 ++++++------ packages/core/src/shared/taskManager.ts | 27 ++------- packages/core/src/shared/transport.ts | 5 +- packages/server/src/server/mcpServer.ts | 31 +++------- 7 files changed, 44 insertions(+), 175 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 7dfe7f5cb..844db606e 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -32,7 +32,6 @@ import type { Notification, NotificationMethod, NotificationOptions, - OutboundMiddleware, ProtocolOptions, ReadResourceRequest, Request, @@ -55,7 +54,6 @@ import { CallToolResultSchema, CancelTaskResultSchema, CompleteResultSchema, - composeOutboundMiddleware, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -230,7 +228,6 @@ export class Client extends Dispatcher { private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); private _taskManager: TaskManager; - private readonly _outboundMw: OutboundMiddleware[] = []; onclose?: () => void; onerror?: (error: Error) => void; @@ -249,7 +246,7 @@ export class Client extends Dispatcher { const tasksOpts = extractTaskManagerOptions(_options?.capabilities?.tasks); this._taskManager = tasksOpts ? new TaskManager(tasksOpts) : new NullTaskManager(); - const tasksOutbound = this._taskManager.attachTo(this, { + this._taskManager.attachTo(this, { channel: () => this._ct ? { @@ -264,7 +261,6 @@ export class Client extends Dispatcher { assertTaskCapability: () => {}, assertTaskHandlerCapability: () => {} }); - this.useOutbound(tasksOutbound); // Strip runtime-only fields from advertised capabilities if (_options?.capabilities?.tasks) { @@ -277,14 +273,6 @@ export class Client extends Dispatcher { super.setRequestHandler('ping', async () => ({})); } - /** - * Register an {@linkcode OutboundMiddleware} applied at the request-correlation seam. - */ - useOutbound(mw: OutboundMiddleware): this { - this._outboundMw.push(mw); - return this; - } - /** * Connects to a server. Accepts either a {@linkcode ClientTransport} * (2026-06-native, request-shaped) or a legacy pipe {@linkcode Transport} @@ -297,7 +285,7 @@ export class Client extends Dispatcher { const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - outboundMw: this._outboundMw + taskManager: this._taskManager }; this._ct = channelAsClientTransport(transport, this, driverOpts); this._ct.driver!.onclose = () => this.onclose?.(); @@ -354,8 +342,7 @@ export class Client extends Dispatcher { return resp ?? { jsonrpc: '2.0', id: r.id, error: { code: -32_601, message: 'Method not found' } }; }, onresponse: r => { - const mw = composeOutboundMiddleware(this._outboundMw); - const consumed = mw.response?.(r, Number(r.id))?.consumed ?? false; + const consumed = this._taskManager.processInboundResponse(r, Number(r.id)).consumed; if (!consumed) this.onerror?.(new Error(`Unmatched response on standalone stream: ${JSON.stringify(r)}`)); } }); @@ -699,8 +686,7 @@ export class Client extends Dispatcher { onresumptiontoken: options?.onresumptiontoken, onnotification: n => void this.dispatchNotification(n).catch(error => this.onerror?.(error)), onresponse: r => { - const mw = composeOutboundMiddleware(this._outboundMw); - const consumed = mw.response?.(r, Number(r.id))?.consumed ?? false; + const consumed = this._taskManager.processInboundResponse(r, Number(r.id)).consumed; if (!consumed) this.onerror?.(new Error(`Unmatched response on stream: ${JSON.stringify(r)}`)); }, onrequest: async r => { diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 1b095699b..2aec48d0c 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -7,12 +7,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, - JSONRPCErrorResponse, JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - JSONRPCResultResponse, LoggingLevel, Notification, Progress, @@ -194,76 +189,6 @@ export interface Outbound { sendRaw?(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise; } -/** - * Middleware around the request-correlation seam of an {@linkcode Outbound}. - * Registered via `useOutbound()` on {@linkcode McpServer} / {@linkcode Client}; - * the transport adapter (e.g. {@linkcode StreamDriver}) calls each hook without - * knowing why a message is queued or consumed. - * - * Unlike {@linkcode DispatchMiddleware} (a single function wrapping `next`), the - * outbound seam has four distinct call sites, so this is a record of optional hooks. - * Multiple middleware are composed with {@linkcode composeOutboundMiddleware} — - * first to claim wins for `request`/`notification`/`response`; all `close` run. - */ -export interface OutboundMiddleware { - /** Called before each outbound request hits the wire. Return `queued: true` to suppress the send (caller resolves via `settle`). */ - request?( - jr: JSONRPCRequest, - options: RequestOptions | undefined, - messageId: number, - settle: (r: JSONRPCResultResponse | Error) => void, - reject: (e: unknown) => void - ): { queued: boolean }; - /** Called before each outbound notification. May suppress and/or rewrite. */ - notification?( - n: Notification, - options: NotificationOptions | undefined - ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }>; - /** Called for each inbound response before correlation. `consumed: true` swallows it. */ - response?(r: JSONRPCResponse | JSONRPCErrorResponse, messageId: number): { consumed: boolean; preserveProgress?: boolean }; - /** Called on connection close. */ - close?(): void; -} - -/** - * Composes a list of {@linkcode OutboundMiddleware} into one, registration-order. - * For `request`/`notification`/`response` the first middleware to claim (queued/consumed) - * short-circuits the rest; `close` runs all. - */ -export function composeOutboundMiddleware(mws: OutboundMiddleware[]): OutboundMiddleware { - if (mws.length <= 1) return mws[0] ?? {}; - return { - request(jr, opts, id, settle, reject) { - for (const mw of mws) { - const r = mw.request?.(jr, opts, id, settle, reject); - if (r?.queued) return r; - } - return { queued: false }; - }, - async notification(n, opts) { - let rewritten: JSONRPCNotification | undefined; - for (const mw of mws) { - const r = await mw.notification?.(n, opts); - if (r?.queued) return r; - if (r?.jsonrpcNotification) rewritten = r.jsonrpcNotification; - } - return { queued: false, jsonrpcNotification: rewritten }; - }, - response(r, id) { - let preserveProgress = false; - for (const mw of mws) { - const out = mw.response?.(r, id); - if (out?.consumed) return out; - if (out?.preserveProgress) preserveProgress = true; - } - return { consumed: false, preserveProgress }; - }, - close() { - for (const mw of mws) mw.close?.(); - } - }; -} - /** * Base context provided to all request handlers. */ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index c5ab25547..4b1792922 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -20,15 +20,7 @@ import type { } from '../types/index.js'; import { getResultSchema, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; -import type { - BaseContext, - NotificationOptions, - Outbound, - OutboundMiddleware, - ProtocolOptions, - RequestEnv, - RequestOptions -} from './context.js'; +import type { BaseContext, NotificationOptions, Outbound, ProtocolOptions, RequestEnv, RequestOptions } from './context.js'; import type { DispatchMiddleware } from './dispatcher.js'; import { Dispatcher } from './dispatcher.js'; import { StreamDriver } from './streamDriver.js'; @@ -73,18 +65,16 @@ export abstract class Protocol { })(); this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; this._ownTaskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); - const omw = this._ownTaskManager.attachTo(this._dispatcher, { + this._ownTaskManager.attachTo(this._dispatcher, { channel: () => this._outbound, reportError: e => this.onerror?.(e), enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, assertTaskCapability: m => this.assertTaskCapability(m), assertTaskHandlerCapability: m => this.assertTaskHandlerCapability(m) }); - this._outboundMw.push(omw); } private readonly _ownTaskManager: TaskManager; - private readonly _outboundMw: OutboundMiddleware[] = []; /** Register a {@linkcode DispatchMiddleware} on the inner dispatcher. */ use(mw: DispatchMiddleware): this { @@ -92,12 +82,6 @@ export abstract class Protocol { return this; } - /** Register an {@linkcode OutboundMiddleware} applied at the request-correlation seam. */ - useOutbound(mw: OutboundMiddleware): this { - this._outboundMw.push(mw); - return this; - } - // ─────────────────────────────────────────────────────────────────────── // Subclass hooks (v1 signatures) // ─────────────────────────────────────────────────────────────────────── @@ -179,7 +163,7 @@ export abstract class Protocol { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), - outboundMw: this._outboundMw + taskManager: this._ownTaskManager }); this._outbound = driver; driver.onclose = () => { diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index c614e73fe..1151aba2f 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -25,9 +25,10 @@ import { } from '../types/index.js'; import type { AnySchema, SchemaOutput } from '../util/schema.js'; import { parseSchema } from '../util/schema.js'; -import type { NotificationOptions, Outbound, OutboundMiddleware, ProgressCallback, RequestEnv, RequestOptions } from './context.js'; -import { composeOutboundMiddleware, DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; +import type { NotificationOptions, Outbound, ProgressCallback, RequestEnv, RequestOptions } from './context.js'; +import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; import type { Dispatcher } from './dispatcher.js'; +import type { TaskManager } from './taskManager.js'; import type { AttachOptions, Transport } from './transport.js'; type TimeoutInfo = { @@ -48,10 +49,12 @@ export type StreamDriverOptions = { */ buildEnv?: (extra: MessageExtraInfo | undefined, base: RequestEnv) => RequestEnv; /** - * {@linkcode OutboundMiddleware} hooks invoked at the request-correlation seam. - * Composed in registration order via {@linkcode composeOutboundMiddleware}. + * Optional {@linkcode TaskManager}. When provided, the driver calls its + * `processOutbound*` / `processInboundResponse` / `onClose` hooks at the + * request-correlation seam. Inbound task processing happens via the + * TaskManager's dispatch middleware (registered by the caller), not here. */ - outboundMw?: OutboundMiddleware[]; + taskManager?: TaskManager; }; /** @@ -70,7 +73,7 @@ export class StreamDriver implements Outbound { private _pendingDebouncedNotifications = new Set(); private _closed = false; private _supportedProtocolVersions: string[]; - private _mw: OutboundMiddleware; + private _taskManager?: TaskManager; onclose?: () => void; onerror?: (error: Error) => void; @@ -82,7 +85,7 @@ export class StreamDriver implements Outbound { private _options: StreamDriverOptions = {} ) { this._supportedProtocolVersions = _options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - this._mw = composeOutboundMiddleware(_options.outboundMw ?? []); + this._taskManager = _options.taskManager; } /** {@linkcode Outbound.removeProgressHandler}. */ @@ -206,14 +209,14 @@ export class StreamDriver implements Outbound { ); let queued = false; - if (this._mw.request) { + if (this._taskManager) { const sideChannelResponse = (resp: JSONRPCResultResponse | Error) => { const h = this._responseHandlers.get(messageId); if (h) h(resp); else this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); }; try { - queued = this._mw.request(jsonrpcRequest, options, messageId, sideChannelResponse, error => { + queued = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, sideChannelResponse, error => { this._progressHandlers.delete(messageId); reject(error); }).queued; @@ -243,9 +246,9 @@ export class StreamDriver implements Outbound { * Sends a notification over the pipe. Supports debouncing per the constructor option. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - const intercepted = await this._mw.notification?.(notification, options); - if (intercepted?.queued || this._closed) return; - const jsonrpc: JSONRPCNotification = intercepted?.jsonrpcNotification ?? { + const taskResult = await this._taskManager?.processOutboundNotification(notification, options); + if (taskResult?.queued || this._closed) return; + const jsonrpc: JSONRPCNotification = taskResult?.jsonrpcNotification ?? { jsonrpc: '2.0', method: notification.method, params: notification.params @@ -342,8 +345,8 @@ export class StreamDriver implements Outbound { private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); - const intercepted = this._mw.response?.(response, messageId); - if (intercepted?.consumed) return; + const taskResult = this._taskManager?.processInboundResponse(response, messageId); + if (taskResult?.consumed) return; const handler = this._responseHandlers.get(messageId); if (handler === undefined) { @@ -352,7 +355,7 @@ export class StreamDriver implements Outbound { } this._responseHandlers.delete(messageId); this._cleanupTimeout(messageId); - if (!intercepted?.preserveProgress) { + if (!taskResult?.preserveProgress) { this._progressHandlers.delete(messageId); } if (isJSONRPCResultResponse(response)) { @@ -367,7 +370,7 @@ export class StreamDriver implements Outbound { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); - this._mw.close?.(); + this._taskManager?.onClose(); this._pendingDebouncedNotifications.clear(); for (const info of this._timeoutInfo.values()) clearTimeout(info.timeoutId); this._timeoutInfo.clear(); @@ -436,7 +439,7 @@ export async function attachChannelTransport( const driver = new StreamDriver(dispatcher, pipe, { supportedProtocolVersions: options?.supportedProtocolVersions, debouncedNotificationMethods: options?.debouncedNotificationMethods, - outboundMw: options?.outboundMw, + taskManager: options?.taskManager, buildEnv: options?.buildEnv }); if (options?.onclose || options?.onerror) { diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts index dac3309ee..4541a8763 100644 --- a/packages/core/src/shared/taskManager.ts +++ b/packages/core/src/shared/taskManager.ts @@ -32,7 +32,7 @@ import { TaskStatusNotificationSchema } from '../types/index.js'; import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; -import type { NotificationOptions, Outbound, OutboundMiddleware, RequestEnv, RequestOptions } from './context.js'; +import type { NotificationOptions, Outbound, RequestEnv, RequestOptions } from './context.js'; import type { Dispatcher, DispatchFn, DispatchMiddleware, DispatchOutput } from './dispatcher.js'; import type { ResponseMessage } from './responseMessage.js'; @@ -189,11 +189,12 @@ export class TaskManager { /** * Attaches this manager to a {@linkcode Dispatcher}: registers the dispatch middleware * via `d.use()`, installs `tasks/*` request handlers when a store is configured, and - * stores the {@linkcode TaskAttachHooks}. Returns the {@linkcode OutboundMiddleware} - * the caller registers via `useOutbound()` (kept separate so callers control ordering). + * stores the {@linkcode TaskAttachHooks}. Outbound-side hooks (request/notification + * augmentation, response correlation, close) are called directly by the channel adapter + * (see {@linkcode StreamDriver}), which receives this manager via {@linkcode AttachOptions}. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- attach is context-agnostic - attachTo(d: Dispatcher, hooks: TaskAttachHooks): OutboundMiddleware { + attachTo(d: Dispatcher, hooks: TaskAttachHooks): void { this._hooks = hooks; d.use(this.dispatchMiddleware); @@ -225,8 +226,6 @@ export class TaskManager { return this.handleCancelTask(params.taskId, ctx.sessionId); }); } - - return this.outboundMiddleware; } protected get _requireHooks(): TaskAttachHooks { @@ -367,20 +366,6 @@ export class TaskManager { } as DispatchFn; } - /** - * The {@linkcode OutboundMiddleware}: adds `task`/`relatedTask` to outbound params, - * queues to the task-message-queue when `relatedTask` is set, consumes/correlates - * responses for queued requests, tracks progress-token lifetime for `CreateTaskResult`s. - */ - get outboundMiddleware(): OutboundMiddleware { - return { - request: (jr, opts, id, settle, reject) => this.processOutboundRequest(jr, opts, id, settle, reject), - notification: (n, opts) => this.processOutboundNotification(n, opts), - response: (r, id) => this.processInboundResponse(r, id), - close: () => this.onClose() - }; - } - get taskStore(): TaskStore | undefined { return this._taskStore; } @@ -819,7 +804,7 @@ export class TaskManager { }; } - // -- OutboundMiddleware lifecycle methods -- + // -- Outbound-seam lifecycle methods (called directly by StreamDriver) -- processOutboundRequest( jsonrpcRequest: JSONRPCRequest, diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 33921a36b..e361725e0 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -7,7 +7,8 @@ import type { MessageExtraInfo, RequestId } from '../types/index.js'; -import type { OutboundMiddleware, RequestEnv } from './context.js'; +import type { RequestEnv } from './context.js'; +import type { TaskManager } from './taskManager.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -156,7 +157,7 @@ export type Transport = ChannelTransport; export type AttachOptions = { supportedProtocolVersions?: string[]; debouncedNotificationMethods?: string[]; - outboundMw?: OutboundMiddleware[]; + taskManager?: TaskManager; buildEnv?: (extra: MessageExtraInfo | undefined, base: RequestEnv) => RequestEnv; onclose?: () => void; onerror?: (error: Error) => void; diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index f95586f6c..b8600115d 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -32,7 +32,6 @@ import type { NotificationMethod, NotificationOptions, Outbound, - OutboundMiddleware, ProtocolOptions, Request, RequestEnv, @@ -62,7 +61,6 @@ import { attachChannelTransport, CallToolRequestSchema, CallToolResultSchema, - composeOutboundMiddleware, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, CreateTaskResultSchema, @@ -145,7 +143,6 @@ export type ServerOptions = Omit & { export class McpServer extends Dispatcher implements RegistriesHost { private _outbound?: Outbound; private readonly _registries = new ServerRegistries(this); - private readonly _outboundMw: OutboundMiddleware[] = []; private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; @@ -193,14 +190,13 @@ export class McpServer extends Dispatcher implements RegistriesHo const tasksOpts = extractTaskManagerOptions(_options?.capabilities?.tasks); this._taskManager = tasksOpts ? new TaskManager(tasksOpts) : new NullTaskManager(); - const tasksOutbound = this._taskManager.attachTo(this, { + this._taskManager.attachTo(this, { channel: () => this._outbound, reportError: e => (this.onerror ?? (() => {}))(e), enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, assertTaskCapability: m => assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, m, 'Client'), assertTaskHandlerCapability: m => assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, m, 'Server') }); - this.useOutbound(tasksOutbound); this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setRequestHandler('ping', () => ({})); @@ -212,29 +208,19 @@ export class McpServer extends Dispatcher implements RegistriesHo } // ─────────────────────────────────────────────────────────────────────── - // Middleware + direct dispatch + // Direct dispatch // ─────────────────────────────────────────────────────────────────────── - /** - * Register an {@linkcode OutboundMiddleware} applied at the request-correlation seam - * (before each outbound write, for each inbound response, on close). - */ - useOutbound(mw: OutboundMiddleware): this { - this._outboundMw.push(mw); - return this; - } - /** * Routes an incoming JSON-RPC response (e.g. a client's reply to an `elicitation/create` - * request the server issued) through the registered {@linkcode OutboundMiddleware} chain. - * Called by {@linkcode shttpHandler} for response-typed POST bodies. + * request the server issued) through the {@linkcode TaskManager}. Called by + * {@linkcode shttpHandler} for response-typed POST bodies. * - * @returns true if a middleware consumed it. + * @returns true if the task manager consumed it. */ dispatchInboundResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean { const id = typeof response.id === 'number' ? response.id : Number(response.id); - const mw = composeOutboundMiddleware(this._outboundMw); - return mw.response?.(response, id)?.consumed ?? false; + return this._taskManager.processInboundResponse(response, id).consumed; } /** @@ -316,12 +302,11 @@ export class McpServer extends Dispatcher implements RegistriesHo `Transport does not support out-of-band ${kind}; use ctx.mcpReq inside a handler.` ) ); - const mw = composeOutboundMiddleware(this._outboundMw); outbound = { close: () => transport.close(), notification: transport.notify ? async (n, opts) => { - const out = (await mw.notification?.(n, opts)) ?? { queued: false }; + const out = await this._taskManager.processOutboundNotification(n, opts); if (!out.queued) await transport.notify!(out.jsonrpcNotification ?? ({ jsonrpc: '2.0', ...n } as JSONRPCNotification)); } @@ -347,7 +332,7 @@ export class McpServer extends Dispatcher implements RegistriesHo supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), - outboundMw: this._outboundMw, + taskManager: this._taskManager, onclose: () => { if (this._outbound === outbound) this._outbound = undefined; this.onclose?.(); From 20fbba0c870ef045b21d198e92e49d95d131bc39 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 19:58:11 +0000 Subject: [PATCH 46/55] simplify: move RequestTransport/Backchannel2511 to internal-only; drop DispatchEnv alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/public no longer exports RequestTransport or isRequestTransport (kept in the internal barrel; shttpHandler can be public without exposing the second transport interface before SEP-2598 settles). - server no longer exports Backchannel2511 (constructed internally by shttpHandler/streamableHttp; it exists to be deleted when 2025-11 sunsets, so making it API surface defeats that). - DispatchEnv deprecated alias deleted (RequestEnv is the name). - Outbound was already not in public exports — confirmed unchanged. --- packages/core/src/exports/public/index.ts | 7 ++++--- packages/core/src/shared/dispatcher.ts | 3 --- packages/server/src/index.ts | 1 - 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index d3438cc74..4bc735ac8 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -71,9 +71,10 @@ export { takeResult, toArrayAsync } from '../../shared/responseMessage.js'; // stdio message framing utilities (for custom transport authors) export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; -// Transport types (NOT normalizeHeaders) -export type { ChannelTransport, FetchLike, RequestTransport, Transport, TransportSendOptions } from '../../shared/transport.js'; -export { createFetchWithInit, isRequestTransport } from '../../shared/transport.js'; +// Transport types (NOT normalizeHeaders). RequestTransport stays internal until +// SEP-2598 (pluggable transports) finalizes. +export type { ChannelTransport, FetchLike, Transport, TransportSendOptions } from '../../shared/transport.js'; +export { createFetchWithInit } from '../../shared/transport.js'; // URI Template export type { Variables } from '../../shared/uriTemplate.js'; diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 817fca86a..65bb781d4 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -18,9 +18,6 @@ import { getNotificationSchema, getRequestSchema, ProtocolError, ProtocolErrorCo import type { StandardSchemaV1 } from '../util/standardSchema.js'; import type { BaseContext, RequestEnv, RequestOptions } from './context.js'; -/** @deprecated Renamed to {@linkcode RequestEnv} (now in `context.ts`). */ -export type DispatchEnv = RequestEnv; - /** * One yielded item from {@linkcode Dispatcher.dispatch}. A dispatch yields zero or more * notifications followed by exactly one terminal response. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ef64e3ac6..f4c811c76 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,7 +6,6 @@ // // Any new export added here becomes public API. Use named exports, not wildcards. -export { Backchannel2511 } from './server/backchannel2511.js'; export { Server } from './server/compat.js'; export type { CompletableSchema, CompleteCallback } from './server/completable.js'; export { completable, isCompletable } from './server/completable.js'; From 7068291997beb005ffb24d24eba352c62163357c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 20:04:11 +0000 Subject: [PATCH 47/55] simplify: explicit `kind` brand on transports replaces duck-typing discriminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChannelTransport gets `readonly kind?: 'channel'` (optional, back-compat). RequestTransport / ClientTransport get `readonly kind: 'request'` (required). isRequestTransport / isChannelTransport check the brand instead of probing for `onrequest` / `fetch` / `start` / `send`. The SHTTP transport classes now `implements RequestTransport` only (not also ChannelTransport — the brands are mutually exclusive). They keep the start/send/onmessage methods for back-compat with v1 callers, but McpServer/Client.connect() routes them via the request-shaped path. --- packages/client/src/client/clientTransport.ts | 7 +++++-- packages/client/src/client/streamableHttp.ts | 7 ++++--- packages/client/test/client/client.test.ts | 1 + packages/core/src/shared/transport.ts | 14 ++++++++++---- packages/middleware/node/src/streamableHttp.ts | 5 +++-- packages/server/src/server/streamableHttp.ts | 7 ++++--- 6 files changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index 74ea1fb09..bf6002717 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -65,6 +65,9 @@ export type ClientFetchOptions = { * interface is adapted via {@linkcode channelAsClientTransport}. */ export interface ClientTransport { + /** Explicit shape brand. Required so {@linkcode isChannelTransport} can discriminate without duck-typing. */ + readonly kind: 'request'; + /** * Send one JSON-RPC request and resolve with the terminal response. * Any progress/notifications received before the response are surfaced @@ -104,8 +107,7 @@ export interface ClientTransport { * request-shaped path. */ export function isChannelTransport(t: Transport | ClientTransport): t is Transport { - if (typeof (t as ClientTransport).fetch === 'function') return false; - return typeof (t as Transport).start === 'function' && typeof (t as Transport).send === 'function'; + return (t as ClientTransport).kind !== 'request'; } /** @@ -134,6 +136,7 @@ export function channelAsClientTransport(pipe: Transport, dispatcher: Dispatcher } }; return { + kind: 'request', driver, async fetch(request, opts) { await ensureStarted(); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 69637b4ab..681689271 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -7,8 +7,7 @@ import type { JSONRPCNotification, JSONRPCRequest, JSONRPCResultResponse, - Notification, - Transport + Notification } from '@modelcontextprotocol/core'; import { createFetchWithInit, @@ -194,7 +193,9 @@ export type StreamableHTTPClientTransportOptions = { * {@linkcode Client.connect}) and the legacy pipe-shaped {@linkcode Transport} (deprecated; kept for * direct callers and v1 compat). */ -export class StreamableHTTPClientTransport implements ClientTransport, Transport { +export class StreamableHTTPClientTransport implements ClientTransport { + readonly kind = 'request' as const; + private _abortController?: AbortController; private _url: URL; private _resourceMetadataUrl?: URL; diff --git a/packages/client/test/client/client.test.ts b/packages/client/test/client/client.test.ts index eab4ee3b6..bbec860b6 100644 --- a/packages/client/test/client/client.test.ts +++ b/packages/client/test/client/client.test.ts @@ -24,6 +24,7 @@ function mockTransport(handler: (req: JSONRPCRequest, opts?: ClientFetchOptions) const sent: JSONRPCRequest[] = []; const notified: Notification[] = []; const ct: ClientTransport = { + kind: 'request', async fetch(req, opts) { sent.push(req); return handler(req, opts); diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index e361725e0..942944100 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -86,6 +86,12 @@ export type TransportSendOptions = { * For request/response-shaped transports (Streamable HTTP), see {@linkcode RequestTransport}. */ export interface ChannelTransport { + /** + * Explicit shape brand. Optional (defaults to `'channel'`) so existing + * `Transport` implementations don't need to declare it. + */ + readonly kind?: 'channel'; + /** * Starts processing messages on the transport, including any connection steps that might need to be taken. * @@ -173,13 +179,13 @@ export type AttachOptions = { * per inbound message. The transport itself never imports or references a `Dispatcher`. */ export interface RequestTransport { + /** Explicit shape brand. Required so {@linkcode isRequestTransport} can discriminate without duck-typing. */ + readonly kind: 'request'; + /** * Callback slot for inbound JSON-RPC requests. Set by `McpServer.connect()`. * The transport calls this per request and writes the yielded messages * (notifications + one terminal response) to the HTTP response stream. - * - * Transports MUST declare this property (initialised to `undefined`) so - * {@linkcode isRequestTransport} can discriminate before `connect()` runs. */ onrequest?: ((req: JSONRPCRequest, env?: RequestEnv) => AsyncIterable) | undefined; @@ -218,5 +224,5 @@ export interface RequestTransport { /** Type guard distinguishing {@linkcode RequestTransport} from {@linkcode ChannelTransport}. */ export function isRequestTransport(t: ChannelTransport | RequestTransport): t is RequestTransport { - return 'onrequest' in t; + return (t as RequestTransport).kind === 'request'; } diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index d0c4f1139..591842785 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -12,7 +12,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; import type { AuthInfo, - ChannelTransport, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, @@ -96,7 +95,9 @@ export function toNodeHttpHandler( * }); * ``` */ -export class NodeStreamableHTTPServerTransport implements ChannelTransport, RequestTransport { +export class NodeStreamableHTTPServerTransport implements RequestTransport { + readonly kind = 'request' as const; + private _webStandardTransport: WebStandardStreamableHTTPServerTransport; private _requestListener: ReturnType; // Store auth and parsedBody per request for passing through to handleRequest diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 201e5e2c3..93a708c4c 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -14,7 +14,6 @@ import type { AuthInfo, - ChannelTransport, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, @@ -157,7 +156,9 @@ export interface HandleRequestOptions { * {@linkcode Transport} interface methods route outbound messages through the * per-session {@linkcode Backchannel2511}. */ -export class WebStandardStreamableHTTPServerTransport implements ChannelTransport, RequestTransport { +export class WebStandardStreamableHTTPServerTransport implements RequestTransport { + readonly kind = 'request' as const; + private _options: WebStandardStreamableHTTPServerTransportOptions; private _session?: SessionCompat; private _backchannel = new Backchannel2511(); @@ -171,7 +172,7 @@ export class WebStandardStreamableHTTPServerTransport implements ChannelTranspor onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - /** {@linkcode RequestTransport.onrequest} — set by `McpServer.connect()`. Declared so {@linkcode isRequestTransport} matches. */ + /** {@linkcode RequestTransport.onrequest} — set by `McpServer.connect()`. */ onrequest: ((req: JSONRPCRequest, env?: RequestEnv) => AsyncIterable) | undefined = undefined; /** {@linkcode RequestTransport.onnotification} — set by `McpServer.connect()`. */ onnotification?: (n: JSONRPCNotification) => void | Promise; From 6c439dac36ca8c1f136231383907cab2d7420edd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 20:06:30 +0000 Subject: [PATCH 48/55] simplify: strip forward-looking spec references from comments Removed speculative '2026-06+', 'subscriptions/listen', and version-labeled 'native' references from JSDoc. Reworded to describe current behavior or cite the SEP. Kept the '2026-06-30' version literal in shttpHandler's negotiated-version gate (functional, not commentary) and 'pre-2026-06' phrasing in sessionCompat/ shttpHandler option docs (describes the back-compat path, not future behavior). --- packages/client/src/client/client.ts | 26 +++++++++---------- packages/client/src/client/clientTransport.ts | 4 +-- packages/core/src/shared/transport.ts | 5 ++-- packages/server/src/server/backchannel2511.ts | 7 ++--- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 844db606e..e9cf4ec21 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -159,9 +159,8 @@ function isCreateTaskResult(r: unknown): r is CreateTaskResult { } /** - * Loose envelope for the (draft) 2026-06 MRTR `input_required` result. Typed - * minimally so this compiles before the spec types land; runtime detection is - * by shape. + * Loose envelope for the SEP-2322 MRTR `input_required` result. Typed minimally + * (field names not yet finalized in the spec); runtime detection is by shape. */ type InputRequiredEnvelope = { ResultType: 'input_required'; @@ -204,10 +203,10 @@ export type ClientOptions = ProtocolOptions & { /** * MCP client built on a request-shaped {@linkcode ClientTransport}. * - * - 2026-06-native: every request is independent; `request()` runs the MRTR - * loop, servicing `input_required` rounds via locally registered handlers. - * - 2025-11-compat: {@linkcode connect} accepts the legacy pipe-shaped - * {@linkcode Transport} and runs the initialize handshake. + * Every request is independent; `request()` runs the SEP-2322 MRTR loop, + * servicing `input_required` rounds via locally registered handlers. + * {@linkcode connect} also accepts a legacy pipe-shaped {@linkcode Transport} + * and runs the 2025-11 initialize handshake for back-compat. */ export class Client extends Dispatcher { private _ct?: ClientTransport; @@ -274,11 +273,10 @@ export class Client extends Dispatcher { } /** - * Connects to a server. Accepts either a {@linkcode ClientTransport} - * (2026-06-native, request-shaped) or a legacy pipe {@linkcode Transport} - * (stdio, SSE, the v1 SHTTP class). Pipe transports are adapted via - * {@linkcode channelAsClientTransport} and the 2025-11 initialize handshake - * is performed. + * Connects to a server. Accepts either a request-shaped {@linkcode ClientTransport} + * or a legacy pipe {@linkcode Transport} (stdio, SSE, the v1 SHTTP class). + * Pipe transports are adapted via {@linkcode channelAsClientTransport} and + * the 2025-11 initialize handshake is performed. */ async connect(transport: Transport | ClientTransport, options?: RequestOptions): Promise { if (isChannelTransport(transport)) { @@ -755,7 +753,7 @@ export class Client extends Dispatcher { } private async _discoverOrInitialize(options: RequestOptions | undefined, setProtocolVersion: (v: string) => void): Promise { - // 2026-06: try server/discover, fall back to initialize. Discover schema + // Try server/discover (SEP-2575 stateless), fall back to initialize. Discover schema // is not yet in spec types, so probe and accept the result loosely. try { const resp = await this._ct!.fetch( @@ -770,7 +768,7 @@ export class Client extends Dispatcher { protocolVersion?: string; }; // Only accept discover if the result is shaped like a real discover response; - // pre-2026-06 servers may return an empty/echo result for unknown methods. + // 2025-11 servers may return an empty/echo result for unknown methods. if (r?.serverInfo) { this._serverCapabilities = r.capabilities; this._serverVersion = r.serverInfo; diff --git a/packages/client/src/client/clientTransport.ts b/packages/client/src/client/clientTransport.ts index bf6002717..efdb0de35 100644 --- a/packages/client/src/client/clientTransport.ts +++ b/packages/client/src/client/clientTransport.ts @@ -61,8 +61,8 @@ export type ClientFetchOptions = { * response out. The transport may be stateful internally (session id, protocol * version) but the contract is per-call. * - * This is the 2026-06-native shape. The legacy pipe {@linkcode Transport} - * interface is adapted via {@linkcode channelAsClientTransport}. + * The legacy pipe {@linkcode Transport} interface is adapted via + * {@linkcode channelAsClientTransport}. */ export interface ClientTransport { /** Explicit shape brand. Required so {@linkcode isChannelTransport} can discriminate without duck-typing. */ diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 942944100..29b8b0f10 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -203,14 +203,13 @@ export interface RequestTransport { /** * 2025-11 back-compat: write an unsolicited notification to the session's standalone - * GET subscription stream. In 2026-06+ clients open `subscriptions/listen` instead. + * GET subscription stream. */ notify?(n: JSONRPCNotification): Promise; /** * 2025-11 back-compat: send an unsolicited server→client request via the standalone - * GET stream and await the client's POSTed-back response. In 2026-06+ server→client - * requests are per-inbound-request via `env.send` (MRTR). + * GET stream and await the client's POSTed-back response. */ request?(r: JSONRPCRequest): Promise; diff --git a/packages/server/src/server/backchannel2511.ts b/packages/server/src/server/backchannel2511.ts index 946f0ec7c..24e3a2b07 100644 --- a/packages/server/src/server/backchannel2511.ts +++ b/packages/server/src/server/backchannel2511.ts @@ -12,15 +12,16 @@ import { DEFAULT_REQUEST_TIMEOUT_MSEC, isJSONRPCErrorResponse, ProtocolError, Sd /** * Isolated 2025-11 server-to-client request backchannel for {@linkcode shttpHandler}. * - * The pre-2026-06 protocol allows a server to send `elicitation/create` and + * The 2025-11 protocol allows a server to send `elicitation/create` and * `sampling/createMessage` requests to the client mid-tool-call by writing them as * SSE events on the open POST response stream and waiting for the client to POST * the response back. This class owns the per-session `{requestId -> resolver}` * map that correlation requires, plus the standalone-GET writer registry used for * unsolicited server notifications. * - * It exists so this stateful behaviour is in one removable file once 2026-06 (MRTR) - * is the floor and `env.send` becomes a hard error in stateless paths. + * It exists so this stateful behaviour is in one removable file once MRTR + * (SEP-2322) is the protocol floor and `env.send` becomes a hard error in + * stateless paths. */ export class Backchannel2511 { private _pending = new Map void; reject: (e: Error) => void }>>(); From 934f6e56c2eea4b31d40cb18a6da6e7d9ee4b981 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 20:07:08 +0000 Subject: [PATCH 49/55] docs(mcpServer): document _outbound singleton multi-connect limitation connect() overwrites _outbound on each call. Inbound dispatch works for all concurrent connections (each has its own onrequest/StreamDriver), but instance-level outbound (createMessage, send*ListChanged) reaches only the most-recently-connected transport. Matches v1 Protocol.connect semantics. Per the simplification review, documenting rather than redesigning. --- packages/server/src/server/mcpServer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index b8600115d..0d1bcb8ed 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -275,6 +275,12 @@ export class McpServer extends Dispatcher implements RegistriesHo * optional `notify`/`request` methods. * - For {@linkcode ChannelTransport} (stdio/WebSocket/InMemory): wraps it in a * {@linkcode StreamDriver} via {@linkcode attachChannelTransport}. + * + * **Known limitation:** `_outbound` is a singleton — each `connect()` call overwrites + * it. Multiple concurrent connections (the v1 stateful-SHTTP `Map` + * pattern) work for *inbound* dispatch, but instance-level *outbound* methods + * ({@linkcode createMessage}, {@linkcode sendToolListChanged}, etc.) reach only the + * most-recently-connected transport. This matches v1 `Protocol.connect()` semantics. */ async connect(transport: ChannelTransport | RequestTransport): Promise { let outbound: Outbound | undefined; From 32669c3916c44456c476ac08a8bfb3a53eddeaa3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 20:31:00 +0000 Subject: [PATCH 50/55] fixup! simplify: remove OutboundMiddleware/useOutbound; StreamDriver calls TaskManager directly Invert the dependency per review: StreamDriver is now fully task-agnostic. - StreamDriver exposes generic per-request `intercept` hook (RequestOptions) and per-connection `onresponse` tap returning {consumed, preserveProgress}. - TaskManager.sendRequest/sendNotification helpers wrap Outbound.request/notification, threading processOutboundRequest through `intercept`. - Protocol/McpServer/Client wire TaskManager via these helpers + the onresponse tap. - McpServer request-shaped Outbound also honours `intercept`. --- packages/client/src/client/client.ts | 9 ++- packages/core/src/shared/context.ts | 16 ++++++ packages/core/src/shared/protocol.ts | 9 +-- packages/core/src/shared/streamDriver.ts | 70 +++++++++--------------- packages/core/src/shared/taskManager.ts | 41 +++++++++++++- packages/core/src/shared/transport.ts | 7 ++- packages/server/src/server/mcpServer.ts | 47 +++++++++------- 7 files changed, 123 insertions(+), 76 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index e9cf4ec21..ebcdaf08f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -282,11 +282,14 @@ export class Client extends Dispatcher { if (isChannelTransport(transport)) { const driverOpts: StreamDriverOptions = { supportedProtocolVersions: this._supportedProtocolVersions, - debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - taskManager: this._taskManager + debouncedNotificationMethods: this._options?.debouncedNotificationMethods }; this._ct = channelAsClientTransport(transport, this, driverOpts); - this._ct.driver!.onclose = () => this.onclose?.(); + this._ct.driver!.onresponse = (r, id) => this._taskManager.processInboundResponse(r, id); + this._ct.driver!.onclose = () => { + this._taskManager.onClose(); + this.onclose?.(); + }; this._ct.driver!.onerror = e => this.onerror?.(e); const skipInit = transport.sessionId !== undefined; if (skipInit) { diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 2aec48d0c..8338c5e75 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -8,6 +8,8 @@ import type { ElicitRequestURLParams, ElicitResult, JSONRPCMessage, + JSONRPCRequest, + JSONRPCResultResponse, LoggingLevel, Notification, Progress, @@ -148,6 +150,20 @@ export type RequestOptions = { * If provided, associates this request with a related task. */ relatedTask?: RelatedTaskMetadata; + + /** + * @internal Called by the channel adapter after the wire request is built + * (id assigned, `_meta.progressToken` set, response/progress handlers registered) + * but before send. Return `true` to skip the send; the caller takes ownership of + * delivering `wire` later. The registered handlers stay live. `settle` injects + * a result/error into the registered response handler out-of-band. + */ + intercept?: ( + wire: JSONRPCRequest, + messageId: number, + settle: (response: JSONRPCResultResponse | Error) => void, + onError: (error: unknown) => void + ) => boolean; } & TransportSendOptions; /** diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 4b1792922..5d5b38be9 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -162,12 +162,13 @@ export abstract class Protocol { const driver = new StreamDriver(this._dispatcher, transport, { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, - buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), - taskManager: this._ownTaskManager + buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }) }); this._outbound = driver; + driver.onresponse = (r, id) => this._ownTaskManager.processInboundResponse(r, id); driver.onclose = () => { if (this._outbound === driver) this._outbound = undefined; + this._ownTaskManager.onClose(); this.onclose?.(); }; driver.onerror = error => this.onerror?.(error); @@ -215,7 +216,7 @@ export abstract class Protocol { if (this._options?.enforceStrictCapabilities === true) { this.assertCapabilityForMethod(request.method as RequestMethod); } - return this._outbound.request(request, resultSchema, options); + return this._ownTaskManager.sendRequest(request, resultSchema, options, this._outbound); } /** @@ -226,7 +227,7 @@ export abstract class Protocol { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } this.assertNotificationCapability(notification.method as NotificationMethod); - return this._outbound.notification(notification, options); + return this._ownTaskManager.sendNotification(notification, options, this._outbound); } // ─────────────────────────────────────────────────────────────────────── diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 1151aba2f..907a466aa 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -28,7 +28,6 @@ import { parseSchema } from '../util/schema.js'; import type { NotificationOptions, Outbound, ProgressCallback, RequestEnv, RequestOptions } from './context.js'; import { DEFAULT_REQUEST_TIMEOUT_MSEC } from './context.js'; import type { Dispatcher } from './dispatcher.js'; -import type { TaskManager } from './taskManager.js'; import type { AttachOptions, Transport } from './transport.js'; type TimeoutInfo = { @@ -48,13 +47,6 @@ export type StreamDriverOptions = { * {@linkcode MessageExtraInfo} (e.g. auth, http req). */ buildEnv?: (extra: MessageExtraInfo | undefined, base: RequestEnv) => RequestEnv; - /** - * Optional {@linkcode TaskManager}. When provided, the driver calls its - * `processOutbound*` / `processInboundResponse` / `onClose` hooks at the - * request-correlation seam. Inbound task processing happens via the - * TaskManager's dispatch middleware (registered by the caller), not here. - */ - taskManager?: TaskManager; }; /** @@ -73,10 +65,18 @@ export class StreamDriver implements Outbound { private _pendingDebouncedNotifications = new Set(); private _closed = false; private _supportedProtocolVersions: string[]; - private _taskManager?: TaskManager; onclose?: () => void; onerror?: (error: Error) => void; + /** + * Tap for every inbound response. Return `consumed: true` to claim it (suppresses the + * matched-handler dispatch / unknown-id error). Return `preserveProgress: true` to keep + * the progress handler registered after the matched handler runs. Set by the owner. + */ + onresponse?: ( + response: JSONRPCResponse | JSONRPCErrorResponse, + messageId: number + ) => { consumed: boolean; preserveProgress?: boolean }; constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- driver is context-agnostic; subclass owns ContextT @@ -85,7 +85,6 @@ export class StreamDriver implements Outbound { private _options: StreamDriverOptions = {} ) { this._supportedProtocolVersions = _options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - this._taskManager = _options.taskManager; } /** {@linkcode Outbound.removeProgressHandler}. */ @@ -208,31 +207,19 @@ export class StreamDriver implements Outbound { options?.resetTimeoutOnProgress ?? false ); - let queued = false; - if (this._taskManager) { - const sideChannelResponse = (resp: JSONRPCResultResponse | Error) => { - const h = this._responseHandlers.get(messageId); - if (h) h(resp); - else this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); - }; - try { - queued = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, sideChannelResponse, error => { - this._progressHandlers.delete(messageId); - reject(error); - }).queued; - } catch (error) { + if (options?.intercept) { + const settle = (r: JSONRPCResultResponse | Error) => this._responseHandlers.get(messageId)?.(r); + const onError = (e: unknown) => { this._progressHandlers.delete(messageId); - reject(error); - return; - } + reject(e); + }; + if (options.intercept(jsonrpcRequest, messageId, settle, onError)) return; } - if (!queued) { - this.pipe.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { - this._progressHandlers.delete(messageId); - reject(error); - }); - } + this.pipe.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._progressHandlers.delete(messageId); + reject(error); + }); }).finally(() => { if (onAbort) options?.signal?.removeEventListener('abort', onAbort); if (cleanupId !== undefined) { @@ -246,9 +233,8 @@ export class StreamDriver implements Outbound { * Sends a notification over the pipe. Supports debouncing per the constructor option. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - const taskResult = await this._taskManager?.processOutboundNotification(notification, options); - if (taskResult?.queued || this._closed) return; - const jsonrpc: JSONRPCNotification = taskResult?.jsonrpcNotification ?? { + if (this._closed) return; + const jsonrpc: JSONRPCNotification = { jsonrpc: '2.0', method: notification.method, params: notification.params @@ -345,9 +331,8 @@ export class StreamDriver implements Outbound { private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); - const taskResult = this._taskManager?.processInboundResponse(response, messageId); - if (taskResult?.consumed) return; - + const tap = this.onresponse?.(response, messageId); + if (tap?.consumed) return; const handler = this._responseHandlers.get(messageId); if (handler === undefined) { this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); @@ -355,9 +340,7 @@ export class StreamDriver implements Outbound { } this._responseHandlers.delete(messageId); this._cleanupTimeout(messageId); - if (!taskResult?.preserveProgress) { - this._progressHandlers.delete(messageId); - } + if (!tap?.preserveProgress) this._progressHandlers.delete(messageId); if (isJSONRPCResultResponse(response)) { handler(response); } else { @@ -370,7 +353,6 @@ export class StreamDriver implements Outbound { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); - this._taskManager?.onClose(); this._pendingDebouncedNotifications.clear(); for (const info of this._timeoutInfo.values()) clearTimeout(info.timeoutId); this._timeoutInfo.clear(); @@ -439,12 +421,12 @@ export async function attachChannelTransport( const driver = new StreamDriver(dispatcher, pipe, { supportedProtocolVersions: options?.supportedProtocolVersions, debouncedNotificationMethods: options?.debouncedNotificationMethods, - taskManager: options?.taskManager, buildEnv: options?.buildEnv }); - if (options?.onclose || options?.onerror) { + if (options?.onclose || options?.onerror || options?.onresponse) { driver.onclose = options.onclose; driver.onerror = options.onerror; + driver.onresponse = options.onresponse; } await driver.start(); return driver; diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts index 4541a8763..85e34e9ed 100644 --- a/packages/core/src/shared/taskManager.ts +++ b/packages/core/src/shared/taskManager.ts @@ -384,7 +384,7 @@ export class TaskManager { private _outboundRequest(req: Request, schema: T, opts?: RequestOptions): Promise> { const ch = this._requireHooks.channel(); if (!ch) throw new ProtocolError(ProtocolErrorCode.InternalError, 'Not connected'); - return ch.request(req, schema, opts); + return this.sendRequest(req, schema, opts, ch); } // -- Public API (client-facing) -- @@ -804,7 +804,44 @@ export class TaskManager { }; } - // -- Outbound-seam lifecycle methods (called directly by StreamDriver) -- + // -- Outbound helpers (called by McpServer/Client/Protocol before delegating to Outbound) -- + + /** + * Task-aware request send: routes through {@linkcode RequestOptions.intercept} so the + * channel adapter builds the wire (id/progressToken/handlers) and TaskManager decides + * whether to queue it. Use this where instance-level outbound requests are made + * (Protocol/McpServer/Client), so the channel adapter stays task-agnostic. + */ + sendRequest( + request: Request, + resultSchema: T, + options: RequestOptions | undefined, + outbound: Outbound + ): Promise> { + if (!options?.relatedTask && !options?.task) { + return outbound.request(request, resultSchema, options); + } + return outbound.request(request, resultSchema, { + ...options, + intercept: (wire, messageId, settle, onError) => + this.processOutboundRequest(wire, options, messageId, settle, onError).queued + }); + } + + /** + * Task-aware notification send: queues when `options.relatedTask` is set, otherwise + * delegates to `outbound.notification()` with related-task metadata attached. + */ + async sendNotification(notification: Notification, options: NotificationOptions | undefined, outbound: Outbound): Promise { + const result = await this.processOutboundNotification(notification, options); + if (result.queued) return; + await outbound.notification( + result.jsonrpcNotification + ? { method: result.jsonrpcNotification.method, params: result.jsonrpcNotification.params } + : notification, + options + ); + } processOutboundRequest( jsonrpcRequest: JSONRPCRequest, diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 29b8b0f10..4cca92e49 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -8,7 +8,6 @@ import type { RequestId } from '../types/index.js'; import type { RequestEnv } from './context.js'; -import type { TaskManager } from './taskManager.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -163,10 +162,14 @@ export type Transport = ChannelTransport; export type AttachOptions = { supportedProtocolVersions?: string[]; debouncedNotificationMethods?: string[]; - taskManager?: TaskManager; buildEnv?: (extra: MessageExtraInfo | undefined, base: RequestEnv) => RequestEnv; onclose?: () => void; onerror?: (error: Error) => void; + /** Tap for every inbound response. See {@linkcode StreamDriver.onresponse}. */ + onresponse?: ( + response: JSONRPCResultResponse | JSONRPCErrorResponse, + messageId: number + ) => { consumed: boolean; preserveProgress?: boolean }; }; /** diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 0d1bcb8ed..0e3464e2d 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -20,6 +20,7 @@ import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, + JSONRPCResultResponse, JSONRPCRequest, JSONRPCResponse, JsonSchemaType, @@ -311,26 +312,29 @@ export class McpServer extends Dispatcher implements RegistriesHo outbound = { close: () => transport.close(), notification: transport.notify - ? async (n, opts) => { - const out = await this._taskManager.processOutboundNotification(n, opts); - if (!out.queued) - await transport.notify!(out.jsonrpcNotification ?? ({ jsonrpc: '2.0', ...n } as JSONRPCNotification)); - } + ? async (n, _opts) => transport.notify!({ jsonrpc: '2.0', ...n } as JSONRPCNotification) : noOutbound('notifications'), request: transport.request - ? async (r, schema, _opts) => { - const id = this._nextOutboundId++; - const resp = await transport.request!({ - jsonrpc: '2.0', - id, - method: r.method, - params: r.params - } as JSONRPCRequest); - if ('error' in resp) throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); - const parsed = parseSchema(schema, resp.result); - if (!parsed.success) throw parsed.error; - return parsed.data as SchemaOutput; - } + ? (r, schema, opts) => + new Promise((resolve, reject) => { + const id = this._nextOutboundId++; + const wire = { jsonrpc: '2.0', id, method: r.method, params: r.params } as JSONRPCRequest; + const finish = (resp: JSONRPCResultResponse | Error) => { + if (resp instanceof Error) return reject(resp); + const parsed = parseSchema(schema, resp.result); + if (!parsed.success) return reject(parsed.error); + resolve(parsed.data as SchemaOutput); + }; + if (opts?.intercept?.(wire, id, finish, reject)) return; + transport + .request!(wire) + .then(resp => + 'error' in resp + ? reject(ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data)) + : finish(resp) + ) + .catch(reject); + }) : noOutbound('requests') }; } else { @@ -338,9 +342,10 @@ export class McpServer extends Dispatcher implements RegistriesHo supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods, buildEnv: (extra, base) => ({ ...base, _transportExtra: extra }), - taskManager: this._taskManager, + onresponse: (r, id) => this._taskManager.processInboundResponse(r, id), onclose: () => { if (this._outbound === outbound) this._outbound = undefined; + this._taskManager.onClose(); this.onclose?.(); }, onerror: e => this.onerror?.(e) @@ -594,7 +599,7 @@ export class McpServer extends Dispatcher implements RegistriesHo if (this._options?.enforceStrictCapabilities === true) { assertCapabilityForMethod(req.method as RequestMethod, this._clientCapabilities); } - return this._requireOutbound().request(req, schema as never, options) as Promise; + return this._taskManager.sendRequest(req, schema as never, options, this._requireOutbound()) as Promise; } /** @@ -748,7 +753,7 @@ export class McpServer extends Dispatcher implements RegistriesHo */ async notification(notification: Notification, options?: NotificationOptions): Promise { assertNotificationCapability(notification.method as NotificationMethod, this._capabilities, this._clientCapabilities); - await this._outbound?.notification(notification, options); + if (this._outbound) await this._taskManager.sendNotification(notification, options, this._outbound); } async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string): Promise { From 8fb9b8e02cbfa089ddc5fb772dd15fa566ee67bc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 20:44:44 +0000 Subject: [PATCH 51/55] rename: Backchannel2511 -> BackchannelCompat; add RFC + WALKTHROUGH docs --- docs/WALKTHROUGH.md | 219 ++++++++++++++++++ docs/rfc-stateless-architecture.md | 121 ++++++++++ ...ackchannel2511.ts => backchannelCompat.ts} | 2 +- packages/server/src/server/shttpHandler.ts | 4 +- packages/server/src/server/streamableHttp.ts | 10 +- 5 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 docs/WALKTHROUGH.md create mode 100644 docs/rfc-stateless-architecture.md rename packages/server/src/server/{backchannel2511.ts => backchannelCompat.ts} (99%) diff --git a/docs/WALKTHROUGH.md b/docs/WALKTHROUGH.md new file mode 100644 index 000000000..5085cfadf --- /dev/null +++ b/docs/WALKTHROUGH.md @@ -0,0 +1,219 @@ +# Walkthrough: why the SDK fights stateless, and how to fix it + +This is a code walk, not a spec. I'm going to start in the current SDK, show where it hurts, and then show what the same thing looks like after the proposed split. The RFC has the formal proposal; this is the "let me show you" version. + +--- + +## Part 1: The current code + +### Start at the only entrance + +There is exactly one way to make an MCP server handle requests: + +```ts +// packages/core/src/shared/protocol.ts:437 +async connect(transport: Transport): Promise { + this._transport = transport; + transport.onmessage = (message, extra) => { + // route to _onrequest / _onresponse / _onnotification + }; + await transport.start(); +} +``` + +You hand it a long-lived `Transport`, it takes over the `onmessage` callback, and from then on requests arrive asynchronously. There is no `handle(request) → response`. If you want to call a handler, you go through a transport. + +`Transport` is shaped like a pipe: + +```ts +// packages/core/src/shared/transport.ts:8 +interface Transport { + start(): Promise; + send(message: JSONRPCMessage): Promise; + onmessage?: (message, extra) => void; + close(): Promise; + sessionId?: string; + setProtocolVersion?(v: string): void; +} +``` + +`start`/`close` for lifecycle, fire-and-forget `send`, async `onmessage` callback. That's stdio's shape. It's also the shape every transport must implement, including HTTP. + +### Follow an HTTP request through + +The Streamable HTTP server transport is `packages/server/src/server/streamableHttp.ts` — 1038 lines. Let's follow a `tools/list` POST: + +1. User's Express handler calls `transport.handleRequest(req, res, body)` (line 176) +2. `handlePostRequest` validates headers (217-268), parses body (282) +3. Now it has a JSON-RPC request and needs to get it to the dispatcher. But the only path is `onmessage`. So it... calls `this.onmessage?.(msg, extra)` (370). Fire and forget. +4. `Protocol._onrequest` runs the handler, gets a result, builds a response, calls `this._transport.send(response)` (634) +5. Back in the transport, `send(response)` needs to find *which* HTTP response stream to write to. It looks up `_streamMapping[streamId]` (756) using a `relatedRequestId` that was threaded through. + +So the transport keeps a table mapping in-flight request IDs to open `Response` writers (`_streamMapping`, `_requestToStreamMapping`, ~80 LOC of bookkeeping), because `send()` is fire-and-forget and the response has to find its way back to the right HTTP response somehow. + +This is the core impedance mismatch: **HTTP is request→response, but the only interface is pipe-shaped, so the transport reconstructs request→response correlation on top of a pipe abstraction that sits on top of HTTP's native request→response.** + +### The session sniffing + +The transport also has to know about `initialize`: + +```ts +// streamableHttp.ts:323 +if (isInitializeRequest(body)) { + if (this._sessionIdGenerator) { + this.sessionId = this._sessionIdGenerator(); + // ... onsessioninitialized callback + } + this._initialized = true; +} +``` + +A transport — whose job should be "bytes in, bytes out" — is parsing message bodies to detect a specific MCP method so it knows when to mint a session ID. There are 18 references to `initialize` in this file. The transport knows about the protocol's handshake. + +### What "stateless" looks like today + +The protocol direction (SEP-2575/2567) is: no `initialize`, no sessions, each request is independent. The SDK has a stateless mode. Here's the example: + +```ts +// examples/server/src/simpleStatelessStreamableHttp.ts (paraphrased) +app.post('/mcp', async (req, res) => { + const server = new McpServer(...); // build a fresh server + server.registerTool('greet', ..., ...); // register everything + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined // <-- magic flag + }); + await server.connect(transport); // connect to it + await transport.handleRequest(req, res, req.body); + // server and transport are GC'd after the request +}); +``` + +Stateless = **build and tear down a stateful server per request**, signaled by `sessionIdGenerator: undefined` taking a different code path through the same 1038-line transport. This works, but it's stateless-by-accident, not stateless-by-design. + +### Why is Protocol 1100 lines? + +`protocol.ts` is the abstract base for both `Server` and `Client`. It does: + +- handler registry (`_requestHandlers`, `setRequestHandler`) +- outbound request/response correlation (`_responseHandlers`, `_requestMessageId`) +- timeouts (`_timeoutInfo`, `_setupTimeout`, `_resetTimeout`) +- progress callbacks (`_progressHandlers`) +- debounced notifications (`_pendingDebouncedNotifications`) +- cancellation (`_requestHandlerAbortControllers`) +- TaskManager binding (`_bindTaskManager`) +- 4 abstract `assert*Capability` methods subclasses must implement +- `connect()` — wiring all of the above to a transport + +Some of those are per-connection state (correlation, timeouts, debounce). Some are pure routing (handler registry). Some are protocol semantics (capabilities). They're fused, so you can't get at the routing without the connection state. + +When you trace a request through, you bounce between `Protocol._onrequest`, `Server.buildContext`, `McpServer`'s registry handlers, back to `Protocol`'s send path. Three classes, two levels of inheritance. (Python folks will recognize this — "is BaseSession or ServerSession handling this line?") + +--- + +## Part 2: The proposed split + +### The primitive + +```ts +class Dispatcher { + setRequestHandler(method, handler): void; + dispatch(req: JSONRPCRequest, env?: RequestEnv): AsyncIterable; +} +``` + +A `Map` and a function that looks up + calls. `dispatch` yields zero-or-more notifications then exactly one response (matching SEP-2260's wire constraint). `RequestEnv` is per-request context the caller provides — `{sessionId?, authInfo?, signal?, send?}`. No transport. No connection state. ~270 LOC. + +That's it. You can call `dispatch` from anywhere — a test, a Lambda, a loop reading stdin. + +### The channel adapter + +For stdio/WebSocket/InMemory — things that *are* persistent pipes — `StreamDriver` wraps a `ChannelTransport` and a `Dispatcher`: + +```ts +class StreamDriver { + constructor(dispatcher, channel) { ... } + start() { + channel.onmessage = msg => { + for await (const out of dispatcher.dispatch(msg, env)) channel.send(out); + }; + } + request(req): Promise; // outbound, with correlation/timeout +} +``` + +This is where Protocol's per-connection half goes: `_responseHandlers`, `_timeoutInfo`, `_progressHandlers`, debounce. One driver per pipe; the dispatcher it wraps can be shared. ~450 LOC. + +`connect(channelTransport)` builds one of these. So `connect` still works exactly as before for stdio. + +### The request adapter + +For HTTP — things that are *not* persistent pipes — `shttpHandler`: + +```ts +function shttpHandler(dispatcher, opts?): (req: Request) => Promise { + return async (req) => { + const body = await req.json(); + const stream = sseStreamFrom(dispatcher.dispatch(body, env)); + return new Response(stream, {headers: {'content-type': 'text/event-stream'}}); + }; +} +``` + +Parse → `dispatch` → stream the AsyncIterable as SSE. ~400 LOC including header validation, batch handling, EventStore replay. No `_streamMapping` — the response stream is just in lexical scope. + +`mcp.handleHttp(req)` is McpServer's convenience wrapper around this. + +### The deletable parts + +`SessionCompat` — bounded LRU `{sessionId → negotiatedVersion}`. If you pass it to `shttpHandler`, the handler validates `mcp-session-id` headers and mints IDs on `initialize`. If you don't, it doesn't. ~200 LOC. + +`BackchannelCompat` — per-session `{requestId → resolver}` so a tool handler can `await ctx.elicitInput()` and the response comes back via a separate POST. The 2025-11 server→client-over-SSE behavior. ~140 LOC. + +These two are the *only* places 2025-11 stateful behavior lives. When that protocol version sunsets and MRTR (SEP-2322) is the floor, delete both files; `shttpHandler` is fully stateless. + +### Same examples, after + +```ts +// stateless — one server, no transport instance +const mcp = new McpServer({name: 'hello', version: '1'}); +mcp.registerTool('greet', ..., ...); +app.post('/mcp', c => mcp.handleHttp(c.req.raw)); +``` + +```ts +// 2025-11 stateful — same server, opt-in session +const session = new SessionCompat({sessionIdGenerator: () => randomUUID()}); +app.all('/mcp', toNodeHttpHandler(shttpHandler(mcp, {session}))); +``` + +```ts +// stdio — unchanged from today +const t = new StdioServerTransport(); +await mcp.connect(t); +``` + +```ts +// the existing v1 pattern — also unchanged +const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: () => randomUUID()}); +await mcp.connect(t); +app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body)); +// (internally, t.handleRequest now calls shttpHandler — same wire behavior) +``` + +--- + +## Part 3: What you get + +**The stateless server is one line.** One `McpServer` at module scope, `handleHttp` per request. The per-request build-and-tear-down workaround is gone. + +**Handlers are testable without a transport.** `await mcp.dispatchToResponse({...})` — no `InMemoryTransport` pair, no `connect`. + +**The SHTTP transport drops from 1038 to ~290 LOC.** No `_streamMapping` (the response stream is in lexical scope), no body-sniffing for `initialize` (SessionCompat handles it), no fake `start()`. + +**2025-11 protocol state lives in two named files.** When that version sunsets, delete `SessionCompat` and `BackchannelCompat`; `shttpHandler` is fully stateless. Today the same logic is `if (sessionIdGenerator)` branches scattered through one transport. + +**Existing code doesn't change.** `new NodeStreamableHTTPServerTransport({...})` + `connect(t)` + `t.handleRequest(...)` works exactly as before — the class builds the compat pieces internally from the options you already pass. + +--- + +*Reference implementation on [`fweinberger/ts-sdk-rebuild`](https://github.com/modelcontextprotocol/typescript-sdk/tree/fweinberger/ts-sdk-rebuild). See the [RFC](./rfc-stateless-architecture.md) for the formal proposal.* diff --git a/docs/rfc-stateless-architecture.md b/docs/rfc-stateless-architecture.md new file mode 100644 index 000000000..9a123cbc1 --- /dev/null +++ b/docs/rfc-stateless-architecture.md @@ -0,0 +1,121 @@ +# RFC: Request-first SDK architecture + +**Status:** Draft, seeking direction feedback +**Reference impl:** [`fweinberger/ts-sdk-rebuild`](https://github.com/modelcontextprotocol/typescript-sdk/tree/fweinberger/ts-sdk-rebuild) (proof-of-concept, not for direct merge) + +--- + +## TL;DR + +The only way into the SDK today is `server.connect(transport)`, which assumes a persistent channel. The protocol is moving to per-request stateless (SEP-2575/2567/2322). This RFC proposes adding `dispatch(request, env) → response` as the core primitive and building the connection model as one adapter on top of it. Existing code keeps working unchanged. + +--- + +## Problem + +``` + ┌────────────────────────────────────────────┐ + │ Protocol (~1100 LOC, abstract) │ + │ ├ handler registry │ + │ ├ request/response correlation │ + │ ├ timeouts, debounce, progress │ + │ ├ capability assertions (abstract) │ + │ ├ TaskManager binding │ + │ └ connect(transport) — wires onmessage │ + └────────────────────────────────────────────┘ + ▲ ▲ + extends │ │ extends + ┌─────────┴──┐ ┌───────┴──────┐ + │ Server │ │ Client │ + └─────┬──────┘ └──────────────┘ + wraps │ + ┌─────┴──────┐ + │ McpServer │ + └────────────┘ +``` + +Everything goes through `connect(transport)`. `Transport` is pipe-shaped (`{start, send, onmessage, close}`). The Streamable HTTP transport (1038 LOC) implements that pipe shape on top of HTTP — keeping a `_streamMapping` table to route fire-and-forget `send()` calls back to the right HTTP response, sniffing message bodies to detect `initialize` so it knows when to mint a session ID. + +The recommended stateless server pattern is to construct a `McpServer`, register all tools, build a transport with `sessionIdGenerator: undefined`, `connect()`, handle one request, then let it all GC — per request. + +--- + +## Proposal + +``` + ┌───────────────────────────────────────┐ + │ Dispatcher (~270 LOC) │ + │ ├ handler registry │ + │ └ dispatch(req, env) → AsyncIterable │ + │ No transport. No connection state. │ + └───────────────────────────────────────┘ + ▲ + extends │ + ┌─────────────────┴─────────────────┐ + │ McpServer / Client │ + │ (MCP handlers, registries) │ + └─────────────────┬─────────────────┘ + │ dispatch() called by: + ┌───────────────┴───────────────┐ + ▼ ▼ + ┌───────────────────┐ ┌──────────────────────────┐ + │ StreamDriver │ │ shttpHandler │ + │ (channel adapter) │ │ (request adapter) │ + │ correlation, │ │ ├ SessionCompat (opt) │ + │ timeouts, debounce│ │ └ BackchannelCompat(opt) │ + │ stdio, WS, InMem │ │ SHTTP │ + └───────────────────┘ └──────────────────────────┘ +``` + +| Piece | What it is | Replaces | +|---|---|---| +| **Dispatcher** | `Map` + `dispatch(req, env)` | Protocol's handler-registry half | +| **StreamDriver** | Wraps a pipe; `onmessage → dispatch → send` loop | Protocol's correlation/timeout half | +| **shttpHandler** | `(Request) → Promise` calling `dispatch()` | The 1038-LOC SHTTP transport's core | +| **SessionCompat** | Bounded LRU `{sessionId → version}` | `Transport.sessionId` + SHTTP `_initialized` | +| **BackchannelCompat** | Per-session `{requestId → resolver}` for server→client over SSE | `_streamMapping` + `relatedRequestId` | + +SessionCompat and BackchannelCompat hold all 2025-11 stateful behavior. When that protocol version sunsets and MRTR is the floor, they're deleted and `shttpHandler` is fully stateless. + +--- + +## Compatibility + +**Existing SHTTP code does not change.** This: + +```ts +const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: () => randomUUID()}); +await mcp.connect(t); +app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body)); +``` + +works exactly as before. The transport class constructs `SessionCompat`/`BackchannelCompat` internally from the options you already pass; `handleRequest` calls `shttpHandler` under the hood. Same wire behavior, same options, no code change. + +`Protocol` and `Server` stay as back-compat shims for direct subclassers. Stdio/WS unchanged. + +--- + +## Wins + +**Stateless becomes one line.** +```ts +const mcp = new McpServer({name: 'hello', version: '1'}); +mcp.registerTool('greet', ..., ...); +app.post('/mcp', c => mcp.handleHttp(c.req.raw)); +``` +One server at module scope, called per request. No transport instance, no `connect`, no per-request construction. + +**Handlers are testable without a transport.** +```ts +const result = await mcp.dispatchToResponse({jsonrpc:'2.0', id:1, method:'tools/list'}); +``` + +**2025-11 state is deletable.** Two named files (`SessionCompat`, `BackchannelCompat`) instead of branches through one transport. When 2025-11 sunsets, delete them. + +**HTTP-shaped transports stop pretending to be pipes.** No `_streamMapping`, no body-sniffing for `initialize`, no fake `start()`. SHTTP transport drops from 1038 to ~290 LOC. + +**Custom transports get a request-shaped option.** gRPC/Lambda/CF Workers can call `dispatch()` directly instead of implementing a fake pipe. + +--- + +The reference implementation passes all SDK tests, conformance (40/40 server, 317/317 client), and 14/14 consumer typecheck after the existing v2 back-compat PRs. See the [WALKTHROUGH](./WALKTHROUGH.md) for a code-level walk through the current pain and the fix. diff --git a/packages/server/src/server/backchannel2511.ts b/packages/server/src/server/backchannelCompat.ts similarity index 99% rename from packages/server/src/server/backchannel2511.ts rename to packages/server/src/server/backchannelCompat.ts index 24e3a2b07..5445f3401 100644 --- a/packages/server/src/server/backchannel2511.ts +++ b/packages/server/src/server/backchannelCompat.ts @@ -23,7 +23,7 @@ import { DEFAULT_REQUEST_TIMEOUT_MSEC, isJSONRPCErrorResponse, ProtocolError, Sd * (SEP-2322) is the protocol floor and `env.send` becomes a hard error in * stateless paths. */ -export class Backchannel2511 { +export class BackchannelCompat { private _pending = new Map void; reject: (e: Error) => void }>>(); private _standaloneWriters = new Map void>(); private _nextId = 0; diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index a8b2ecfc5..d8e9360ac 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -19,7 +19,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; -import type { Backchannel2511 } from './backchannel2511.js'; +import type { BackchannelCompat } from './backchannelCompat.js'; import type { SessionCompat } from './sessionCompat.js'; export type StreamId = string; @@ -98,7 +98,7 @@ export interface ShttpHandlerOptions { * waiting `env.send` promise. Version-gated: only active for sessions whose negotiated * protocol version is below `2026-06-30`. */ - backchannel?: Backchannel2511; + backchannel?: BackchannelCompat; /** * Event store for SSE resumability via `Last-Event-ID`. When configured, every diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 93a708c4c..ec8f3823b 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -2,7 +2,7 @@ * Web Standards Streamable HTTP Server Transport * * Thin compat wrapper over {@linkcode shttpHandler} + {@linkcode SessionCompat} + - * {@linkcode Backchannel2511}. The class name, constructor options, and + * {@linkcode BackchannelCompat}. The class name, constructor options, and * {@linkcode Transport} interface are kept for back-compat so existing * `server.connect(new WebStandardStreamableHTTPServerTransport({...}))` code * works unchanged. Request handling delegates to {@linkcode shttpHandler}. @@ -26,7 +26,7 @@ import type { } from '@modelcontextprotocol/core'; import { isJSONRPCErrorResponse, isJSONRPCResultResponse, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; -import { Backchannel2511 } from './backchannel2511.js'; +import { BackchannelCompat } from './backchannelCompat.js'; import { SessionCompat } from './sessionCompat.js'; import type { ShttpRequestExtra } from './shttpHandler.js'; import { shttpHandler, STATELESS_GET_KEY } from './shttpHandler.js'; @@ -154,14 +154,14 @@ export interface HandleRequestOptions { * The class is now a thin shim: {@linkcode handleRequest} delegates to a captured * {@linkcode shttpHandler} bound at {@linkcode connect | connect()} time. The * {@linkcode Transport} interface methods route outbound messages through the - * per-session {@linkcode Backchannel2511}. + * per-session {@linkcode BackchannelCompat}. */ export class WebStandardStreamableHTTPServerTransport implements RequestTransport { readonly kind = 'request' as const; private _options: WebStandardStreamableHTTPServerTransportOptions; private _session?: SessionCompat; - private _backchannel = new Backchannel2511(); + private _backchannel = new BackchannelCompat(); private _handler: (req: Request, extra?: ShttpRequestExtra) => Promise; private _started = false; private _closed = false; @@ -280,7 +280,7 @@ export class WebStandardStreamableHTTPServerTransport implements RequestTranspor /** * {@linkcode ChannelTransport.send} (back-compat costume). Outbound responses route to the - * {@linkcode Backchannel2511} resolver map; notifications and server-initiated requests go + * {@linkcode BackchannelCompat} resolver map; notifications and server-initiated requests go * on the session's standalone GET stream. * * @deprecated Use {@linkcode notify} / {@linkcode request} (the {@linkcode RequestTransport} surface). From 2a28377280968016867a96dfb2d7c6c1b83df1a6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 21:10:41 +0000 Subject: [PATCH 52/55] rename: Client._ct -> _clientTransport; update RFC diagram --- docs/WALKTHROUGH.md | 20 ++-- docs/rfc-stateless-architecture.md | 133 +++++++++++++++++++++++---- packages/client/src/client/client.ts | 36 ++++---- 3 files changed, 138 insertions(+), 51 deletions(-) diff --git a/docs/WALKTHROUGH.md b/docs/WALKTHROUGH.md index 5085cfadf..65c57297c 100644 --- a/docs/WALKTHROUGH.md +++ b/docs/WALKTHROUGH.md @@ -72,23 +72,17 @@ A transport — whose job should be "bytes in, bytes out" — is parsing message ### What "stateless" looks like today -The protocol direction (SEP-2575/2567) is: no `initialize`, no sessions, each request is independent. The SDK has a stateless mode. Here's the example: +The protocol direction (SEP-2575/2567) is: no `initialize`, no sessions, each request is independent. You can do this today with a module-scope transport: ```ts -// examples/server/src/simpleStatelessStreamableHttp.ts (paraphrased) -app.post('/mcp', async (req, res) => { - const server = new McpServer(...); // build a fresh server - server.registerTool('greet', ..., ...); // register everything - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined // <-- magic flag - }); - await server.connect(transport); // connect to it - await transport.handleRequest(req, res, req.body); - // server and transport are GC'd after the request -}); +const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: undefined}); +await mcp.connect(t); +app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body)); ``` -Stateless = **build and tear down a stateful server per request**, signaled by `sessionIdGenerator: undefined` taking a different code path through the same 1038-line transport. This works, but it's stateless-by-accident, not stateless-by-design. +`sessionIdGenerator: undefined` is the opt-out — it makes `handleRequest` skip the session-ID minting/validation branches in the transport. The request still goes through the pipe-shaped path (`onmessage → _onrequest → handler → send → _streamMapping` lookup), but without sessions the mapping is just per-in-flight-request. + +It works. It's not obvious — you have to know that `undefined` is the flag, that `connect()` is still needed, and that the transport class is doing pipe-correlation under a request/response API. (The shipped example actually constructs the transport per-request, which is unnecessary but suggests the authors weren't confident in the module-scope version either.) ### Why is Protocol 1100 lines? diff --git a/docs/rfc-stateless-architecture.md b/docs/rfc-stateless-architecture.md index 9a123cbc1..1a6ea4001 100644 --- a/docs/rfc-stateless-architecture.md +++ b/docs/rfc-stateless-architecture.md @@ -36,7 +36,24 @@ The only way into the SDK today is `server.connect(transport)`, which assumes a Everything goes through `connect(transport)`. `Transport` is pipe-shaped (`{start, send, onmessage, close}`). The Streamable HTTP transport (1038 LOC) implements that pipe shape on top of HTTP — keeping a `_streamMapping` table to route fire-and-forget `send()` calls back to the right HTTP response, sniffing message bodies to detect `initialize` so it knows when to mint a session ID. -The recommended stateless server pattern is to construct a `McpServer`, register all tools, build a transport with `sessionIdGenerator: undefined`, `connect()`, handle one request, then let it all GC — per request. +The shipped stateless example constructs a fresh server and transport per request ([`examples/server/src/simpleStatelessStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/7bb79ebbbba88a503851617d053b13d8fd9228bb/examples/server/src/simpleStatelessStreamableHttp.ts#L99-L111)): + +```ts +app.post('/mcp', async (req, res) => { + const server = getServer(); // McpServer + all registrations + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: undefined // opt-out flag + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + transport.close(); + server.close(); + }); +}); +``` + +A module-scope version (one server, one transport, `sessionIdGenerator: undefined`) does work, but the example doesn't use it — and the request still goes through the pipe-shaped path either way. --- @@ -67,21 +84,21 @@ The recommended stateless server pattern is to construct a `McpServer`, register └───────────────────┘ └──────────────────────────┘ ``` -| Piece | What it is | Replaces | +| Piece | What it does | Replaces | |---|---|---| -| **Dispatcher** | `Map` + `dispatch(req, env)` | Protocol's handler-registry half | -| **StreamDriver** | Wraps a pipe; `onmessage → dispatch → send` loop | Protocol's correlation/timeout half | -| **shttpHandler** | `(Request) → Promise` calling `dispatch()` | The 1038-LOC SHTTP transport's core | -| **SessionCompat** | Bounded LRU `{sessionId → version}` | `Transport.sessionId` + SHTTP `_initialized` | -| **BackchannelCompat** | Per-session `{requestId → resolver}` for server→client over SSE | `_streamMapping` + `relatedRequestId` | +| **Dispatcher** | Knows which handler to call for which method. You register handlers (`setRequestHandler('tools/list', fn)`); `dispatch(request)` looks one up, runs it, returns the output. Doesn't know how the request arrived or where the response goes. | Protocol's handler-registry half | +| **StreamDriver** | Runs a Dispatcher over a persistent connection (stdio, WebSocket). Reads from the pipe → `dispatch()` → writes back. Owns the per-connection state: response correlation, timeouts, debounce. One per pipe; the Dispatcher it wraps can be shared. | Protocol's correlation/timeout half | +| **shttpHandler** | Runs a Dispatcher over HTTP. Takes a web `Request`, parses the body, calls `dispatch()`, streams the result as a `Response`. A function you mount on a router, not a class you connect. | The 1038-LOC SHTTP transport's core | +| **SessionCompat** | Remembers session IDs across HTTP requests. 2025-11 servers mint an ID on `initialize` and validate it on every later request — this is the bounded LRU that does that. Pass it to `shttpHandler` for 2025-11 clients; omit it for stateless. | `Transport.sessionId` + SHTTP `_initialized` | +| **BackchannelCompat** | Lets a tool handler ask the client a question mid-call (`ctx.elicitInput()`) over HTTP. 2025-11 does this by writing the question into the still-open SSE response and waiting for the client to POST the answer back; this holds the "waiting for answer N" table. Under MRTR the same thing is a return value, so this gets deleted. | `_streamMapping` + `relatedRequestId` | -SessionCompat and BackchannelCompat hold all 2025-11 stateful behavior. When that protocol version sunsets and MRTR is the floor, they're deleted and `shttpHandler` is fully stateless. +The last two are the only places 2025-11 stateful behavior lives. They're passed to `shttpHandler` as options; without them it's pure request→response. --- ## Compatibility -**Existing SHTTP code does not change.** This: +**Existing stateful SHTTP code does not change:** ```ts const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: () => randomUUID()}); @@ -89,32 +106,108 @@ await mcp.connect(t); app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body)); ``` -works exactly as before. The transport class constructs `SessionCompat`/`BackchannelCompat` internally from the options you already pass; `handleRequest` calls `shttpHandler` under the hood. Same wire behavior, same options, no code change. +Same options, same wire behavior — sessions are minted on `initialize`, validated on every later request, `transport.sessionId` is populated, `onsessioninitialized`/`onsessionclosed` fire, `ctx.elicitInput()` works mid-tool-call. Under the hood the transport class constructs a `SessionCompat` and `BackchannelCompat` from those options and routes `handleRequest` through `shttpHandler`. The session-ful behavior is identical; the implementation is the new path. + +**Existing stdio code does not change:** + +```ts +const t = new StdioServerTransport(); +await mcp.connect(t); +``` + +`connect()` sees a channel-shaped transport and builds a `StreamDriver(mcp, t)` internally — which reads stdin, calls `dispatch()`, writes stdout. The stdio transport class itself is unchanged (it was always just a pipe wrapper); what's different is that the read-dispatch-write loop now lives in `StreamDriver` instead of `Protocol`. + +`Protocol` and `Server` stay as back-compat shims for direct subclassers (ext-apps). + +--- + +## Client side + +The same split applies. `Client extends Dispatcher` — its registry holds the handlers for requests the *server* sends (`elicitation/create`, `sampling/createMessage`, `roots/list`). When one arrives, `dispatch()` routes it. + +For outbound (`callTool`, `listTools`, etc.), Client uses a `ClientTransport`: + +```ts +interface ClientTransport { + fetch(req: JSONRPCRequest, opts?): Promise; // request → response + notify(n: Notification): Promise; + close(): Promise; +} +``` + +This is the request-shaped mirror of the server side: `fetch` is one request → one response. + +``` + ┌───────────────────────────────────────┐ + │ Client extends Dispatcher │ + │ inbound: dispatch() for elicit/ │ + │ sampling/roots │ + │ outbound: callTool → │ + │ _clientTransport.fetch(req)│ + └─────────────────┬─────────────────────┘ + │ _clientTransport is ONE of: + ┌───────────────┴───────────────┐ + ▼ ▼ + ┌───────────────────────┐ ┌────────────────────────────────┐ + │ pipeAsClientTransport │ │ StreamableHTTPClientTransport │ + │ (wraps a channel via │ │ (implements ClientTransport │ + │ StreamDriver) │ │ directly: POST → Response) │ + │ stdio, WS, InMem │ │ SHTTP │ + └───────────────────────┘ └────────────────────────────────┘ +``` -`Protocol` and `Server` stay as back-compat shims for direct subclassers. Stdio/WS unchanged. +**Over HTTP:** `StreamableHTTPClientTransport.fetch` POSTs the request and reads the response (SSE or JSON). If the server writes a JSON-RPC *request* into that SSE stream (2025-11 elicitation), the transport calls `opts.onrequest(r)` — which Client wires to `this.dispatch(r)` — and POSTs the answer back. Same flow as today, request-shaped underneath. + +**Over stdio:** `pipeAsClientTransport(stdioTransport)` wraps the channel in a StreamDriver and exposes `{fetch, notify, close}`. `fetch` becomes "send over the pipe, await the correlated response." + +**MRTR (SEP-2322):** the stateless server→client path. Instead of the held-stream backchannel, the server *returns* `{input_required, requests: [...]}` as the `tools/call` result. Client sees that, services each request via its own `dispatch()`, and re-sends `tools/call` with the answers attached. No held stream, works over any transport. Client's `_request` runs this loop transparently — `await client.callTool(...)` looks the same to the caller whether the server used the backchannel or MRTR. + +**Compat:** `client.connect(transport)` keeps working with both `ChannelTransport` and `ClientTransport`. Existing code (`new StreamableHTTPClientTransport(url)` + `connect`) is unchanged. --- ## Wins -**Stateless becomes one line.** +**Stateless without the opt-out.** Today's stateless is `sessionIdGenerator: undefined` — a flag that opts you out of session handling but leaves the request going through the pipe-shaped path (`onmessage → dispatch → send → _streamMapping` lookup). It's stateless at the wire but not in the code: concurrent requests still share a `_streamMapping` table on the transport instance, the transport still parses bodies looking for `initialize`, and the shipped example constructs everything per-request because the module-scope version isn't obviously safe. After: ```ts -const mcp = new McpServer({name: 'hello', version: '1'}); -mcp.registerTool('greet', ..., ...); +import { McpServer } from '@modelcontextprotocol/server'; +import { Hono } from 'hono'; + +const mcp = new McpServer({name: 'hello', version: '1.0.0'}); +mcp.registerTool('greet', {description: 'Say hello'}, async () => ({ + content: [{type: 'text', text: 'hello'}] +})); + +const app = new Hono(); app.post('/mcp', c => mcp.handleHttp(c.req.raw)); ``` -One server at module scope, called per request. No transport instance, no `connect`, no per-request construction. +No transport class, no `connect`, no flag. The path is `parse → dispatch → respond`. -**Handlers are testable without a transport.** +**Handlers are testable without a transport.** Today, unit-testing a tool handler means an `InMemoryTransport` pair, two `connect()` calls, and a client to drive it. After: ```ts -const result = await mcp.dispatchToResponse({jsonrpc:'2.0', id:1, method:'tools/list'}); +const mcp = new McpServer({name: 'test', version: '1.0.0'}); +mcp.registerTool('greet', {description: '...'}, async () => ({ + content: [{type: 'text', text: 'hello'}] +})); + +const out = await mcp.dispatchToResponse({ + jsonrpc: '2.0', id: 1, method: 'tools/call', params: {name: 'greet', arguments: {}} +}); +expect(out.result.content[0].text).toBe('hello'); ``` +The HTTP layer is testable the same way — `await shttpHandler(mcp)(new Request('http://test/mcp', {method: 'POST', body: ...}))` returns a `Response` you can assert on, no server to spin up. + +**Middleware that covers everything.** `Dispatcher.use(mw)` wraps every dispatch — including `initialize`, which today has no hook. FastMCP currently subclasses the SDK's session class and overrides a `_`-private method to intercept `initialize` for auth; after, it's `mcp.use(authMiddleware)`. Same story for logging, rate limiting, tracing. + +**Pluggable transports stop paying the pipe tax.** A gRPC/WebTransport/Lambda integration today has to implement `{start, send, onmessage, close}` and reconstruct request→response on top. After, request-shaped transports call `dispatch()` directly; only genuinely persistent channels (stdio, WebSocket) implement `ChannelTransport`. + +**Extensions plug in cleanly.** Tasks (and later sampling/roots when they move to `ext-*` packages) attach via `mcp.use(tasksMiddleware(store))` instead of being wired into Protocol. The core SDK doesn't import them. -**2025-11 state is deletable.** Two named files (`SessionCompat`, `BackchannelCompat`) instead of branches through one transport. When 2025-11 sunsets, delete them. +**2025-11 state is deletable.** Two named files instead of `if (sessionIdGenerator)` branches through one transport. The sunset is `git rm sessionCompat.ts backchannelCompat.ts`, not a hunt. -**HTTP-shaped transports stop pretending to be pipes.** No `_streamMapping`, no body-sniffing for `initialize`, no fake `start()`. SHTTP transport drops from 1038 to ~290 LOC. +**Protocol stops being a god class.** Today `Protocol` (~1100 LOC) is registry + correlation + timeouts + capabilities + tasks + connect, abstract, with both Server and Client extending it. Tracing a request means bouncing between Protocol, Server, and McpServer. After: Dispatcher does routing, StreamDriver does per-connection state, McpServer does MCP semantics. Each file has one job; you can read one without the others. -**Custom transports get a request-shaped option.** gRPC/Lambda/CF Workers can call `dispatch()` directly instead of implementing a fake pipe. +**The SHTTP transport class drops from 1038 to ~290 LOC.** New code doesn't need the class at all (`handleHttp` is the entry). The class still exists for back-compat — existing code that does `new NodeStreamableHTTPServerTransport(...)` keeps working — but it's now a thin shim that constructs `shttpHandler` internally. No `_streamMapping`, no body-sniffing for `initialize`, no fake `start()`. --- diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index ebcdaf08f..9bb8ef20f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -209,7 +209,7 @@ export type ClientOptions = ProtocolOptions & { * and runs the 2025-11 initialize handshake for back-compat. */ export class Client extends Dispatcher { - private _ct?: ClientTransport; + private _clientTransport?: ClientTransport; private _capabilities: ClientCapabilities; private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; @@ -247,12 +247,12 @@ export class Client extends Dispatcher { this._taskManager = tasksOpts ? new TaskManager(tasksOpts) : new NullTaskManager(); this._taskManager.attachTo(this, { channel: () => - this._ct + this._clientTransport ? { request: (r, schema, opts) => this._request(r, schema, opts), notification: (n, opts) => this.notification(n, opts), close: () => this.close(), - removeProgressHandler: t => this._ct?.driver?.removeProgressHandler(t) + removeProgressHandler: t => this._clientTransport?.driver?.removeProgressHandler(t) } : undefined, reportError: e => this.onerror?.(e), @@ -284,13 +284,13 @@ export class Client extends Dispatcher { supportedProtocolVersions: this._supportedProtocolVersions, debouncedNotificationMethods: this._options?.debouncedNotificationMethods }; - this._ct = channelAsClientTransport(transport, this, driverOpts); - this._ct.driver!.onresponse = (r, id) => this._taskManager.processInboundResponse(r, id); - this._ct.driver!.onclose = () => { + this._clientTransport = channelAsClientTransport(transport, this, driverOpts); + this._clientTransport.driver!.onresponse = (r, id) => this._taskManager.processInboundResponse(r, id); + this._clientTransport.driver!.onclose = () => { this._taskManager.onClose(); this.onclose?.(); }; - this._ct.driver!.onerror = e => this.onerror?.(e); + this._clientTransport.driver!.onerror = e => this.onerror?.(e); const skipInit = transport.sessionId !== undefined; if (skipInit) { if (this._negotiatedProtocolVersion && transport.setProtocolVersion) { @@ -306,7 +306,7 @@ export class Client extends Dispatcher { } return; } - this._ct = transport; + this._clientTransport = transport; const t = transport as { sessionId?: string; setProtocolVersion?: (v: string) => void }; const setProtocolVersion = (v: string) => t.setProtocolVersion?.(v); if (t.sessionId !== undefined) { @@ -330,7 +330,7 @@ export class Client extends Dispatcher { * {@linkcode ClientTransport} path. No-op if the transport doesn't support it. */ private _startStandaloneStream(): void { - const ct = this._ct; + const ct = this._clientTransport; if (!ct?.subscribe) return; void (async () => { try { @@ -357,8 +357,8 @@ export class Client extends Dispatcher { } async close(): Promise { - const ct = this._ct; - this._ct = undefined; + const ct = this._clientTransport; + this._clientTransport = undefined; for (const t of this._listChangedDebounceTimers.values()) clearTimeout(t); this._listChangedDebounceTimers.clear(); // For pipe transports, driver.onclose (wired in connect) fires this.onclose. @@ -369,12 +369,12 @@ export class Client extends Dispatcher { } get transport(): Transport | undefined { - return this._ct?.driver?.pipe; + return this._clientTransport?.driver?.pipe; } /** Register additional capabilities. Must be called before {@linkcode connect}. */ registerCapabilities(capabilities: ClientCapabilities): void { - if (this._ct) throw new Error('Cannot register capabilities after connecting to transport'); + if (this._clientTransport) throw new Error('Cannot register capabilities after connecting to transport'); this._capabilities = mergeCapabilities(this._capabilities, capabilities); } @@ -472,9 +472,9 @@ export class Client extends Dispatcher { /** Low-level: send a notification to the server. */ async notification(n: Notification, _options?: NotificationOptions): Promise { - if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + if (!this._clientTransport) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); if (this._enforceStrictCapabilities) this._assertNotificationCapability(n.method as NotificationMethod); - await this._ct.notify(n); + await this._clientTransport.notify(n); } // -- typed RPC sugar ------------------------------------------------------ @@ -650,7 +650,7 @@ export class Client extends Dispatcher { /** Like {@linkcode _request} but returns the unparsed result. Used where the result is polymorphic (e.g. SEP-2557 task results). */ private async _requestRaw(req: Request, options?: RequestOptions): Promise { - if (!this._ct) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + if (!this._clientTransport) throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); if (this._enforceStrictCapabilities) this._assertCapabilityForMethod(req.method as RequestMethod); let inputResponses: Record = {}; for (let round = 0; round < this._mrtrMaxRounds; round++) { @@ -698,7 +698,7 @@ export class Client extends Dispatcher { return resp ?? { jsonrpc: '2.0', id: r.id, error: { code: -32_601, message: 'Method not found' } }; } }; - const resp = await this._ct.fetch(jr, opts); + const resp = await this._clientTransport.fetch(jr, opts); if (isJSONRPCErrorResponse(resp)) { throw ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data); } @@ -759,7 +759,7 @@ export class Client extends Dispatcher { // Try server/discover (SEP-2575 stateless), fall back to initialize. Discover schema // is not yet in spec types, so probe and accept the result loosely. try { - const resp = await this._ct!.fetch( + const resp = await this._clientTransport!.fetch( { jsonrpc: '2.0', id: this._requestMessageId++, method: 'server/discover' as RequestMethod }, { timeout: options?.timeout, signal: options?.signal } ); From 858fa3473a0dd957e0377eec47fd2cf7e41f11e2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 21:31:51 +0000 Subject: [PATCH 53/55] docs(rfc): introduce middleware + ChannelTransport/RequestTransport in Proposal --- docs/rfc-stateless-architecture.md | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/rfc-stateless-architecture.md b/docs/rfc-stateless-architecture.md index 1a6ea4001..dd504d29a 100644 --- a/docs/rfc-stateless-architecture.md +++ b/docs/rfc-stateless-architecture.md @@ -94,6 +94,27 @@ A module-scope version (one server, one transport, `sessionIdGenerator: undefine The last two are the only places 2025-11 stateful behavior lives. They're passed to `shttpHandler` as options; without them it's pure request→response. +### Middleware + +`Dispatcher.use(mw)` registers generator middleware that wraps every `dispatch()`: + +```ts +mcp.use(next => async function* (req, env) { + // before handler + for await (const out of next(req, env)) { + // around each notification + the response + yield out; + } + // after +}); +``` + +Runs for every method (including `initialize`), regardless of transport. Short-circuit (auth reject, cache hit), transform outputs, time the call. A small `onMethod('tools/list', fn)` helper gives typed per-method post-processing without the `if (req.method === ...)` boilerplate. + +### Transport interfaces + +`Transport` is renamed `ChannelTransport` (the pipe shape: `start/send/onmessage/close`). `Transport` stays as a deprecated alias. A second internal shape, `RequestTransport`, is what the SHTTP server transport implements — it doesn't pretend to be a pipe. `connect()` accepts both and picks the right adapter via an explicit `kind: 'channel' | 'request'` brand on the transport. + --- ## Compatibility @@ -197,7 +218,15 @@ expect(out.result.content[0].text).toBe('hello'); ``` The HTTP layer is testable the same way — `await shttpHandler(mcp)(new Request('http://test/mcp', {method: 'POST', body: ...}))` returns a `Response` you can assert on, no server to spin up. -**Middleware that covers everything.** `Dispatcher.use(mw)` wraps every dispatch — including `initialize`, which today has no hook. FastMCP currently subclasses the SDK's session class and overrides a `_`-private method to intercept `initialize` for auth; after, it's `mcp.use(authMiddleware)`. Same story for logging, rate limiting, tracing. +**Method-level middleware.** There's no per-method hook today — auth is HTTP-layer (`requireBearerAuth` checks the bearer token before MCP parsing), and to log/trace/rate-limit by MCP method you'd wrap each handler manually. `Dispatcher.use(mw)` wraps every dispatch including `initialize`: +```ts +mcp.use(next => async function* (req, env) { + const start = Date.now(); + yield* next(req, env); + metrics.timing('mcp.method', Date.now() - start, {method: req.method}); +}); +``` +(Python's FastMCP ships ten middleware modules — auth, caching, rate-limiting, tracing — and had to subclass an SDK-private method to intercept `initialize`. That's the demand signal; `use()` is the hook.) **Pluggable transports stop paying the pipe tax.** A gRPC/WebTransport/Lambda integration today has to implement `{start, send, onmessage, close}` and reconstruct request→response on top. After, request-shaped transports call `dispatch()` directly; only genuinely persistent channels (stdio, WebSocket) implement `ChannelTransport`. @@ -207,7 +236,7 @@ The HTTP layer is testable the same way — `await shttpHandler(mcp)(new Request **Protocol stops being a god class.** Today `Protocol` (~1100 LOC) is registry + correlation + timeouts + capabilities + tasks + connect, abstract, with both Server and Client extending it. Tracing a request means bouncing between Protocol, Server, and McpServer. After: Dispatcher does routing, StreamDriver does per-connection state, McpServer does MCP semantics. Each file has one job; you can read one without the others. -**The SHTTP transport class drops from 1038 to ~290 LOC.** New code doesn't need the class at all (`handleHttp` is the entry). The class still exists for back-compat — existing code that does `new NodeStreamableHTTPServerTransport(...)` keeps working — but it's now a thin shim that constructs `shttpHandler` internally. No `_streamMapping`, no body-sniffing for `initialize`, no fake `start()`. +**The SHTTP server transport class drops from 1038 to ~290 LOC.** New server code doesn't need the class at all (`handleHttp` is the entry). The class still exists for back-compat — existing code that does `new NodeStreamableHTTPServerTransport(...)` keeps working — but it's now a thin shim that constructs `shttpHandler` internally. No `_streamMapping`, no body-sniffing for `initialize`, no fake `start()`. (Client-side still needs a transport instance — it has to know where to send. `StreamableHTTPClientTransport` stays, just request-shaped underneath.) --- From 0f2a7f3a2b4e01ca94f6223558e0c808b726ec9e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 21:36:28 +0000 Subject: [PATCH 54/55] docs(rfc): link draft PR #1942 --- docs/rfc-stateless-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc-stateless-architecture.md b/docs/rfc-stateless-architecture.md index dd504d29a..7786d97e5 100644 --- a/docs/rfc-stateless-architecture.md +++ b/docs/rfc-stateless-architecture.md @@ -1,7 +1,7 @@ # RFC: Request-first SDK architecture **Status:** Draft, seeking direction feedback -**Reference impl:** [`fweinberger/ts-sdk-rebuild`](https://github.com/modelcontextprotocol/typescript-sdk/tree/fweinberger/ts-sdk-rebuild) (proof-of-concept, not for direct merge) +**Reference impl:** [#1942](https://github.com/modelcontextprotocol/typescript-sdk/pull/1942) (`fweinberger/ts-sdk-rebuild` — proof-of-concept, not for direct merge) --- From 7026e73e5c4176ab6dbaf3ef24c8e6d302549af4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 21 Apr 2026 21:39:25 +0000 Subject: [PATCH 55/55] chore: prettier formatting (streamDriver, taskManager) --- packages/core/src/shared/streamDriver.ts | 5 +---- packages/core/src/shared/taskManager.ts | 3 +-- packages/server/src/server/mcpServer.ts | 5 ++--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/core/src/shared/streamDriver.ts b/packages/core/src/shared/streamDriver.ts index 907a466aa..0ac24ae6d 100644 --- a/packages/core/src/shared/streamDriver.ts +++ b/packages/core/src/shared/streamDriver.ts @@ -73,10 +73,7 @@ export class StreamDriver implements Outbound { * matched-handler dispatch / unknown-id error). Return `preserveProgress: true` to keep * the progress handler registered after the matched handler runs. Set by the owner. */ - onresponse?: ( - response: JSONRPCResponse | JSONRPCErrorResponse, - messageId: number - ) => { consumed: boolean; preserveProgress?: boolean }; + onresponse?: (response: JSONRPCResponse | JSONRPCErrorResponse, messageId: number) => { consumed: boolean; preserveProgress?: boolean }; constructor( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- driver is context-agnostic; subclass owns ContextT diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts index 85e34e9ed..f3361f3c5 100644 --- a/packages/core/src/shared/taskManager.ts +++ b/packages/core/src/shared/taskManager.ts @@ -823,8 +823,7 @@ export class TaskManager { } return outbound.request(request, resultSchema, { ...options, - intercept: (wire, messageId, settle, onError) => - this.processOutboundRequest(wire, options, messageId, settle, onError).queued + intercept: (wire, messageId, settle, onError) => this.processOutboundRequest(wire, options, messageId, settle, onError).queued }); } diff --git a/packages/server/src/server/mcpServer.ts b/packages/server/src/server/mcpServer.ts index 0e3464e2d..df2f5ee03 100644 --- a/packages/server/src/server/mcpServer.ts +++ b/packages/server/src/server/mcpServer.ts @@ -20,9 +20,9 @@ import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, - JSONRPCResultResponse, JSONRPCRequest, JSONRPCResponse, + JSONRPCResultResponse, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, @@ -326,8 +326,7 @@ export class McpServer extends Dispatcher implements RegistriesHo resolve(parsed.data as SchemaOutput); }; if (opts?.intercept?.(wire, id, finish, reject)) return; - transport - .request!(wire) + transport.request!(wire) .then(resp => 'error' in resp ? reject(ProtocolError.fromError(resp.error.code, resp.error.message, resp.error.data))