diff --git a/.changeset/stateless-2026-06.md b/.changeset/stateless-2026-06.md new file mode 100644 index 0000000000..0e5176786b --- /dev/null +++ b/.changeset/stateless-2026-06.md @@ -0,0 +1,25 @@ +--- +'@modelcontextprotocol/core': major +'@modelcontextprotocol/server': major +'@modelcontextprotocol/client': major +--- + +2026-06 stateless protocol support (SEP-2575, SEP-2567, SEP-2322). + +`Server` and `Client` now support the 2026-06 stateless connection model +alongside the existing pre-2026 model. They remain the same classes (still +extending `Protocol`); the new behavior is additive. + +- `Client.connect()` auto-probes `server/discover` and falls back to the + legacy `initialize` handshake. +- `Server` gained `subscriptions` and `statelessHandlers()`; transports route + per-message via the `MCP-Protocol-Version` header / `_meta` key. +- `handleHttp(server, opts)` is a new Fetch-API entry point: one shared + `Server` instance, no `Transport`, no `connect()`. +- `client.subscribe(filter)` opens a `subscriptions/listen` stream. +- `Transport` interface gained optional `setStatelessHandlers?` and + `sendAndReceive?` for custom transports. +- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside + handlers; works under both protocols (MRTR under 2026-06). + +See `docs/migration.md` for the full guide. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 84b5d320b6..3375eb0768 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -7,6 +7,9 @@ import type { CompleteRequest, GetPromptRequest, Implementation, + InputRequiredResult, + JSONRPCMessage, + JSONRPCNotification, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, @@ -20,12 +23,16 @@ import type { MessageExtraInfo, Middleware, NotificationMethod, + Progress, + ProgressToken, ProtocolOptions, ReadResourceRequest, RequestMethod, RequestOptions, ServerCapabilities, + StandardSchemaV1, SubscribeRequest, + SubscriptionFilter, Tool, Transport, UnsubscribeRequest @@ -36,11 +43,19 @@ import { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + DEFAULT_REQUEST_TIMEOUT_MSEC, + DiscoverResultSchema, ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, GetPromptResultSchema, InitializeResultSchema, + InputRequiredResultSchema, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCResultResponse, + isStatelessProtocolVersion, + JSONRPC_VERSION, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, ListPromptsResultSchema, @@ -48,6 +63,7 @@ import { ListResourceTemplatesResultSchema, ListToolsResultSchema, mergeCapabilities, + META_KEYS, parseSchema, Protocol, ProtocolError, @@ -57,6 +73,47 @@ import { SdkErrorCode } from '@modelcontextprotocol/core'; +const MRTR_MAX_ROUNDS = 16; +const MAX_INPUT_REQUESTS_PER_ROUND = 16; + +/** Only these methods may appear as `inputRequests` (defense-in-depth; schema also constrains). */ +const MRTR_INPUT_METHODS: ReadonlySet = new Set(['sampling/createMessage', 'elicitation/create', 'roots/list']); + +type ListChangedKinds = Record< + string, + { + filterKey: Exclude; + config: ListChangedOptions; + fetcher: () => Promise; + autoRefresh: boolean; + debounceMs?: number; + } +>; + +/** + * Returns true for `server/discover` failures that should fall through to the + * legacy `initialize` handshake (server doesn't speak 2026-06). Auth failures + * (401/403) are NOT fallbackable: a server that requires auth for `discover` + * will require it for `initialize` too, so falling back would only mask the + * real error and skip the transport's re-auth path. + */ +function isFallbackable(e: unknown): boolean { + if (e instanceof ProtocolError) { + return e.code === ProtocolErrorCode.MethodNotFound; + } + if (e instanceof SdkError) { + const status = (e.data as { status?: number } | undefined)?.status; + // Any 4xx except 401/403 (auth) means the server doesn't speak 2026-06. + // 400 in particular is what a pre-2026 StreamableHTTP server returns for + // a non-initialize POST without an mcp-session-id. + return ( + e.code === SdkErrorCode.InvalidResult || + (typeof status === 'number' && status >= 400 && status < 500 && status !== 401 && status !== 403) + ); + } + return false; +} + /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -240,6 +297,356 @@ export class Client extends Protocol { return ctx; } + // ═══════════════════════════════════════════════════════════════════════ + // 2026 stateless (SEP-2575/2322) + // ═══════════════════════════════════════════════════════════════════════ + + /** Set true by {@linkcode _negotiate} when `server/discover` succeeds. */ + private _isStateless = false; + + /** Log level included in per-request `_meta` (set by {@linkcode setLoggingLevel}). */ + private _logLevel?: LoggingLevel; + + /** + * Builds the namespaced `_meta` object this client sends on every 2026-06 + * request: protocol version, client identity, capabilities, log level. + */ + private _buildMeta(version?: string): Record { + const meta: Record = { + [META_KEYS.protocolVersion]: version ?? this._negotiatedProtocolVersion, + [META_KEYS.clientInfo]: this._clientInfo, + [META_KEYS.clientCapabilities]: this._capabilities + }; + if (this._logLevel !== undefined) meta[META_KEYS.logLevel] = this._logLevel; + return meta; + } + + /** + * Merges {@linkcode _buildMeta} + `extra` into `params._meta`. Caller-supplied + * `params._meta` keys take precedence over the namespaced identity keys, but + * SDK-set `extra` (e.g. the correlation `progressToken`) is spread last so it + * always wins, matching {@linkcode Protocol.request}. + */ + private _withMeta(params: Record | undefined, extra?: Record): Record { + return { ...params, _meta: { ...this._buildMeta(), ...(params?._meta as object | undefined), ...extra } }; + } + + /** + * Drains a `sendAndReceive` async iterable: routes `notifications/progress` + * with the matching token to `opts.onprogress`, routes any other + * notification through {@linkcode _onnotification} so registered handlers + * fire, parses and returns the first response, throws on JSON-RPC error. + */ + private async _collect( + it: AsyncIterable, + opts?: { signal?: AbortSignal; onprogress?: (p: Progress) => void; progressToken?: ProgressToken } + ): Promise> { + for await (const m of it) { + opts?.signal?.throwIfAborted(); + if (isJSONRPCErrorResponse(m)) { + throw new ProtocolError(m.error.code, m.error.message, m.error.data); + } + if (isJSONRPCResultResponse(m)) { + return m.result; + } + if (isJSONRPCNotification(m)) { + if ( + m.method === 'notifications/progress' && + opts?.onprogress && + (m.params as { progressToken?: ProgressToken }).progressToken === opts.progressToken + ) { + opts.onprogress(m.params as Progress); + } else { + // Route other notifications (e.g. notifications/message) through + // Protocol's _onnotification so any handler registered via + // setNotificationHandler fires, with the fallback as last resort. + this._onnotification(m); + } + } + // Anything else (e.g. a stray request) is ignored. + } + if (opts?.signal?.aborted) throw opts.signal.reason ?? new DOMException('Aborted', 'AbortError'); + throw new SdkError(SdkErrorCode.ConnectionClosed, 'Stream ended without a response'); + } + + /** + * Routes one client-to-server request. When not stateless (or transport + * lacks `sendAndReceive`), delegates to {@linkcode Protocol.request | request()}. + * Otherwise sends via `sendAndReceive` and runs the MRTR resume loop: + * on `resultType: 'input_required'`, dispatch each input request through + * `this.dispatcher.dispatch` (so {@linkcode _validationMiddleware} runs), + * accumulate `inputResponses` + thread `requestState`, re-send. + */ + private async _send( + request: { method: string; params?: Record }, + schema: T, + options?: RequestOptions + ): Promise> { + const sar = this.transport?.sendAndReceive?.bind(this.transport); + if (!this._isStateless || !sar) { + return this._requestWithSchema(request, schema, options); + } + if (this._enforceStrictCapabilities) { + this.assertCapabilityForMethod(request.method); + } + + const progressToken: ProgressToken | undefined = options?.onprogress ? crypto.randomUUID() : undefined; + const accumulated: Record = {}; + let requestState: string | undefined; + + // Compose `options.signal` + `options.maxTotalTimeout` + a resettable + // per-request `options.timeout` (default 60s, same as Protocol.request) + // into one signal. `resetTimeoutOnProgress` resets the per-request timer + // when progress arrives; `maxTotalTimeout` is never reset. + const timeoutMs = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timeoutCtl = new AbortController(); + const armTimeout = () => + setTimeout( + () => timeoutCtl.abort(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: timeoutMs })), + timeoutMs + ); + let timeoutHandle = armTimeout(); + const onprogress = (p: Progress) => { + if (options?.resetTimeoutOnProgress) { + clearTimeout(timeoutHandle); + timeoutHandle = armTimeout(); + } + options?.onprogress?.(p); + }; + const parts: AbortSignal[] = [timeoutCtl.signal]; + if (options?.signal) parts.push(options.signal); + if (options?.maxTotalTimeout !== undefined) parts.push(AbortSignal.timeout(options.maxTotalTimeout)); + const signal = parts.length === 1 ? parts[0]! : AbortSignal.any(parts); + + try { + for (let round = 0; round < MRTR_MAX_ROUNDS; round++) { + signal.throwIfAborted(); + // SEP-2322: inputResponses + requestState are params-level fields + // (spec InputResponseRequestParams), not _meta keys. + const params: Record = { ...request.params }; + if (Object.keys(accumulated).length > 0) params.inputResponses = accumulated; + if (requestState !== undefined) params.requestState = requestState; + const metaExtra = progressToken === undefined ? undefined : { progressToken }; + + const raw = await this._collect(sar({ method: request.method, params: this._withMeta(params, metaExtra) }, { signal }), { + signal, + onprogress, + progressToken + }); + + if (raw.resultType !== 'input_required') { + const parsed = await schema['~standard'].validate(raw); + if (parsed.issues) { + throw new SdkError(SdkErrorCode.InvalidResult, `Invalid result: ${JSON.stringify(parsed.issues)}`); + } + return parsed.value; + } + const ir = InputRequiredResultSchema.parse(raw) as InputRequiredResult; + requestState = ir.requestState; + const entries = Object.entries(ir.inputRequests ?? {}); + if (entries.length > MAX_INPUT_REQUESTS_PER_ROUND) { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Too many input requests (${entries.length}); server may issue at most ${MAX_INPUT_REQUESTS_PER_ROUND} per round` + ); + } + for (const [key, irq] of entries) { + signal.throwIfAborted(); + if (!MRTR_INPUT_METHODS.has(irq.method)) { + throw new SdkError(SdkErrorCode.InvalidResult, `inputRequests['${key}'].method '${irq.method}' is not allowed`); + } + // Dispatch through the same middleware chain as legacy + // server-to-client requests so _validationMiddleware applies. + const ctx = this.buildContext({ + sessionId: undefined, + mcpReq: { + id: key, + method: irq.method, + signal, + send: (() => { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'send is not available inside MRTR input handlers'); + }) as ClientContext['mcpReq']['send'], + notify: async () => {} + } + }); + const res = await this.dispatcher.dispatch( + { jsonrpc: JSONRPC_VERSION, id: key, method: irq.method, params: irq.params as Record }, + ctx + ); + if ('error' in res) { + throw new ProtocolError(res.error.code, res.error.message, res.error.data); + } + accumulated[key] = res.result; + } + } + throw new SdkError(SdkErrorCode.RequestTimeout, `MRTR exceeded ${MRTR_MAX_ROUNDS} rounds for ${request.method}`); + } finally { + clearTimeout(timeoutHandle); + } + } + + /** + * Probes `server/discover` via `transport.sendAndReceive`. On success, + * marks this client stateless and populates server identity/capabilities + * from the result. On {@linkcode isFallbackable} failure, leaves state + * untouched so {@linkcode connect} falls through to the legacy + * `initialize` handshake. + */ + private async _negotiate(transport: Transport, options?: RequestOptions): Promise { + const sar = transport.sendAndReceive?.bind(transport); + const preferred = this._supportedProtocolVersions.find(v => isStatelessProtocolVersion(v)); + if (!sar || !preferred) return; + + transport.setProtocolVersion?.(preferred); + const timeoutSignal = AbortSignal.timeout(options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC); + const signal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; + try { + const raw = await this._collect(sar({ method: 'server/discover', params: { _meta: this._buildMeta(preferred) } }, { signal }), { + signal + }); + const drParsed = DiscoverResultSchema.safeParse(raw); + if (drParsed.success) { + const dr = drParsed.data; + // The probe only counts as success when there is a mutual + // *stateless* version; otherwise fall through to legacy initialize. + const negotiated = dr.supportedVersions.find( + v => isStatelessProtocolVersion(v) && this._supportedProtocolVersions.includes(v) + ); + if (negotiated) { + this._serverCapabilities = dr.capabilities; + this._serverVersion = dr.serverInfo; + this._instructions = dr.instructions; + this._negotiatedProtocolVersion = negotiated; + this._isStateless = true; + transport.setProtocolVersion?.(negotiated); + return; + } + } + } catch (error) { + if (!isFallbackable(error)) { + // Reset the version we set before re-throwing so the + // transport is not left advertising a stateless version. + transport.setProtocolVersion?.(this._negotiatedProtocolVersion ?? ''); + throw error; + } + } + // Fallback path: reset the version header so the subsequent legacy + // `_initialize()` (run by `connect()`) can set it. + transport.setProtocolVersion?.(this._negotiatedProtocolVersion ?? ''); + } + + /** + * Opens a `subscriptions/listen` stream and yields each notification. + * Throws unless this client negotiated stateless mode and the transport + * supports `sendAndReceive`. Breaking out of the loop (or aborting the + * signal) cancels the underlying transport stream, which the server treats + * as unsubscribe. + */ + async *subscribe(filter: SubscriptionFilter, opts?: { signal?: AbortSignal }): AsyncGenerator { + const sar = this.transport?.sendAndReceive?.bind(this.transport); + if (!this._isStateless || !sar) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + 'subscribe() requires a stateless protocol version and a transport that supports sendAndReceive' + ); + } + for await (const m of sar( + { method: 'subscriptions/listen', params: this._withMeta({ notifications: filter }) }, + { signal: opts?.signal } + )) { + if (isJSONRPCNotification(m)) { + yield m; + } else if (isJSONRPCErrorResponse(m)) { + throw new ProtocolError(m.error.code, m.error.message, m.error.data); + } + } + } + + private _listChangedAbort?: AbortController; + + /** + * Stateless backing for `options.listChanged`: opens ONE + * `subscriptions/listen` for all configured list-changed kinds and calls + * the matching `onChanged` per notification (debounced by `debounceMs`). + */ + private async _listChangedLoop(kinds: ListChangedKinds): Promise { + const filter: SubscriptionFilter = {}; + const debounced: Record void> = {}; + const timers = new Map>(); + for (const [method, k] of Object.entries(kinds)) { + filter[k.filterKey] = true; + const { autoRefresh, debounceMs } = k; + const refresh = async () => { + if (!autoRefresh) { + k.config.onChanged(null, null); + return; + } + try { + k.config.onChanged(null, await k.fetcher()); + } catch (error) { + k.config.onChanged(error instanceof Error ? error : new Error(String(error)), null); + } + }; + // eslint-disable-next-line unicorn/consistent-function-scoping -- closes over per-iteration `refresh` + const run = () => + void refresh().catch(error => (this.onerror ?? console.error)(error instanceof Error ? error : new Error(String(error)))); + debounced[method] = debounceMs + ? () => { + const t = timers.get(method); + if (t) clearTimeout(t); + timers.set(method, setTimeout(run, debounceMs)); + } + : run; + } + this._listChangedAbort?.abort(); + this._listChangedAbort = new AbortController(); + const { signal } = this._listChangedAbort; + try { + for await (const n of this.subscribe(filter, { signal })) { + debounced[n.method]?.(); + } + // Stream ended without error and without our abort: surface so the + // caller knows list-changed delivery has stopped. + if (!signal.aborted) { + throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended'); + } + } finally { + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // dual-mode (SEP-2575/2567) + // + // `connect()` probes `server/discover` then falls back to legacy + // `initialize`. `_setupListChanged()` and `setLoggingLevel()` branch on + // `_isStateless`. Typed request methods (callTool/listTools/etc.) route + // via `_send`, which falls back to `Protocol.request()` when not + // stateless. These methods appear inline below among session-dependent + // code for diff-minimality; the dual-mode set is: connect, close, + // _initialize, _setupListChanged, setLoggingLevel, callTool, listTools, + // getPrompt, listPrompts, readResource, listResources, + // listResourceTemplates, complete. + // ═══════════════════════════════════════════════════════════════════════ + + override async close(): Promise { + this._isStateless = false; + this._listChangedAbort?.abort(); + this._listChangedAbort = undefined; + await super.close(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // session-dependent (existing — bodies unchanged unless noted dual-mode above) + // + // `_initialize()` (extracted verbatim from the previous inline `connect()` + // body) performs the legacy `initialize` handshake. `ping`, + // `subscribeResource`, `unsubscribeResource`, and + // `_setupListChangedHandler*` use the persistent connection; + // `_listChangedLoop` (above) is the 2026 path. + // ═══════════════════════════════════════════════════════════════════════ + /** * 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. @@ -412,50 +819,100 @@ export class Client extends Protocol { return; } try { - const result = await this._requestWithSchema( - { - method: 'initialize', - params: { - protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, - capabilities: this._capabilities, - clientInfo: this._clientInfo - } - }, - InitializeResultSchema, - options - ); - - if (result === undefined) { - throw new Error(`Server sent invalid initialize result: ${result}`); + // Probe `server/discover` (SEP-2575). If it succeeds, this client is + // stateless and the legacy `initialize` is skipped. + await this._negotiate(transport, options); + if (!this._isStateless) { + await this._initialize(transport, options); } + this._setupListChanged(); + } catch (error) { + // Disconnect if initialization fails. + void this.close(); + throw error; + } + } - if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { - throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); - } + /** + * Legacy `initialize` handshake; called from {@linkcode connect} when the + * 2026-06 discover probe is unavailable or falls back. + */ + private async _initialize(transport: Transport, options?: RequestOptions): Promise { + const result = await this._requestWithSchema( + { + method: 'initialize', + params: { + protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, + InitializeResultSchema, + options + ); - 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); - } + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } - this._instructions = result.instructions; + if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } - await this.notification({ - method: 'notifications/initialized' - }); + 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); + } - // Set up list changed handlers now that we know server capabilities - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; - } - } catch (error) { - // Disconnect if initialization fails. - void this.close(); - throw error; + this._instructions = result.instructions; + + await this.notification({ + method: 'notifications/initialized' + }); + } + + /** + * Wires `options.listChanged` after capabilities are known. Stateless + * connections use `subscriptions/listen` ({@linkcode _listChangedLoop}); + * legacy connections register notification handlers. + */ + private _setupListChanged(): void { + const config = this._pendingListChangedConfig; + if (!config) return; + if (!this._isStateless) { + this._pendingListChangedConfig = undefined; + this._setupListChangedHandlers(config); + return; + } + const kinds: ListChangedKinds = {}; + const add = ( + method: string, + filterKey: Exclude, + cfg: ListChangedOptions, + fetcher: () => Promise + ): void => { + const parsed = parseSchema(ListChangedOptionsBaseSchema, cfg); + if (!parsed.success) throw new Error(`Invalid ${String(filterKey)} listChanged options: ${parsed.error.message}`); + kinds[method] = { filterKey, config: cfg as ListChangedOptions, fetcher, ...parsed.data }; + }; + if (config.tools && this._serverCapabilities?.tools?.listChanged) { + add('notifications/tools/list_changed', 'toolsListChanged', config.tools, () => this.listTools().then(r => r.tools)); + } + if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { + add('notifications/prompts/list_changed', 'promptsListChanged', config.prompts, () => this.listPrompts().then(r => r.prompts)); + } + if (config.resources && this._serverCapabilities?.resources?.listChanged) { + add('notifications/resources/list_changed', 'resourcesListChanged', config.resources, () => + this.listResources().then(r => r.resources) + ); + } + if (Object.keys(kinds).length > 0) { + this._listChangedLoop(kinds).catch(error => + (this.onerror ?? console.error)(error instanceof Error ? error : new Error(String(error))) + ); } } @@ -620,23 +1077,32 @@ export class Client extends Protocol { } } + /** + * @deprecated `ping` is removed in the 2026-06 protocol. This method requires a pre-2026 connection. + */ 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); + return this._send({ method: 'completion/complete', params }, CompleteResultSchema, options); } - /** Sets the minimum severity level for log messages sent by the server. */ + /** + * Sets the minimum severity level for log messages sent by the server. + * Stored locally for per-request `_meta.logLevel`; when not stateless, + * also sends the legacy `logging/setLevel` RPC. + */ async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + this._logLevel = level; + if (this._isStateless) return {}; return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); } /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'prompts/get', params }, GetPromptResultSchema, options); + return this._send({ method: 'prompts/get', params }, GetPromptResultSchema, options); } /** @@ -666,7 +1132,7 @@ export class Client extends Protocol { 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); + return this._send({ method: 'prompts/list', params }, ListPromptsResultSchema, options); } /** @@ -696,7 +1162,7 @@ export class Client extends Protocol { 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); + return this._send({ method: 'resources/list', params }, ListResourcesResultSchema, options); } /** @@ -713,20 +1179,28 @@ export class Client extends Protocol { ); return { resourceTemplates: [] }; } - return this._requestWithSchema({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + return this._send({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); } /** Reads the contents of a resource by URI. */ async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); + return this._send({ method: 'resources/read', params }, ReadResourceResultSchema, options); } - /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ + /** + * Subscribes to change notifications for a resource. The server must support resource subscriptions. + * + * @deprecated Use `client.subscribe({ resourceSubscriptions: [uri] })` when connected to a 2026-06 server. This RPC form requires a pre-2026 connection. + */ async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); } - /** Unsubscribes from change notifications for a resource. */ + /** + * Unsubscribes from change notifications for a resource. + * + * @deprecated Use `client.subscribe()` and break out of the loop / abort its signal to stop, when connected to a 2026-06 server. This RPC form requires a pre-2026 connection. + */ async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { return this._requestWithSchema({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); } @@ -769,7 +1243,7 @@ export class Client extends Protocol { * ``` */ async callTool(params: CallToolRequest['params'], options?: RequestOptions) { - const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); + const result = await this._send({ method: 'tools/call', params }, CallToolResultSchema, options); // Check if the tool has an outputSchema const validator = this.getToolOutputValidator(params.name); @@ -859,7 +1333,7 @@ export class Client extends Protocol { 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); + const result = await this._send({ method: 'tools/list', params }, ListToolsResultSchema, options); // Cache the tools and their output schemas for future validation this.cacheToolMetadata(result.tools); @@ -927,7 +1401,11 @@ export class Client extends Protocol { this.setNotificationHandler(notificationMethod, handler); } - /** Notifies the server that the client's root list has changed. Requires the `roots.listChanged` capability. */ + /** + * Notifies the server that the client's root list has changed. Requires the `roots.listChanged` capability. + * + * @deprecated Under the 2026-06 protocol the server polls roots via MRTR; there is no client-to-server notification path. This form requires a pre-2026 connection. + */ async sendRootsListChanged() { return this.notification({ method: 'notifications/roots/list_changed' }); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cd643c96dc..238ec53a95 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,6 +1,6 @@ import type { ReadableWritablePair } from 'node:stream/web'; -import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import type { FetchLike, JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; import { createFetchWithInit, isInitializedNotification, @@ -189,6 +189,100 @@ export class StreamableHTTPClientTransport implements Transport { onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; + /** + * Sends one stateless request and yields the messages the server emits for + * it (zero or more notifications then one response, or for + * `subscriptions/listen` the indefinite stream). Backed directly by + * `fetch`; does not go through `Protocol.request()`/`_responseHandlers`. + * + * Used by `Client` for 2026-06 stateless calls. Auth handling mirrors + * {@linkcode send}: token attached via `_commonHeaders`, one 401 retry via + * `authProvider.onUnauthorized`, and one 403 `insufficient_scope` upscoping + * retry for OAuth providers. The ladder is duplicated here (not shared with + * `send()`) so `send()` stays byte-identical to the pre-2026 code path. + */ + async *sendAndReceive( + request: Omit, + opts?: { signal?: AbortSignal } + ): AsyncGenerator { + const body = JSON.stringify({ jsonrpc: '2.0', id: 0, ...request }); + const signal = + opts?.signal && this._abortController + ? AbortSignal.any([opts.signal, this._abortController.signal]) + : (opts?.signal ?? this._abortController?.signal); + const post = async (): Promise => { + const headers = await this._commonHeaders(); + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json, text/event-stream'); + return (this._fetch ?? fetch)(this._url, { ...this._requestInit, method: 'POST', headers, body, signal }); + }; + let response = await post(); + 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) { + await this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }); + await response.text?.().catch(() => {}); + response = await post(); + } + } + if (response.status === 403 && this._oauthProvider) { + const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); + if (error === 'insufficient_scope') { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (this._lastUpscopingHeader !== wwwAuthHeader) { + 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') { + await response.text?.().catch(() => {}); + response = await post(); + } + } + } + } + if (!response.ok) { + const text = await response.text().catch(() => ''); + if (response.status === 401) { + throw new SdkError(SdkErrorCode.ClientHttpAuthentication, text || 'Unauthorized', { status: 401 }); + } + throw new SdkError(SdkErrorCode.SendFailed, `HTTP ${response.status}: ${text || response.statusText}`, { + status: response.status + }); + } + const ct = response.headers.get('content-type') ?? ''; + if (ct.includes('text/event-stream') && response.body) { + const reader = response.body + .pipeThrough(new TextDecoderStream() as ReadableWritablePair) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + try { + for (;;) { + const { value: event, done } = await reader.read(); + if (done) break; + if (!event.data) continue; + yield JSONRPCMessageSchema.parse(JSON.parse(event.data)); + } + } finally { + await reader.cancel().catch(() => {}); + } + } else { + const json = (await response.json()) as unknown; + for (const m of Array.isArray(json) ? json : [json]) { + yield JSONRPCMessageSchema.parse(m); + } + } + } + constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) { this._url = url; this._resourceMetadataUrl = undefined; diff --git a/packages/client/test/client/clientSend.test.ts b/packages/client/test/client/clientSend.test.ts new file mode 100644 index 0000000000..4d56ba3063 --- /dev/null +++ b/packages/client/test/client/clientSend.test.ts @@ -0,0 +1,257 @@ +import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { DRAFT_PROTOCOL_VERSION, JSONRPC_VERSION, META_KEYS, SdkError } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +/** Minimal transport with a scriptable sendAndReceive. */ +function mockTransport(handler: (req: JSONRPCRequest) => AsyncIterable): Transport { + return { + start: async () => {}, + close: async () => {}, + send: async () => {}, + sendAndReceive: req => handler({ jsonrpc: JSONRPC_VERSION, id: 0, ...req } as JSONRPCRequest) + }; +} + +/** Forces the client into stateless mode without going through connect(). */ +function statelessClient(transport: Transport): Client { + const c = new Client({ name: 'c', version: '1' }, { capabilities: { elicitation: {} } }); + Object.assign(c as object, { + _isStateless: true, + _negotiatedProtocolVersion: DRAFT_PROTOCOL_VERSION, + _serverCapabilities: { tools: {}, prompts: {} }, + _transport: transport + }); + return c; +} + +async function* once(m: JSONRPCMessage): AsyncIterable { + yield m; +} + +describe('Client._send (stateless)', () => { + it('routes via sendAndReceive when stateless', async () => { + let seenMeta: unknown; + const t = mockTransport(req => { + seenMeta = req.params?._meta; + return once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { tools: [] } }); + }); + const c = statelessClient(t); + const r = await c.listTools(); + expect(r.tools).toEqual([]); + expect((seenMeta as Record)[META_KEYS.protocolVersion]).toBe(DRAFT_PROTOCOL_VERSION); + expect((seenMeta as Record)[META_KEYS.clientInfo]).toEqual({ name: 'c', version: '1' }); + }); + + it('falls back to Protocol.request when not stateless', async () => { + const c = new Client({ name: 'c', version: '1' }); + // Not stateless, no transport — request() will throw NotConnected. + await expect(c.getPrompt({ name: 'x' })).rejects.toThrow(); + }); + + it('MRTR: dispatches input requests via dispatcher.dispatch (middleware runs)', async () => { + let round = 0; + const t = mockTransport(req => { + round++; + if (round === 1) { + return once({ + jsonrpc: JSONRPC_VERSION, + id: req.id, + result: { + resultType: 'input_required', + inputRequests: { + e0: { + method: 'elicitation/create', + params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } + } + } + } + }); + } + const ir = (req.params as Record).inputResponses as Record; + return once({ + jsonrpc: JSONRPC_VERSION, + id: req.id, + result: { tools: [{ name: JSON.stringify(ir.e0), inputSchema: { type: 'object' } }] } + }); + }); + const c = statelessClient(t); + c.setRequestHandler('elicitation/create', async () => ({ action: 'accept' as const })); + const r = await c.listTools(); + expect(round).toBe(2); + expect(JSON.parse((r.tools[0] as { name: string }).name)).toEqual({ action: 'accept' }); + }); + + it('MRTR: threads requestState into next round', async () => { + let round = 0; + let seenState: unknown; + const t = mockTransport(req => { + round++; + if (round === 1) { + return once({ + jsonrpc: JSONRPC_VERSION, + id: req.id, + result: { resultType: 'input_required', inputRequests: {}, requestState: 'opaque-state' } + }); + } + seenState = (req.params as Record).requestState; + return once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { tools: [] } }); + }); + const c = statelessClient(t); + await c.listTools(); + expect(seenState).toBe('opaque-state'); + }); + + it('MRTR: throws after MRTR_MAX_ROUNDS', async () => { + const t = mockTransport(req => + once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { resultType: 'input_required', inputRequests: {} } }) + ); + const c = statelessClient(t); + await expect(c.listTools()).rejects.toThrow(SdkError); + }); + + it('propagates abort signal', async () => { + const ac = new AbortController(); + const t = mockTransport(req => { + ac.abort(); + return once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { resultType: 'input_required', inputRequests: {} } }); + }); + const c = statelessClient(t); + await expect(c.listTools(undefined, { signal: ac.signal })).rejects.toThrow(); + }); + + it('routes notifications/progress with matching token to onprogress', async () => { + const t = mockTransport(req => { + const token = (req.params?._meta as Record).progressToken; + return (async function* () { + yield { + jsonrpc: JSONRPC_VERSION, + method: 'notifications/progress', + params: { progressToken: token, progress: 50, total: 100 } + } as JSONRPCMessage; + yield { jsonrpc: JSONRPC_VERSION, id: req.id, result: { tools: [] } }; + })(); + }); + const c = statelessClient(t); + const seen: number[] = []; + await c.listTools(undefined, { onprogress: p => seen.push(p.progress) }); + expect(seen).toEqual([50]); + }); + + it('throws ProtocolError on JSON-RPC error response', async () => { + const t = mockTransport(req => once({ jsonrpc: JSONRPC_VERSION, id: req.id, error: { code: -32_601, message: 'nope' } })); + const c = statelessClient(t); + await expect(c.listTools()).rejects.toMatchObject({ code: -32_601 }); + }); +}); + +describe('Client.subscribe', () => { + it('throws when not stateless', async () => { + const c = new Client({ name: 'c', version: '1' }); + const it = c.subscribe({ toolsListChanged: true }); + await expect(it.next()).rejects.toThrow(SdkError); + }); + + it('yields notifications from listen stream', async () => { + const t = mockTransport(() => + (async function* () { + yield { + jsonrpc: JSONRPC_VERSION, + method: 'notifications/subscriptions/acknowledged', + params: { notifications: { toolsListChanged: true } } + } as JSONRPCMessage; + yield { jsonrpc: JSONRPC_VERSION, method: 'notifications/tools/list_changed', params: {} } as JSONRPCMessage; + })() + ); + const c = statelessClient(t); + const seen: string[] = []; + for await (const n of c.subscribe({ toolsListChanged: true })) { + seen.push(n.method); + } + expect(seen).toEqual(['notifications/subscriptions/acknowledged', 'notifications/tools/list_changed']); + }); +}); + +describe('Client.connect auto-probe (SEP-2575)', () => { + function discoverable(handler: (req: JSONRPCRequest) => AsyncIterable): Transport { + const t = mockTransport(handler); + // Route legacy `initialize` (sent via Protocol.request → transport.send) + // back through onmessage so the fallback path can complete in-process. + t.send = async m => { + if ('method' in m && m.method === 'initialize') { + queueMicrotask(() => + t.onmessage?.({ + jsonrpc: JSONRPC_VERSION, + id: (m as JSONRPCRequest).id, + result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 's', version: '1' } } + }) + ); + } else if ('method' in m && m.method === 'notifications/initialized') { + // ignore + } + }; + return t; + } + + it('discover success → stateless mode, skips initialize', async () => { + const seen: string[] = []; + const t = discoverable(req => { + seen.push(req.method); + return once({ + jsonrpc: JSONRPC_VERSION, + id: req.id, + result: { + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: { tools: {} }, + serverInfo: { name: 's', version: '2' } + } + }); + }); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(t); + expect(seen).toEqual(['server/discover']); + expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(true); + expect(c.getServerCapabilities()).toEqual({ tools: {} }); + expect(c.getServerVersion()).toEqual({ name: 's', version: '2' }); + }); + + it('discover MethodNotFound → falls back to legacy initialize', async () => { + const seen: string[] = []; + const t = discoverable(req => { + seen.push(req.method); + return once({ jsonrpc: JSONRPC_VERSION, id: req.id, error: { code: -32_601, message: 'unknown method' } }); + }); + const c = new Client({ name: 'c', version: '1' }); + await c.connect(t); + expect(seen).toEqual(['server/discover']); + expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(false); + expect(c.getServerVersion()).toEqual({ name: 's', version: '1' }); + }); + + it('no sendAndReceive → goes straight to legacy initialize', async () => { + const t: Transport = { + start: async () => {}, + close: async () => {}, + send: async m => { + if ('method' in m && m.method === 'initialize') { + queueMicrotask(() => + t.onmessage?.({ + jsonrpc: JSONRPC_VERSION, + id: (m as JSONRPCRequest).id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }) + ); + } + } + }; + const c = new Client({ name: 'c', version: '1' }); + await c.connect(t); + expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(false); + expect(c.getServerVersion()?.name).toBe('s'); + }); +}); diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index ef30915ba6..6a371e0d4d 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -112,4 +112,15 @@ export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js'; // fromJsonSchema is intentionally NOT exported here — the server and client packages // provide runtime-aware wrappers that default to the appropriate validator via _shims. +export type { ClientMeta, DispatchContext, ListenContext, ListenStream, StatelessHandlers } from '../../shared/stateless.js'; +export { + InputRequiredError, + isInputRequiredError, + isStatelessProtocolVersion, + isStatelessRequest, + META_KEYS, + parseClientMeta, + STATEFUL_PROTOCOL_VERSIONS, + STATELESS_REMOVED_METHODS +} from '../../shared/stateless.js'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from '../../validators/types.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ba787aefa1..f188b4a7c2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,11 +5,13 @@ export * from './shared/authUtils.js'; export * from './shared/dispatcher.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; +export * from './shared/stateless.js'; export * from './shared/stdio.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; export * from './types/index.js'; +export * from './util/asyncQueue.js'; export * from './util/inMemory.js'; export * from './util/schema.js'; export * from './util/standardSchema.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 77a2222a14..1a3abdd2ca 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -4,6 +4,8 @@ import type { CancelledNotification, ClientCapabilities, CreateMessageRequest, + CreateMessageRequestParamsBase, + CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, ElicitRequestFormParams, @@ -14,6 +16,8 @@ import type { JSONRPCRequest, JSONRPCResponse, JSONRPCResultResponse, + ListRootsRequest, + ListRootsResult, LoggingLevel, MessageExtraInfo, Notification, @@ -208,6 +212,13 @@ export type BaseContext = { * Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields. */ export type ServerContext = BaseContext & { + /** + * The client's declared capabilities for this request. + * Under the pre-2026 connection model these come from the `initialize` + * handshake; under 2026-06 they are per-request from `_meta`. + */ + clientCapabilities?: ClientCapabilities; + mcpReq: { /** * Send a log message notification to the client. @@ -223,10 +234,16 @@ export type ServerContext = BaseContext & { /** * Request LLM sampling from the client. */ - requestSampling: ( - params: CreateMessageRequest['params'], - options?: RequestOptions - ) => Promise; + requestSampling: { + (params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; + (params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; + (params: CreateMessageRequest['params'], options?: RequestOptions): Promise; + }; + + /** + * Request the client's filesystem roots. + */ + listRoots: (params?: ListRootsRequest['params'], options?: RequestOptions) => Promise; }; http?: { diff --git a/packages/core/src/shared/stateless.ts b/packages/core/src/shared/stateless.ts new file mode 100644 index 0000000000..be5c48fb1c --- /dev/null +++ b/packages/core/src/shared/stateless.ts @@ -0,0 +1,228 @@ +import type { + AuthInfo, + ClientCapabilities, + Implementation, + InputRequests, + InputResponses, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + LoggingLevel +} from '../types/index.js'; + +/** + * Closed list of protocol versions that use the legacy stateful model + * (`initialize` handshake, per-connection state). Any version not in this list + * is treated as stateless (per-request `_meta`). + * + * Hardcoded by design. Do NOT derive from `SUPPORTED_PROTOCOL_VERSIONS`: when + * `2026-06-18` is added there, a derived list would silently classify it as + * stateful and misroute every request. New stateful versions are not expected; + * if one ever ships, add it here explicitly. + */ +export const STATEFUL_PROTOCOL_VERSIONS: readonly string[] = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; + +/** + * Returns true when the given protocol-version string is one the SDK should + * serve via the stateless dispatch path (per-request `_meta`, no `initialize`). + */ +export function isStatelessProtocolVersion(version: string): boolean { + return version.length > 0 && !STATEFUL_PROTOCOL_VERSIONS.includes(version); +} + +/** + * RPC method names removed in the 2026-06 stateless model. A stateless server + * returns `-32601 MethodNotFound` for these. + */ +export const STATELESS_REMOVED_METHODS: ReadonlySet = new Set([ + 'initialize', + 'ping', + 'logging/setLevel', + 'resources/subscribe', + 'resources/unsubscribe' +]); + +/** + * Reserved `_meta` keys under the `io.modelcontextprotocol/` namespace. + */ +export const META_KEYS = { + protocolVersion: 'io.modelcontextprotocol/protocolVersion', + clientCapabilities: 'io.modelcontextprotocol/clientCapabilities', + clientInfo: 'io.modelcontextprotocol/clientInfo', + logLevel: 'io.modelcontextprotocol/logLevel', + subscriptionId: 'io.modelcontextprotocol/subscriptionId' +} as const; + +/** + * Per-request client state extracted from a request's `params`. All fields are + * client-asserted advisory data, not authentication; a malformed value is + * indistinguishable from a well-formed lie. Authorization is at the transport + * layer. + */ +export interface ClientMeta { + protocolVersion?: string; + clientCapabilities?: ClientCapabilities; + clientInfo?: Implementation; + logLevel?: LoggingLevel; + /** From `params.inputResponses` (spec `InputResponseRequestParams`), not `_meta`. */ + inputResponses?: InputResponses; + /** From `params.requestState` (spec `InputResponseRequestParams`), not `_meta`. */ + requestState?: string; +} + +/** + * Reads the SEP-2575/2322 per-request keys from `params`: namespaced + * `io.modelcontextprotocol/*` keys from `params._meta`, plus `inputResponses` + * and `requestState` from `params` itself (spec `InputResponseRequestParams`). + * Top-level type checks only; unknown keys ignored. See the security note on + * {@linkcode ClientMeta}. + */ +export function parseClientMeta( + params: { _meta?: Record; inputResponses?: unknown; requestState?: unknown } | undefined +): ClientMeta { + const out: ClientMeta = {}; + const meta = params?._meta; + if (meta && typeof meta === 'object') { + const v = meta[META_KEYS.protocolVersion]; + if (typeof v === 'string') out.protocolVersion = v; + // Client-asserted advisory state, not auth; malformed = well-formed lie. + // clientCapabilities declares what the CLIENT supports; lying only hurts the client. + const caps = meta[META_KEYS.clientCapabilities]; + if (caps && typeof caps === 'object') out.clientCapabilities = caps as ClientCapabilities; + const info = meta[META_KEYS.clientInfo]; + if (info && typeof info === 'object') out.clientInfo = info as Implementation; + const lvl = meta[META_KEYS.logLevel]; + if (typeof lvl === 'string') out.logLevel = lvl as LoggingLevel; + } + // SEP-2322: inputResponses + requestState live at params-level, not in _meta. + if (params?.inputResponses && typeof params.inputResponses === 'object') { + out.inputResponses = params.inputResponses as InputResponses; + } + if (typeof params?.requestState === 'string') out.requestState = params.requestState; + return out; +} + +/** + * Returns true when the message is a JSON-RPC request whose `_meta` carries a + * stateless protocol version. This is the per-message routing predicate used by + * transport bridges. + */ +export function isStatelessRequest(msg: unknown): msg is JSONRPCRequest { + if (!msg || typeof msg !== 'object' || !('method' in msg) || !('id' in msg)) return false; + const v = parseClientMeta((msg as { params?: { _meta?: Record } }).params).protocolVersion; + return typeof v === 'string' && isStatelessProtocolVersion(v); +} + +/** + * Thrown by a tool/prompt handler running under the stateless dispatch path to + * indicate it needs additional client input (sampling, elicitation, roots) + * before it can produce a final result. The dispatcher catches this and returns + * an `InputRequiredResult`; the client retries with `inputResponses` in params. + * + * Handler code does not normally throw this directly; the `ctx.mcpReq.elicitInput` + * (etc.) helpers throw it when no cached response is available. + */ +export class InputRequiredError extends Error { + constructor( + readonly inputRequests: InputRequests, + readonly requestState?: string + ) { + super('input required'); + this.name = 'InputRequiredError'; + } + + /** + * Returns the top-level client capability names this set of input requests + * requires. Used for the `-32003` cap-gate before returning `InputRequiredResult`. + */ + requiredCapabilities(): string[] { + const caps = new Set(); + for (const r of Object.values(this.inputRequests)) { + switch (r.method) { + case 'sampling/createMessage': { + caps.add('sampling'); + break; + } + case 'elicitation/create': { + caps.add('elicitation'); + break; + } + case 'roots/list': { + caps.add('roots'); + break; + } + } + } + return [...caps]; + } +} + +/** Type guard for {@linkcode InputRequiredError}. */ +export function isInputRequiredError(e: unknown): e is InputRequiredError { + return e instanceof InputRequiredError; +} + +/** + * Per-request context the caller (transport adapter or `handleHttp`) provides + * to the stateless dispatch path. Everything is request-scoped; there is no + * connection state. + */ +export interface DispatchContext { + /** Aborts the handler if the caller disconnects or cancels. */ + signal?: AbortSignal; + /** Validated authorization info from the transport layer (HTTP only). */ + authInfo?: AuthInfo; + /** The original HTTP `Request` (HTTP only). */ + httpRequest?: globalThis.Request; + /** + * Pre-parsed `_meta` for this request. Callers that already ran + * {@linkcode parseClientMeta} (e.g. for validation) pass the result here so + * the server does not parse again. Omit to have it parsed from + * `request.params._meta`. + */ + meta?: ClientMeta; + /** + * Called for each notification the handler emits via `ctx.mcpReq.notify` + * or `ctx.mcpReq.log`. The caller writes these to the response stream + * immediately (real time, not buffered). MUST NOT throw. + */ + notify(notification: JSONRPCNotification): void; +} + +/** + * Per-listen context a transport supplies when opening a `subscriptions/listen` + * stream. Everything is request-scoped. + */ +export interface ListenContext { + /** Validated authorization info from the transport layer (HTTP only). */ + authInfo?: AuthInfo; + /** + * Decides whether the requesting client may subscribe to updates for a + * specific resource URI. If absent, all `resourceSubscriptions` are denied + * (fail-closed). + */ + onAuthorizeResourceSubscription?: (uri: string, ctx: { authInfo?: AuthInfo }) => boolean; +} + +/** + * The result of opening a `subscriptions/listen` stream. The caller iterates + * `stream` (the first message is always `notifications/subscriptions/acknowledged`) + * and calls `close()` on disconnect/abort to release the registration. + */ +export interface ListenStream { + stream: AsyncIterable; + close(): void; +} + +/** + * The two function shapes a server-side transport needs to handle 2026-06 + * stateless requests. `dispatch` is request→response (always short-lived); + * `listen` is request→stream (`subscriptions/listen` only). Installed on the + * transport via {@linkcode Transport.setStatelessHandlers}. + */ +export interface StatelessHandlers { + dispatch(request: JSONRPCRequest, ctx: DispatchContext): Promise; + listen(request: JSONRPCRequest, ctx: ListenContext): ListenStream; +} diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b5..2a372c1009 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 { JSONRPCMessage, JSONRPCRequest, MessageExtraInfo, RequestId } from '../types/index.js'; +import type { StatelessHandlers } from './stateless.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -131,4 +132,26 @@ export interface Transport { * This allows the server to pass its supported versions to the transport. */ setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + + /** + * Server-side. Installs the 2026-06 stateless dispatch and listen handlers + * on this transport. Called by `Server.connect()`. Transports that + * implement this route stateless requests via {@linkcode StatelessHandlers} + * instead of `onmessage`. + */ + setStatelessHandlers?(handlers: StatelessHandlers): void; + + /** + * Client-side. Sends one stateless request and returns an async-iterable of + * the messages the server emits for it (notifications then a response, or a + * stream for `subscriptions/listen`). When implemented, `Client` short- + * circuits `Protocol.request()` for stateless calls. + */ + sendAndReceive?(request: Omit, opts?: SendAndReceiveOptions): AsyncIterable; +} + +/** Options for {@linkcode Transport.sendAndReceive}. */ +export interface SendAndReceiveOptions { + /** Aborts the in-flight request and ends the returned iterable. */ + signal?: AbortSignal; } diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 1766f0c8e5..3c4c2fa95e 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -1,6 +1,17 @@ +export const DRAFT_PROTOCOL_VERSION = 'DRAFT-2026-v1'; export const LATEST_PROTOCOL_VERSION = '2025-11-25'; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; -export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; +// DRAFT at the end so `[0]` (the default-preferred version sent by Client and +// returned by Server fallback) stays the latest released version. The 2026 +// path is opted into via `server/discover` auto-probe, not version preference. +export const SUPPORTED_PROTOCOL_VERSIONS = [ + LATEST_PROTOCOL_VERSION, + '2025-06-18', + '2025-03-26', + '2024-11-05', + '2024-10-07', + DRAFT_PROTOCOL_VERSION +]; /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; diff --git a/packages/core/src/types/enums.ts b/packages/core/src/types/enums.ts index 0d80242a86..f83e0e44ef 100644 --- a/packages/core/src/types/enums.ts +++ b/packages/core/src/types/enums.ts @@ -11,6 +11,8 @@ export enum ProtocolErrorCode { InternalError = -32_603, // MCP-specific error codes + HeaderMismatch = -32_001, ResourceNotFound = -32_002, + MissingRequiredClientCapability = -32_003, UrlElicitationRequired = -32_042 } diff --git a/packages/core/src/util/asyncQueue.ts b/packages/core/src/util/asyncQueue.ts new file mode 100644 index 0000000000..49efa797ed --- /dev/null +++ b/packages/core/src/util/asyncQueue.ts @@ -0,0 +1,78 @@ +/** + * Bounded single-consumer push/pull queue. Producer calls {@linkcode push}; + * consumer reads via {@linkcode iterate} (`for await`). When the queue fills, + * the consumer is closed (slow-consumer eviction). {@linkcode close} ends + * iteration after draining whatever is already queued. + * + * Single-consumer: at most one `iterate()` may be active. Starting a second + * concurrent consumer is undefined behavior. + */ +export class AsyncQueue { + private readonly _queue: T[] = []; + private _waiter?: (r: IteratorResult) => void; + private _closed = false; + + constructor(private readonly _capacity = Infinity) {} + + /** + * Enqueues an item, or hands it directly to a waiting consumer. + * Returns false (and closes the queue) if the capacity bound is hit. + * Returns false if already closed. + */ + push(item: T): boolean { + if (this._closed) return false; + if (this._waiter) { + const w = this._waiter; + this._waiter = undefined; + w({ value: item, done: false }); + return true; + } + if (this._queue.length >= this._capacity) { + this.close(); + return false; + } + this._queue.push(item); + return true; + } + + /** + * Closes the queue. A waiting consumer is released with `done: true`. + * Items already queued remain readable until drained. + */ + close(): void { + if (this._closed) return; + this._closed = true; + if (this._waiter) { + const w = this._waiter; + this._waiter = undefined; + w({ value: undefined as never, done: true }); + } + } + + /** True once {@linkcode close} has been called. */ + get closed(): boolean { + return this._closed; + } + + /** + * Async iterable over pushed items. Yields until {@linkcode close} is called + * and the queue is drained. Breaking out of the loop calls `close()`. + */ + iterate(): AsyncIterableIterator { + const next = (): Promise> => { + if (this._queue.length > 0) { + return Promise.resolve({ value: this._queue.shift() as T, done: false }); + } + if (this._closed) return Promise.resolve({ value: undefined as never, done: true }); + return new Promise(resolve => { + this._waiter = resolve; + }); + }; + const ret = (): Promise> => { + this.close(); + return Promise.resolve({ value: undefined as never, done: true }); + }; + const it: AsyncIterableIterator = { [Symbol.asyncIterator]: () => it, next, return: ret }; + return it; + } +} diff --git a/packages/core/test/shared/stateless.test.ts b/packages/core/test/shared/stateless.test.ts new file mode 100644 index 0000000000..6df7dcb285 --- /dev/null +++ b/packages/core/test/shared/stateless.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + InputRequiredError, + isInputRequiredError, + isStatelessProtocolVersion, + isStatelessRequest, + META_KEYS, + parseClientMeta, + STATEFUL_PROTOCOL_VERSIONS +} from '../../src/shared/stateless.js'; + +describe('isStatelessProtocolVersion', () => { + it('returns false for every stateful version', () => { + for (const v of STATEFUL_PROTOCOL_VERSIONS) { + expect(isStatelessProtocolVersion(v)).toBe(false); + } + }); + + it('returns true for unknown/future versions', () => { + expect(isStatelessProtocolVersion('2026-06-01')).toBe(true); + expect(isStatelessProtocolVersion('draft')).toBe(true); + }); + + it('returns false for empty string', () => { + expect(isStatelessProtocolVersion('')).toBe(false); + }); +}); + +describe('parseClientMeta', () => { + it('extracts namespaced _meta keys and params-level inputResponses/requestState', () => { + const out = parseClientMeta({ + _meta: { + [META_KEYS.protocolVersion]: '2026-06-01', + [META_KEYS.clientCapabilities]: { sampling: {} }, + [META_KEYS.clientInfo]: { name: 'c', version: '1' }, + [META_KEYS.logLevel]: 'debug', + unrelated: 1 + }, + inputResponses: { a: { result: {} } }, + requestState: 'opaque' + }); + expect(out.protocolVersion).toBe('2026-06-01'); + expect(out.clientCapabilities).toEqual({ sampling: {} }); + expect(out.clientInfo).toEqual({ name: 'c', version: '1' }); + expect(out.logLevel).toBe('debug'); + expect(out.inputResponses).toEqual({ a: { result: {} } }); + expect(out.requestState).toBe('opaque'); + }); + + it('returns empty for missing/invalid params', () => { + expect(parseClientMeta(undefined)).toEqual({}); + expect(parseClientMeta({})).toEqual({}); + expect(parseClientMeta({ _meta: undefined })).toEqual({}); + }); + + it('ignores keys with wrong types', () => { + const out = parseClientMeta({ + _meta: { + [META_KEYS.protocolVersion]: 123, + [META_KEYS.clientCapabilities]: 'nope', + [META_KEYS.logLevel]: {} + } as Record, + requestState: 5 + }); + expect(out).toEqual({}); + }); +}); + +describe('isStatelessRequest', () => { + it('detects requests with stateless protocolVersion in _meta', () => { + expect( + isStatelessRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { [META_KEYS.protocolVersion]: '2026-06-01' } } + }) + ).toBe(true); + }); + + it('rejects stateful versions and notifications', () => { + expect( + isStatelessRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { [META_KEYS.protocolVersion]: '2025-06-18' } } + }) + ).toBe(false); + expect(isStatelessRequest({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBe(false); + expect(isStatelessRequest(null)).toBe(false); + }); +}); + +describe('InputRequiredError', () => { + it('reports required capabilities by method', () => { + const e = new InputRequiredError({ + a: { method: 'sampling/createMessage', params: {} as never }, + b: { method: 'elicitation/create', params: {} as never }, + c: { method: 'roots/list', params: {} } + }); + expect(new Set(e.requiredCapabilities())).toEqual(new Set(['sampling', 'elicitation', 'roots'])); + }); + + it('isInputRequiredError matches by instanceof only', () => { + const e = new InputRequiredError({}); + expect(isInputRequiredError(e)).toBe(true); + // Structural look-alikes are not matched (avoids false positives on + // objects lacking the prototype method). + const lookAlike = Object.assign(new Error('x'), { name: 'InputRequiredError', inputRequests: {} }); + expect(isInputRequiredError(lookAlike)).toBe(false); + expect(isInputRequiredError(new Error('x'))).toBe(false); + }); +}); diff --git a/packages/core/test/util/asyncQueue.test.ts b/packages/core/test/util/asyncQueue.test.ts new file mode 100644 index 0000000000..649d6742f9 --- /dev/null +++ b/packages/core/test/util/asyncQueue.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { AsyncQueue } from '../../src/util/asyncQueue.js'; + +describe('AsyncQueue', () => { + it('delivers pushed items to a waiting consumer', async () => { + const q = new AsyncQueue(); + const p = (async () => { + const out: number[] = []; + for await (const v of q.iterate()) { + out.push(v); + if (out.length === 3) break; + } + return out; + })(); + q.push(1); + q.push(2); + q.push(3); + expect(await p).toEqual([1, 2, 3]); + }); + + it('queues items pushed before consumer starts', async () => { + const q = new AsyncQueue(); + q.push(1); + q.push(2); + const out: number[] = []; + for await (const v of q.iterate()) { + out.push(v); + if (out.length === 2) break; + } + expect(out).toEqual([1, 2]); + }); + + it('close() ends iteration after draining queued items', async () => { + const q = new AsyncQueue(); + q.push(1); + q.push(2); + q.close(); + const out: number[] = []; + for await (const v of q.iterate()) out.push(v); + expect(out).toEqual([1, 2]); + }); + + it('close() releases a waiting consumer', async () => { + const q = new AsyncQueue(); + const p = (async () => { + for await (const _ of q.iterate()) { + throw new Error('should not yield'); + } + return 'done'; + })(); + q.close(); + expect(await p).toBe('done'); + }); + + it('push() after close() returns false and item is dropped', async () => { + const q = new AsyncQueue(); + q.close(); + expect(q.push(1)).toBe(false); + const out: number[] = []; + for await (const v of q.iterate()) out.push(v); + expect(out).toEqual([]); + }); + + it('hitting capacity closes the queue (slow-consumer eviction)', async () => { + const q = new AsyncQueue(2); + expect(q.push(1)).toBe(true); + expect(q.push(2)).toBe(true); + expect(q.push(3)).toBe(false); + expect(q.closed).toBe(true); + const out: number[] = []; + for await (const v of q.iterate()) out.push(v); + expect(out).toEqual([1, 2]); + }); + + it('breaking out of for-await closes the queue', async () => { + const q = new AsyncQueue(); + q.push(1); + for await (const _ of q.iterate()) break; + expect(q.closed).toBe(true); + expect(q.push(2)).toBe(false); + }); +}); diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f0..c3a6be77d0 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, StatelessHandlers, Transport } from '@modelcontextprotocol/core'; import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; @@ -130,6 +130,14 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.onmessage; } + /** + * Installed by `Server.connect()`. Forwards to the wrapped web-standard + * transport so its `handleRequest` router can dispatch 2026-06 requests. + */ + setStatelessHandlers(h: StatelessHandlers): void { + this._webStandardTransport.setStatelessHandlers(h); + } + /** * 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 c33d394c8b..9949f610a8 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,8 @@ export type { CompletableSchema, CompleteCallback } from './server/completable.js'; export { completable, isCompletable } from './server/completable.js'; +export type { HandleHttpOptions } from './server/handleHttp.js'; +export { handleHttp } from './server/handleHttp.js'; export type { AnyToolHandler, BaseToolCallback, @@ -28,6 +30,10 @@ export type { HostHeaderValidationResult } from './server/middleware/hostHeaderV export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; export type { ServerOptions } from './server/server.js'; export { Server } from './server/server.js'; +export type { StatelessHttpRequestOptions } from './server/statelessHttp.js'; +export { statelessHttpHandler } from './server/statelessHttp.js'; +export type { SubscriptionBackend, SubscriptionEvent } from './server/subscriptions.js'; +export { InMemorySubscriptions } from './server/subscriptions.js'; // StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node // imports (erased at compile time), but matching the client's `./stdio` subpath gives consumers a // consistent shape across packages. diff --git a/packages/server/src/server/handleHttp.ts b/packages/server/src/server/handleHttp.ts new file mode 100644 index 0000000000..a9ab4861db --- /dev/null +++ b/packages/server/src/server/handleHttp.ts @@ -0,0 +1,71 @@ +import { ProtocolErrorCode } from '@modelcontextprotocol/core'; + +import { validateHostHeader } from './middleware/hostHeaderValidation.js'; +import type { Server } from './server.js'; +import type { StatelessHttpRequestOptions } from './statelessHttp.js'; +import { jsonError, statelessHttpHandler } from './statelessHttp.js'; + +/** Options for {@linkcode handleHttp}. */ +export interface HandleHttpOptions { + /** + * Hostnames to accept in the `Host` header (DNS-rebinding guard). When set, requests from other hosts are rejected with 403. + * Port-agnostic; for IPv6 include brackets (e.g. `[::1]`). Same convention as {@linkcode validateHostHeader}. + */ + allowedHosts?: string[]; + /** Origin header values to accept. When set, requests from other origins are rejected with 403. */ + allowedOrigins?: string[]; + /** Maximum POST body size. Default 4 MiB. */ + maxBodyBytes?: number; + /** Called once per request to validate authorization. May throw or return `Response` to short-circuit. */ + auth?: (req: Request) => Promise; + /** See {@linkcode StatelessHttpRequestOptions.onAuthorizeResourceSubscription}. */ + onAuthorizeResourceSubscription?: StatelessHttpRequestOptions['onAuthorizeResourceSubscription']; +} + +/** + * 2026-06 Fetch-API HTTP entry. Returns a `(Request) → Promise` + * handler that dispatches via the server's {@linkcode Server.dispatcher} and + * {@linkcode Server.subscriptions}. No `Transport` instance, no `connect()`, + * no per-connection state; one server instance can be shared across requests. + * + * Pre-2026 clients are NOT served by this entry (use the + * `WebStandardStreamableHTTPServerTransport` router for both eras, or compose + * both behind your own router). + * + * @example + * ```ts + * const mcp = new McpServer({ name: 'srv', version: '1.0.0' }); + * mcp.registerTool('echo', { ... }, async ({ text }) => ({ content: [{ type: 'text', text }] })); + * app.all('/mcp', (req) => handleHttp(mcp.server)(req)); + * ``` + */ +export function handleHttp(server: Server, opts?: HandleHttpOptions): (req: Request) => Promise { + const handlers = server.statelessHandlers(); + return async req => { + // DNS-rebinding / origin checks BEFORE auth (do not invoke user auth + // callback for requests from forbidden hosts/origins). + if (opts?.allowedHosts) { + const result = validateHostHeader(req.headers.get('host'), opts.allowedHosts); + if (!result.ok) { + return jsonError(403, ProtocolErrorCode.InvalidRequest, `Forbidden: ${result.message}`, null); + } + } + const origin = req.headers.get('origin'); + if (opts?.allowedOrigins && (!origin || !opts.allowedOrigins.includes(origin))) { + return jsonError(403, ProtocolErrorCode.InvalidRequest, 'Forbidden: missing or invalid Origin header', null); + } + + let authInfo: StatelessHttpRequestOptions['authInfo']; + if (opts?.auth) { + const a = await opts.auth(req); + if (a instanceof Response) return a; + authInfo = a; + } + + return statelessHttpHandler(handlers, req, { + authInfo, + maxBodyBytes: opts?.maxBodyBytes, + onAuthorizeResourceSubscription: opts?.onAuthorizeResourceSubscription + }); + }; +} diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index b3fb54813e..67253c46fc 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -26,6 +26,7 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + isInputRequiredError, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -154,6 +155,11 @@ export class McpServer { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult } + if (isInputRequiredError(error)) { + // MRTR: re-throw so inputRequiredMiddleware translates to InputRequiredResult + // (rather than swallowing into an isError:true CallToolResult). + throw error; + } return this.createToolError(error instanceof Error ? error.message : String(error)); } }); @@ -926,30 +932,30 @@ export class McpServer { return this.server.sendLoggingMessage(params, sessionId); } /** - * Sends a resource list changed event to the client, if connected. + * Sends a resource list changed event. Delivered to a connected pre-2026 + * client (if any) and fanned out to `subscriptions/listen` listeners via + * `Server._fanoutNotify`, so this works in pure-stateless deployments too. */ sendResourceListChanged() { - if (this.isConnected()) { - this.server.sendResourceListChanged(); - } + void this.server.sendResourceListChanged(); } /** - * Sends a tool list changed event to the client, if connected. + * Sends a tool list changed event. Delivered to a connected pre-2026 + * client (if any) and fanned out to `subscriptions/listen` listeners via + * `Server._fanoutNotify`, so this works in pure-stateless deployments too. */ sendToolListChanged() { - if (this.isConnected()) { - this.server.sendToolListChanged(); - } + void this.server.sendToolListChanged(); } /** - * Sends a prompt list changed event to the client, if connected. + * Sends a prompt list changed event. Delivered to a connected pre-2026 + * client (if any) and fanned out to `subscriptions/listen` listeners via + * `Server._fanoutNotify`, so this works in pure-stateless deployments too. */ sendPromptListChanged() { - if (this.isConnected()) { - this.server.sendPromptListChanged(); - } + void this.server.sendPromptListChanged(); } } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 55d8a44b17..7d7451c110 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,24 +1,33 @@ import type { BaseContext, ClientCapabilities, + ClientMeta, CreateMessageRequest, CreateMessageRequestParamsBase, CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + DiscoverResult, + DispatchContext, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, Implementation, InitializeRequest, InitializeResult, + InputRequest, + JSONRPCErrorResponse, + JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, + ListRootsResult, LoggingLevel, LoggingMessageNotification, MessageExtraInfo, Middleware, + Notification, NotificationMethod, NotificationOptions, ProtocolOptions, @@ -27,8 +36,10 @@ import type { ResourceUpdatedNotification, ServerCapabilities, ServerContext, + StatelessHandlers, ToolResultContent, - ToolUseContent + ToolUseContent, + Transport } from '@modelcontextprotocol/core'; import { CallToolRequestSchema, @@ -37,19 +48,184 @@ import { CreateMessageResultWithToolsSchema, ElicitResultSchema, EmptyResultSchema, + errorResponse, + InputRequiredError, + isInputRequiredError, + isStatelessProtocolVersion, + JSONRPC_VERSION, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, + META_KEYS, + parseClientMeta, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, - SdkErrorCode + SdkErrorCode, + STATELESS_REMOVED_METHODS, + SubscriptionsListenRequestSchema } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import type { SubscriptionBackend } from './subscriptions.js'; +import { InMemorySubscriptions } from './subscriptions.js'; + +const LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); + +/** + * Returns true when `level` is at least as severe as `threshold`. + * Lower index in {@linkcode LoggingLevelSchema}.options is more verbose. + */ +function severityAtLeast(level: LoggingLevel, threshold: LoggingLevel): boolean { + return (LOG_LEVEL_SEVERITY.get(level) ?? 0) >= (LOG_LEVEL_SEVERITY.get(threshold) ?? 0); +} + +/** + * Throws if the given sampling params require a client sub-capability the + * client did not declare. Shared by the legacy `createMessage` path and the + * stateless `ctx.mcpReq.requestSampling` path so the handler-facing call has + * the same semantics under both protocols. + */ +function assertSamplingCapability(params: CreateMessageRequest['params'], clientCapabilities: ClientCapabilities | undefined): void { + if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); + } +} + +/** + * Validates `tool_use`/`tool_result` pairing in sampling messages. These may + * appear even without `tools`/`toolChoice` in the current request when a prior + * sampling request returned `tool_use` and this is a follow-up with results. + */ +function assertSamplingMessagePairing(params: CreateMessageRequest['params']): void { + if (params.messages.length === 0) return; + 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' + ); + } + } +} + +/** + * Throws if the given elicitation params require a client sub-capability the + * client did not declare. Shared by the legacy `elicitInput` path and the + * stateless `ctx.mcpReq.elicitInput` path. + */ +function assertElicitCapability( + params: ElicitRequestFormParams | ElicitRequestURLParams, + clientCapabilities: ClientCapabilities | undefined +): void { + const mode = (params.mode ?? 'form') as 'form' | 'url'; + if (mode === 'url' && !clientCapabilities?.elicitation?.url) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); + } + if (mode === 'form' && !clientCapabilities?.elicitation?.form) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); + } +} + +/** + * Validates a form-mode elicitation result's `content` against the request's + * `requestedSchema`. Throws on schema or validation failure. + */ +function validateElicitFormContent( + validator: jsonSchemaValidator, + params: ElicitRequestFormParams | ElicitRequestURLParams, + result: ElicitResult +): void { + const mode = params.mode ?? 'form'; + if (mode !== 'form' || result.action !== 'accept' || !result.content) return; + const formParams = params as ElicitRequestFormParams; + if (!formParams.requestedSchema) return; + try { + const v = validator.getValidator(formParams.requestedSchema as JsonSchemaType); + const validationResult = v(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)}` + ); + } +} + +/** + * Dispatcher middleware that catches {@linkcode InputRequiredError}, gates + * against `ctx.clientCapabilities`, and translates to an `InputRequiredResult`. + * + * Runs on both dispatch paths but only the stateless ctx-builder installs the + * MRTR `elicitInput`/`requestSampling` shims that throw `InputRequiredError`; + * legacy `ctx.mcpReq.elicitInput`/`requestSampling` send real requests, so the + * catch never fires on the legacy path. + * + * MRTR via {@linkcode InputRequiredError} works for handlers registered via + * `setRequestHandler`; `fallbackRequestHandler` is not wrapped by middleware + * (matches pre-existing behavior). + */ +const inputRequiredMiddleware: Middleware = async (_request, ctx, next) => { + try { + return await next(); + } catch (error) { + if (!isInputRequiredError(error)) throw error; + const caps = ctx.clientCapabilities as Record | undefined; + const missing = error.requiredCapabilities().filter(c => !caps?.[c]); + if (missing.length > 0) { + // Spec: data.requiredCapabilities is a (partial) ClientCapabilities + // object, not a string array. + const requiredCapabilities: ClientCapabilities = {}; + for (const c of missing) (requiredCapabilities as Record)[c] = {}; + throw new ProtocolError(ProtocolErrorCode.MissingRequiredClientCapability, 'Missing required client capability', { + requiredCapabilities + }); + } + return { + resultType: 'input_required', + inputRequests: error.inputRequests, + ...(error.requestState === undefined ? {} : { requestState: error.requestState }) + }; + } +}; + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -70,6 +246,12 @@ export type ServerOptions = ProtocolOptions & { * @default {@linkcode DefaultJsonSchemaValidator} ({@linkcode index.AjvJsonSchemaValidator | AjvJsonSchemaValidator} on Node.js, `CfWorkerJsonSchemaValidator` on Cloudflare Workers) */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Backend for `subscriptions/listen`. Defaults to {@linkcode InMemorySubscriptions}. + * Supply a distributed implementation for horizontally-scaled deployments. + */ + subscriptions?: SubscriptionBackend; }; /** @@ -88,6 +270,8 @@ export class Server extends Protocol { /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). + * + * @deprecated A 2026-06 client never sends `notifications/initialized`. This callback fires only when a pre-2026 client connects. */ oninitialized?: () => void; @@ -102,10 +286,13 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this.subscriptions = options?.subscriptions ?? new InMemorySubscriptions(); + this.dispatcher.use(inputRequiredMiddleware); this.dispatcher.use(Server._callToolResultMiddleware); this.setRequestHandler('initialize', request => this._oninitialize(request)); + this.setRequestHandler('server/discover', async (): Promise => this._ondiscover()); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); if (this._capabilities.logging) { @@ -113,6 +300,245 @@ export class Server extends Protocol { } } + // ═══════════════════════════════════════════════════════════════════════ + // 2026 stateless (SEP-2575/2322) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Backend for `subscriptions/listen`. Default in-memory; pass via + * `ServerOptions.subscriptions` for distributed deployments. + */ + readonly subscriptions: SubscriptionBackend; + + /** + * Builds the {@linkcode StatelessHandlers} pair this server provides to + * transports (via `setStatelessHandlers`) and to `handleHttp`. + */ + statelessHandlers(): StatelessHandlers { + return { + dispatch: (req, ctx) => this._dispatchStateless(req, ctx), + listen: (req, ctx) => + this.subscriptions.handle({ ...SubscriptionsListenRequestSchema.parse(req), id: req.id }, ctx, this._capabilities) + }; + } + + /** + * server/discover handler. Returns this server's identity, capabilities, + * and supported protocol versions. + */ + private _ondiscover(): DiscoverResult { + return { + supportedVersions: [...this._supportedProtocolVersions], + capabilities: this._capabilities, + serverInfo: this._serverInfo, + ...(this._instructions === undefined ? {} : { instructions: this._instructions }) + }; + } + + /** + * Dispatches one stateless JSON-RPC request and returns its response. + * + * Builds a per-request `ServerContext` from {@linkcode DispatchContext} + + * the request's `_meta` (notify/log via `dctx.notify`; `send` throws; + * `elicitInput`/`requestSampling` are MRTR throw-then-cache), then routes + * through the shared {@linkcode Dispatcher} so the same registry and + * middleware chain as `_onrequest` apply. Pre-2026-only methods are + * rejected before the dispatcher. + */ + private async _dispatchStateless(request: JSONRPCRequest, dctx: DispatchContext): Promise { + const id = request.id; + const meta = dctx.meta ?? parseClientMeta(request.params); + + if (meta.protocolVersion !== undefined && !this._supportedProtocolVersions.includes(meta.protocolVersion)) { + return errorResponse(id, ProtocolErrorCode.InvalidParams, 'Unsupported protocol version', { + supported: [...this._supportedProtocolVersions], + requested: meta.protocolVersion + }); + } + + if (STATELESS_REMOVED_METHODS.has(request.method)) { + return errorResponse(id, ProtocolErrorCode.MethodNotFound, `Method not found: '${request.method}'`); + } + + const ctx = this._buildDispatchServerContext(request, dctx, meta); + + const response = await this.dispatcher.dispatch(request, ctx); + // Default resultType:'complete' on success, but never on server/discover + // (DiscoverResult has no resultType field). + if ( + request.method !== 'server/discover' && + 'result' in response && + (response.result as { resultType?: unknown }).resultType === undefined + ) { + return { ...response, result: { ...response.result, resultType: 'complete' } }; + } + return response; + } + + /** + * Builds the `ServerContext` handlers receive under the stateless dispatch + * path. `notify`/`log` go out via `dctx.notify`; `send` throws (no push + * channel under stateless); `elicitInput`/`requestSampling`/`listRoots` + * are MRTR throw-then-cache against `params.inputResponses`. + */ + private _buildDispatchServerContext(request: JSONRPCRequest, dctx: DispatchContext, per: ClientMeta): ServerContext { + let mrtrSeq = 0; + const mrtrOrThrow = (method: string, params: unknown, schema: { parse(v: unknown): R }, after?: (r: R) => void): Promise => { + const key = `${method}#${mrtrSeq++}`; + const cached = per.inputResponses?.[key]; + if (cached !== undefined) { + // Validate the cached value: do not return raw client input. + let parsed: R; + try { + parsed = schema.parse(cached); + } catch (error) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `inputResponses['${key}'] does not match expected schema: ${error instanceof Error ? error.message : String(error)}` + ); + } + after?.(parsed); + return Promise.resolve(parsed); + } + throw new InputRequiredError({ [key]: { method, params } as InputRequest }); + }; + + const notify = (n: Notification): Promise => { + // Stamp `_meta.subscriptionId` (= the JSON-RPC request id, per + // SEP-2575) so notifications correlate to this request on + // pipe-shaped client transports that demultiplex a single inbound + // stream. Handler-supplied `_meta` first, server-stamped key last, + // so a handler cannot override the framing key. + const _meta = { ...n.params?._meta, [META_KEYS.subscriptionId]: String(request.id) }; + const params = { ...n.params, _meta }; + dctx.notify({ jsonrpc: JSONRPC_VERSION, method: n.method, params }); + return Promise.resolve(); + }; + + const sendThrows = (() => { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + 'Server-to-client requests are not available under the stateless dispatch path; use ctx.mcpReq.elicitInput/requestSampling (MRTR).' + ); + }) as ServerContext['mcpReq']['send']; + + return { + sessionId: undefined, + clientCapabilities: per.clientCapabilities, + mcpReq: { + id: request.id, + method: request.method, + _meta: request.params?._meta, + signal: dctx.signal ?? new AbortController().signal, + send: sendThrows, + notify, + log: async (level, data, logger) => { + // Spec: server MUST NOT emit notifications/message for + // requests that did not include _meta.logLevel. + if (per.logLevel === undefined || !severityAtLeast(level, per.logLevel)) return; + await notify({ method: 'notifications/message', params: { level, data, ...(logger === undefined ? {} : { logger }) } }); + }, + listRoots: params => mrtrOrThrow('roots/list', params ?? {}, ListRootsResultSchema), + elicitInput: params => { + // Sub-capability (form/url) check only when the top-level + // `elicitation` capability is declared. Absent top-level is + // handled by `inputRequiredMiddleware` (-32003) so the wire + // error code matches SEP-2322. + if (per.clientCapabilities?.elicitation) assertElicitCapability(params, per.clientCapabilities); + return mrtrOrThrow('elicitation/create', params, ElicitResultSchema, result => + validateElicitFormContent(this._jsonSchemaValidator, params, result) + ); + }, + // Cast: arrow has the implementation signature (union return); + // narrowing is provided by the overload set on the field type. + requestSampling: ((params: CreateMessageRequest['params']) => { + if (per.clientCapabilities?.sampling) assertSamplingCapability(params, per.clientCapabilities); + assertSamplingMessagePairing(params); + return mrtrOrThrow( + 'sampling/createMessage', + params, + params.tools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema + ); + }) as ServerContext['mcpReq']['requestSampling'] + }, + http: + dctx.authInfo !== undefined || dctx.httpRequest !== undefined + ? { authInfo: dctx.authInfo, req: dctx.httpRequest } + : undefined + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // dual-mode (SEP-2575/2567) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Connects this server to a transport. Installs the stateless + * {@linkcode statelessHandlers | dispatch/listen} pair on transports that + * support per-message routing (`setStatelessHandlers` is optional on the + * `Transport` interface), then starts the legacy `Protocol` connect path + * so pre-2026 clients also work over the same transport. + */ + override async connect(transport: Transport): Promise { + // Install stateless handlers before starting the transport so the + // first message cannot arrive before the router is wired. + transport.setStatelessHandlers?.(this.statelessHandlers()); + await super.connect(transport); + } + + /** + * Runs `subscriptions.notify` and the legacy `notification()` concurrently. + * A subscription-backend rejection does not block legacy delivery (and is + * surfaced via `onerror`); a legacy rejection (cap-missing, send fail) is + * rethrown so existing callers see the same errors as before. + */ + private async _fanoutNotify(event: Parameters[0], legacy: () => Promise): Promise { + const [sub, leg] = await Promise.allSettled([this.subscriptions.notify(event), this.transport ? legacy() : Promise.resolve()]); + if (sub.status === 'rejected') { + this.onerror?.(sub.reason instanceof Error ? sub.reason : new Error(String(sub.reason))); + } + if (leg.status === 'rejected') throw leg.reason; + } + + async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { + await this._fanoutNotify({ type: 'resourceUpdated', uri: params.uri }, () => + this.notification({ method: 'notifications/resources/updated', params }) + ); + } + + async sendResourceListChanged() { + await this._fanoutNotify({ type: 'resourcesListChanged' }, () => + this.notification({ method: 'notifications/resources/list_changed' }) + ); + } + + async sendToolListChanged() { + await this._fanoutNotify({ type: 'toolsListChanged' }, () => this.notification({ method: 'notifications/tools/list_changed' })); + } + + async sendPromptListChanged() { + await this._fanoutNotify({ type: 'promptsListChanged' }, () => this.notification({ method: 'notifications/prompts/list_changed' })); + } + + // ═══════════════════════════════════════════════════════════════════════ + // session-dependent (existing — bodies unchanged) + // + // These top-level methods need a connected pre-2026 client (initialize + // handshake). The same capability is available per-request via + // ctx.mcpReq.* / ctx.clientCapabilities under both protocols. + // See _buildDispatchServerContext for the 2026 ctx shape. + // + // _oninitialize — 2026 equiv: _ondiscover + // createMessage — 2026 equiv: ctx.mcpReq.requestSampling (MRTR) + // elicitInput — 2026 equiv: ctx.mcpReq.elicitInput (MRTR) + // listRoots — 2026 equiv: ctx.mcpReq.listRoots (MRTR) + // sendLoggingMessage — 2026 equiv: ctx.mcpReq.log + // getClientCapabilities — 2026 equiv: ctx.clientCapabilities (per-request from _meta) + // getClientVersion — 2026 equiv: ctx.mcpReq._meta clientInfo + // ping — removed by 2026 spec (STATELESS_REMOVED_METHODS) + // buildContext — legacy ctx builder; _buildDispatchServerContext is 2026's + // ═══════════════════════════════════════════════════════════════════════ + private _registerLoggingHandler(): void { this.setRequestHandler('logging/setLevel', async (request, ctx) => { const transportSessionId: string | undefined = @@ -131,11 +557,13 @@ export class Server extends Protocol { const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; return { ...ctx, + clientCapabilities: this._clientCapabilities, 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) + requestSampling: this.createMessage.bind(this), + listRoots: (params, options) => this.listRoots(params, options) }, http: hasHttpInfo ? { @@ -151,13 +579,10 @@ export class Server extends Protocol { // 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; + return currentLevel !== undefined && !severityAtLeast(level, currentLevel); }; /** @@ -349,9 +774,12 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + // The legacy `initialize` handshake never agrees on a stateless (2026+) + // version: a client that wants 2026 sends `server/discover`, not this. + const legacySupported = this._supportedProtocolVersions.filter(v => !isStatelessProtocolVersion(v)); + const protocolVersion = legacySupported.includes(requestedVersion) ? requestedVersion - : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); + : (legacySupported[0] ?? LATEST_PROTOCOL_VERSION); this.transport?.setProtocolVersion?.(protocolVersion); @@ -365,6 +793,8 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with the client's reported capabilities. + * + * @deprecated Use `ctx.clientCapabilities` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ getClientCapabilities(): ClientCapabilities | undefined { return this._clientCapabilities; @@ -372,6 +802,8 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with information about the client's name and version. + * + * @deprecated Use `ctx.mcpReq._meta?.['io.modelcontextprotocol/clientInfo']` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ getClientVersion(): Implementation | undefined { return this._clientVersion; @@ -384,6 +816,9 @@ export class Server extends Protocol { return this._capabilities; } + /** + * @deprecated `ping` is removed in the 2026-06 protocol. This top-level form requires a pre-2026 connection. + */ async ping() { return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); } @@ -391,18 +826,24 @@ export class Server extends Protocol { /** * Request LLM sampling from the client (without tools). * Returns single content block for backwards compatibility. + * + * @deprecated Use `ctx.mcpReq.requestSampling(params)` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ 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). + * + * @deprecated Use `ctx.mcpReq.requestSampling(params)` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ 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. + * + * @deprecated Use `ctx.mcpReq.requestSampling(params)` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ async createMessage( params: CreateMessageRequest['params'], @@ -414,54 +855,8 @@ export class Server extends Protocol { 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' - ); - } - } - } + assertSamplingCapability(params, this._clientCapabilities); + assertSamplingMessagePairing(params); // Use different schemas based on whether tools are provided if (params.tools) { @@ -476,24 +871,18 @@ export class Server extends Protocol { * @param params The parameters for the elicitation request. * @param options Optional request options. * @returns The result of the elicitation request. + * @deprecated Use `ctx.mcpReq.elicitInput(params)` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + assertElicitCapability(params, this._clientCapabilities); 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' }; @@ -503,27 +892,7 @@ export class Server extends Protocol { 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)}` - ); - } - } + validateElicitFormContent(this._jsonSchemaValidator, formParams, result); return result; } } @@ -557,6 +926,9 @@ export class Server extends Protocol { ); } + /** + * @deprecated Use `ctx.mcpReq.listRoots()` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. + */ async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); } @@ -567,31 +939,11 @@ export class Server extends Protocol { * @see {@linkcode LoggingMessageNotification} * @param params * @param sessionId Optional for stateless transports and backward compatibility. + * @deprecated Use `ctx.mcpReq.log(level, data, logger?)` inside a handler. Works under both protocols. This top-level form requires a pre-2026 connection. */ async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { 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' }); - } } diff --git a/packages/server/src/server/statelessHttp.ts b/packages/server/src/server/statelessHttp.ts new file mode 100644 index 0000000000..1a63af1c43 --- /dev/null +++ b/packages/server/src/server/statelessHttp.ts @@ -0,0 +1,363 @@ +import type { + AuthInfo, + ClientMeta, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCRequest, + ListenContext, + StatelessHandlers +} from '@modelcontextprotocol/core'; +import { + isJSONRPCNotification, + isJSONRPCRequest, + isStatelessProtocolVersion, + JSONRPC_VERSION, + META_KEYS, + parseClientMeta, + ProtocolErrorCode +} from '@modelcontextprotocol/core'; + +/** Per-request options the framework adapter may supply. */ +export interface StatelessHttpRequestOptions { + authInfo?: AuthInfo; + /** Pre-parsed body (skips reading from `req`). */ + parsedBody?: unknown; + /** See {@linkcode ListenContext.onAuthorizeResourceSubscription}. */ + onAuthorizeResourceSubscription?: ListenContext['onAuthorizeResourceSubscription']; +} + +const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; +const MAX_BATCH_SIZE = 64; + +/** + * Handles one 2026-06 HTTP request via the supplied {@linkcode StatelessHandlers}. + * Shared by `WebStandardStreamableHTTPServerTransport`'s router branch and the + * standalone `handleHttp` entry. + * + * - POST only (GET/DELETE → 405). + * - `subscriptions/listen` → SSE stream from `handlers.listen`; rejected if + * inside a batch (`-32600`). + * - Otherwise: per-request `handlers.dispatch`. Batch requests are dispatched + * in parallel; each request's `_meta` is parsed independently. + * - SSE if `Accept` includes `text/event-stream`; else JSON. + * - HTTP status reflects the single-response error code (404 for `-32601`, + * 400 for `InvalidParams`/`HeaderMismatch`/`-32003`, 500 for `InternalError`). + */ +export async function statelessHttpHandler( + handlers: StatelessHandlers, + req: Request, + options?: StatelessHttpRequestOptions & { maxBodyBytes?: number } +): Promise { + if (req.method !== 'POST') { + return jsonError(405, ProtocolErrorCode.InvalidRequest, 'Method Not Allowed (stateless server accepts POST only)', null, { + Allow: 'POST' + }); + } + + // CSRF barrier: a same-origin policy lets pages POST cross-origin only with + // a "simple" Content-Type (form/text); requiring application/json forces a + // preflight. Exact media-type match (parameters like `; charset=utf-8` are + // stripped); avoids substring false-positives like `application/jsonp`. + // Checked even when `parsedBody` is supplied so framework adapters that + // pre-parse do not silently bypass the barrier. + const ct = (req.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase(); + if (ct !== 'application/json') { + return jsonError(415, ProtocolErrorCode.InvalidRequest, 'Unsupported Media Type: Content-Type must be application/json', null); + } + + const acceptsSse = (req.headers.get('accept') ?? '').includes('text/event-stream'); + const headerVersion = req.headers.get('mcp-protocol-version'); + + let body: unknown; + if (options?.parsedBody === undefined) { + // Always use the bounded streaming reader; never trust Content-Length + // (a forged-small CL would otherwise bypass the limit via req.json()). + const text = await readBoundedText(req, options?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES); + if (text === undefined) { + return jsonError(413, ProtocolErrorCode.InvalidRequest, 'Request body too large', null); + } + try { + body = JSON.parse(text); + } catch { + return jsonError(400, ProtocolErrorCode.ParseError, 'Parse error', null); + } + } else { + body = options.parsedBody; + } + + const messages: unknown[] = Array.isArray(body) ? body : [body]; + if (messages.length === 0) { + // JSON-RPC 2.0 §6: an empty batch is an invalid request. + return jsonError(400, ProtocolErrorCode.InvalidRequest, 'Invalid Request: empty batch', null); + } + + if (messages.length > MAX_BATCH_SIZE) { + return jsonError(400, ProtocolErrorCode.InvalidRequest, `Batch too large (max ${MAX_BATCH_SIZE})`, null); + } + + // Reject any message that is neither a JSON-RPC request nor a notification + // (e.g., a stray response object) so it does not silently fall through to + // an empty-batch dispatch. + for (const m of messages) { + if (!isJSONRPCRequest(m) && !isJSONRPCNotification(m)) { + return jsonError(400, ProtocolErrorCode.InvalidRequest, 'Invalid Request: not a JSON-RPC request or notification', null); + } + } + + // Validate _meta on every request (per-request, not batch-first-only) and + // keep the parsed result so dispatch does not parse again. Per JSON-RPC 2.0 + // batch semantics, an invalid item produces an error response at that index + // rather than failing the whole batch. + const requests: Array<{ r: JSONRPCRequest; meta: ClientMeta } | { r: JSONRPCRequest; error: JSONRPCErrorResponse }> = []; + for (const r of messages.filter(m => isJSONRPCRequest(m))) { + const meta = parseClientMeta(r.params); + const err = validateRequestMeta(meta, headerVersion); + if (err) { + requests.push({ r, error: { jsonrpc: JSONRPC_VERSION, id: r.id, error: err } }); + } else { + requests.push({ r, meta }); + } + } + // Single non-batch request with invalid _meta: keep the existing 400 + single + // error-object behavior so clients can rely on the HTTP status. + if (!Array.isArray(body) && requests.length === 1 && 'error' in requests[0]!) { + const { error } = requests[0].error; + return jsonError(statusForCode(error.code), error.code, error.message, requests[0].r.id); + } + const isNotificationOnly = requests.length === 0 && messages.every(m => isJSONRPCNotification(m)); + + // subscriptions/listen owns the response stream — cannot share a batch. + const listen = requests.find(({ r }) => r.method === 'subscriptions/listen'); + if (listen !== undefined) { + if (requests.length > 1 || messages.length > 1) { + return jsonError( + 400, + ProtocolErrorCode.InvalidRequest, + 'subscriptions/listen cannot be batched with other messages', + listen.r.id + ); + } + if ('error' in listen) { + const { error } = listen.error; + return jsonError(statusForCode(error.code), error.code, error.message, listen.r.id); + } + if (!acceptsSse) { + return jsonError(406, ProtocolErrorCode.InvalidRequest, 'subscriptions/listen requires Accept: text/event-stream', listen.r.id); + } + try { + const { stream, close } = handlers.listen(listen.r, { + authInfo: options?.authInfo, + onAuthorizeResourceSubscription: options?.onAuthorizeResourceSubscription + }); + return sseResponse(stream, { signal: req.signal, onCancel: close }); + } catch (error) { + return jsonError( + 400, + ProtocolErrorCode.InvalidParams, + error instanceof Error ? error.message : 'Invalid listen request', + listen.r.id + ); + } + } + + if (isNotificationOnly) { + // Spec: server returns 202 for notification-only POSTs. + return new Response(null, { status: 202 }); + } + + if (acceptsSse) { + return sseDispatch(handlers, requests, req, options); + } + + // JSON response: dispatch in parallel; collect responses. + const responses = await Promise.all( + requests.map(item => + 'error' in item + ? Promise.resolve(item.error) + : handlers.dispatch(item.r, { + signal: req.signal, + authInfo: options?.authInfo, + httpRequest: req, + meta: item.meta, + notify: () => { + /* JSON branch cannot deliver notifications; spec allows dropping. */ + } + }) + ) + ); + const single = !Array.isArray(body) && responses.length === 1 ? responses[0] : undefined; + const status = single && 'error' in single ? statusForCode(single.error.code) : 200; + return Response.json(single ?? responses, { status, headers: { 'Content-Type': 'application/json' } }); +} + +function validateRequestMeta(meta: ClientMeta, headerVersion: string | null): { code: number; message: string } | undefined { + if (meta.protocolVersion === undefined) { + return { code: ProtocolErrorCode.InvalidParams, message: `Missing required _meta.${META_KEYS.protocolVersion}` }; + } + if (!isStatelessProtocolVersion(meta.protocolVersion)) { + return { code: ProtocolErrorCode.InvalidParams, message: `'_meta.protocolVersion' is not a stateless version` }; + } + if (headerVersion !== null && headerVersion !== meta.protocolVersion) { + return { + code: ProtocolErrorCode.HeaderMismatch, + message: `MCP-Protocol-Version header does not match _meta.${META_KEYS.protocolVersion}` + }; + } + if (meta.clientInfo === undefined) { + return { code: ProtocolErrorCode.InvalidParams, message: `Missing required _meta.${META_KEYS.clientInfo}` }; + } + if (meta.clientCapabilities === undefined) { + return { code: ProtocolErrorCode.InvalidParams, message: `Missing required _meta.${META_KEYS.clientCapabilities}` }; + } + return undefined; +} + +function sseDispatch( + handlers: StatelessHandlers, + requests: ReadonlyArray<{ r: JSONRPCRequest; meta: ClientMeta } | { r: JSONRPCRequest; error: JSONRPCErrorResponse }>, + req: Request, + options?: StatelessHttpRequestOptions +): Response { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + let closed = false; + const write = (m: JSONRPCMessage) => { + if (closed) return; + controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(m)}\n\n`)); + }; + const stream = new ReadableStream({ + start(c) { + controller = c; + void (async () => { + // Dispatch in parallel; each request's notifications stream as they arrive. + await Promise.all( + requests.map(async item => { + if ('error' in item) { + write(item.error); + return; + } + const response = await handlers.dispatch(item.r, { + signal: req.signal, + authInfo: options?.authInfo, + httpRequest: req, + meta: item.meta, + notify: write + }); + write(response); + }) + ); + if (!closed) { + closed = true; + controller.close(); + } + })().catch(error => { + if (!closed) { + closed = true; + controller.error(error); + } + }); + }, + cancel() { + closed = true; + } + }); + return new Response(stream, { status: 200, headers: SSE_HEADERS }); +} + +function sseResponse(source: AsyncIterable, opts: { signal?: AbortSignal; onCancel(): void }): Response { + const encoder = new TextEncoder(); + let closed = false; + const cancel = () => { + if (closed) return; + closed = true; + opts.onCancel(); + }; + opts.signal?.addEventListener('abort', cancel, { once: true }); + const stream = new ReadableStream({ + async start(controller) { + let iteratorError: unknown; + try { + for await (const m of source) { + if (closed) break; + controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(m)}\n\n`)); + } + } catch (error) { + iteratorError = error; + } finally { + opts.signal?.removeEventListener('abort', cancel); + // Always release the listener registration on stream end, + // including when the for-await throws non-abort. + if (!closed) { + closed = true; + opts.onCancel(); + if (iteratorError === undefined) { + controller.close(); + } else { + controller.error(iteratorError); + } + } + } + }, + cancel + }); + return new Response(stream, { status: 200, headers: SSE_HEADERS }); +} + +const SSE_HEADERS = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' +}; + +function statusForCode(code: number): number { + switch (code) { + case ProtocolErrorCode.MethodNotFound: { + return 404; + } + case ProtocolErrorCode.InvalidParams: + case ProtocolErrorCode.InvalidRequest: + case ProtocolErrorCode.ParseError: + case ProtocolErrorCode.HeaderMismatch: + case ProtocolErrorCode.MissingRequiredClientCapability: { + return 400; + } + case ProtocolErrorCode.InternalError: { + return 500; + } + default: { + return 200; + } + } +} + +async function readBoundedText(req: Request, max: number): Promise { + if (!req.body) return ''; + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > max) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + const buf = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + buf.set(c, off); + off += c.byteLength; + } + return new TextDecoder().decode(buf); +} + +/** Builds a JSON-RPC error response with the given HTTP status. @internal */ +export function jsonError(status: number, code: number, message: string, id: unknown, headers?: Record): Response { + return Response.json( + { jsonrpc: JSONRPC_VERSION, id, error: { code, message } }, + { status, headers: { 'Content-Type': 'application/json', ...headers } } + ); +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index fd3563a077..2418b14e32 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -7,17 +7,29 @@ * 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 type { + AuthInfo, + JSONRPCMessage, + ListenContext, + MessageExtraInfo, + RequestId, + StatelessHandlers, + Transport +} from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, + isStatelessProtocolVersion, JSONRPCMessageSchema, + parseClientMeta, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { statelessHttpHandler } from './statelessHttp.js'; + export type StreamId = string; export type EventId = string; @@ -168,6 +180,15 @@ export interface HandleRequestOptions { * Authentication info from middleware. If provided, will be passed to message handlers. */ authInfo?: AuthInfo; + + /** + * Per-URI authorization for `resourceSubscriptions` on the 2026-06 + * `subscriptions/listen` path. See {@linkcode ListenContext.onAuthorizeResourceSubscription}. + */ + onAuthorizeResourceSubscription?: ListenContext['onAuthorizeResourceSubscription']; + + /** Maximum POST body size for the 2026-06 stateless path. Default 4 MiB. */ + maxBodyBytes?: number; } /** @@ -241,11 +262,22 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _retryInterval?: number; private _supportedProtocolVersions: string[]; + private _statelessHandlers?: StatelessHandlers; + sessionId?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + /** + * Installed by `Server.connect()`. When present, {@linkcode handleRequest} + * routes 2026-06 requests to {@linkcode statelessHttpHandler}; when absent, + * all requests fall through to the legacy stateful path. + */ + setStatelessHandlers(h: StatelessHandlers): void { + this._statelessHandlers = h; + } + constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; @@ -341,16 +373,42 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } /** - * Handles an incoming HTTP request, whether `GET`, `POST`, or `DELETE` - * Returns a `Response` object (Web Standard) + * Top-level request entry. Validates DNS-rebinding headers (both protocol + * eras), then routes by `MCP-Protocol-Version` header (spec: header is + * mandatory for 2026-06 POSTs; absent or pre-2026 implies legacy stateful path). */ async handleRequest(req: Request, options?: HandleRequestOptions): Promise { - // Validate request headers for DNS rebinding protection const validationError = this.validateRequestHeaders(req); if (validationError) { return validationError; } + // Route by header first (spec: header is MUST for 2026-06 POSTs). If + // header is absent, fall back to the body's first request _meta. The + // conformance harness (and any client that omits the header) still + // routes correctly. parsedBody is set by Node/Express adapters. + const headerPv = req.headers.get('mcp-protocol-version'); + const pv = headerPv ?? versionFromParsedBody(options?.parsedBody); + if (pv && this._supportedProtocolVersions.includes(pv) && isStatelessProtocolVersion(pv) && this._statelessHandlers) { + // Stateless requests have no session by definition. Authorization + // MUST be enforced at the transport/framework layer (bearer token + // surfaced as `options.authInfo`, threaded to dispatch/listen ctx), + // not via session state. `validateSession()` is session-id + // correlation, not authorization, so it does not apply here. + return statelessHttpHandler(this._statelessHandlers, req, options); + } + // Unsupported / pre-2026 / no stateless handlers route to legacy path + // (existing unsupported-version handling lives in handlePostRequest, byte-identical). + return this.handleStatefulRequest(req, options); + } + + /** + * Pre-2026 stateful request handling. Body moved wholesale from + * `handleRequest`; behavior is byte-identical. The GHSA-345p + * `_hasHandledRequest` guard correctly stays inside this path (the + * stateless dispatch path is reuse-safe by construction). + */ + private async handleStatefulRequest(req: Request, options?: HandleRequestOptions): Promise { switch (req.method) { case 'POST': { return this.handlePostRequest(req, options); @@ -1036,3 +1094,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } } + +function versionFromParsedBody(body: unknown): string | undefined { + const first = Array.isArray(body) ? body.find(m => isJSONRPCRequest(m)) : body; + if (!isJSONRPCRequest(first)) return undefined; + return parseClientMeta(first.params).protocolVersion; +} diff --git a/packages/server/src/server/subscriptions.ts b/packages/server/src/server/subscriptions.ts new file mode 100644 index 0000000000..acb4cb67e7 --- /dev/null +++ b/packages/server/src/server/subscriptions.ts @@ -0,0 +1,148 @@ +import type { + JSONRPCNotification, + ListenContext, + ListenStream, + RequestId, + ServerCapabilities, + SubscriptionFilter, + SubscriptionsListenRequest +} from '@modelcontextprotocol/core'; +import { AsyncQueue, JSONRPC_VERSION, META_KEYS } from '@modelcontextprotocol/core'; + +/** + * Kinds of server-side change a {@linkcode SubscriptionBackend} can deliver. + */ +export type SubscriptionEvent = + | { type: 'toolsListChanged' } + | { type: 'promptsListChanged' } + | { type: 'resourcesListChanged' } + | { type: 'resourceUpdated'; uri: string }; + +/** + * Backend for `subscriptions/listen`. The transport adapter routes the + * `subscriptions/listen` request here (NOT through the Dispatcher) and wraps + * the returned stream in wire format. `Server.send*ListChanged` calls + * {@linkcode notify} to deliver events to all active listeners. + * + * `subscriptions/listen` is the one 2026-06 method that is request→stream + * rather than request→response, so it lives outside the Dispatcher's + * always-short-lived contract. + */ +export interface SubscriptionBackend { + /** + * Opens a listen stream. Computes the accepted filter (intersection of + * requested + server capabilities + authorization), emits the ack + * notification, then streams matching events until `close()` is called. + */ + handle(request: SubscriptionsListenRequest & { id: RequestId }, ctx: ListenContext, capabilities: ServerCapabilities): ListenStream; + + /** + * Delivers an event to all active listeners whose filter matches. + * Distributed implementations may await broker delivery. + */ + notify(event: SubscriptionEvent): Promise; +} + +const MAX_QUEUE = 256; +const MAX_RESOURCE_SUBSCRIPTIONS = 256; + +type ListenerEntry = { + subscriptionId: string; + filter: SubscriptionFilter; + resourceSubscriptions?: ReadonlySet; + queue: AsyncQueue; +}; + +/** + * In-memory {@linkcode SubscriptionBackend}. Suitable for single-process + * deployments; horizontally-scaled deployments should provide a distributed + * implementation (e.g., Redis pub/sub). + */ +export class InMemorySubscriptions implements SubscriptionBackend { + private readonly _active = new Map(); + + handle(request: SubscriptionsListenRequest & { id: RequestId }, ctx: ListenContext, capabilities: ServerCapabilities): ListenStream { + const requested = request.params.notifications; + const accepted = acceptedFilter(requested, capabilities, ctx); + // SEP-2575: the wire `_meta.subscriptionId` is the JSON-RPC id of the + // listen request. The internal `_active` map is keyed by a + // server-minted UUID so concurrent listeners on a shared Server + // instance cannot collide on client-chosen ids. + const subscriptionId = String(request.id); + const activeKey = crypto.randomUUID(); + + // Evict slow consumers at MAX_QUEUE rather than buffering unbounded server memory. + const queue = new AsyncQueue(MAX_QUEUE); + const close = (): void => { + queue.close(); + this._active.delete(activeKey); + }; + + this._active.set(activeKey, { + subscriptionId, + filter: accepted, + resourceSubscriptions: accepted.resourceSubscriptions ? new Set(accepted.resourceSubscriptions) : undefined, + queue + }); + + // First message on the stream is always the ack. + queue.push({ + jsonrpc: JSONRPC_VERSION, + method: 'notifications/subscriptions/acknowledged', + params: { notifications: accepted, _meta: { [META_KEYS.subscriptionId]: subscriptionId } } + }); + + return { stream: queue.iterate(), close }; + } + + async notify(event: SubscriptionEvent): Promise { + for (const [key, entry] of this._active) { + const n = matchEvent(event, entry); + if (n && !entry.queue.push(n)) this._active.delete(key); + } + } +} + +function acceptedFilter(requested: SubscriptionFilter, caps: ServerCapabilities, ctx: ListenContext): SubscriptionFilter { + const out: SubscriptionFilter = {}; + if (requested.toolsListChanged && caps.tools?.listChanged) out.toolsListChanged = true; + if (requested.promptsListChanged && caps.prompts?.listChanged) out.promptsListChanged = true; + if (requested.resourcesListChanged && caps.resources?.listChanged) out.resourcesListChanged = true; + // resourceSubscriptions: fail-closed. Denied entirely without an authorization + // hook. Capped before iterating so a large client array cannot amplify into + // unbounded auth-hook calls. + const authorize = ctx.onAuthorizeResourceSubscription; + if (requested.resourceSubscriptions && caps.resources?.subscribe && authorize) { + const allowed = requested.resourceSubscriptions + .slice(0, MAX_RESOURCE_SUBSCRIPTIONS) + .filter(uri => authorize(uri, { authInfo: ctx.authInfo })); + if (allowed.length > 0) out.resourceSubscriptions = allowed; + } + return out; +} + +function matchEvent(event: SubscriptionEvent, entry: ListenerEntry): JSONRPCNotification | undefined { + const _meta = { [META_KEYS.subscriptionId]: entry.subscriptionId }; + switch (event.type) { + case 'toolsListChanged': { + return entry.filter.toolsListChanged + ? { jsonrpc: JSONRPC_VERSION, method: 'notifications/tools/list_changed', params: { _meta } } + : undefined; + } + case 'promptsListChanged': { + return entry.filter.promptsListChanged + ? { jsonrpc: JSONRPC_VERSION, method: 'notifications/prompts/list_changed', params: { _meta } } + : undefined; + } + case 'resourcesListChanged': { + return entry.filter.resourcesListChanged + ? { jsonrpc: JSONRPC_VERSION, method: 'notifications/resources/list_changed', params: { _meta } } + : undefined; + } + case 'resourceUpdated': { + return entry.resourceSubscriptions?.has(event.uri) + ? { jsonrpc: JSONRPC_VERSION, method: 'notifications/resources/updated', params: { uri: event.uri, _meta } } + : undefined; + } + } +} diff --git a/packages/server/test/server/dispatchStateless.test.ts b/packages/server/test/server/dispatchStateless.test.ts new file mode 100644 index 0000000000..6dadf25af7 --- /dev/null +++ b/packages/server/test/server/dispatchStateless.test.ts @@ -0,0 +1,236 @@ +import type { + DispatchContext, + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + Result, + ServerContext +} from '@modelcontextprotocol/core'; +import { + InputRequiredError, + JSONRPC_VERSION, + LATEST_PROTOCOL_VERSION, + META_KEYS, + ProtocolError, + ProtocolErrorCode, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Server } from '../../src/server/server.js'; + +const STATELESS_VERSION = LATEST_PROTOCOL_VERSION; + +function makeServer(): Server { + return new Server( + { name: 's', version: '1' }, + { capabilities: { tools: { listChanged: true }, prompts: {}, logging: {} }, instructions: 'hi' } + ); +} + +function dispatch(server: Server) { + return server.statelessHandlers().dispatch; +} + +function setHandler(server: Server, method: string, handler: (req: JSONRPCRequest, ctx: ServerContext) => Promise): void { + (server.setRequestHandler as (m: string, h: typeof handler) => void)(method, handler); +} + +function meta(extra?: Record): Record { + return { + [META_KEYS.protocolVersion]: STATELESS_VERSION, + [META_KEYS.clientInfo]: { name: 'c', version: '1' }, + [META_KEYS.clientCapabilities]: {}, + ...extra + }; +} + +function req(method: string, params: Record = {}, id: number | string = 1): JSONRPCRequest { + return { jsonrpc: JSONRPC_VERSION, id, method, params: { ...params, _meta: meta(params._meta as Record) } }; +} + +function ctx(over?: Partial): DispatchContext { + return { notify: () => {}, ...over }; +} + +function ok(r: JSONRPCResponse | JSONRPCErrorResponse): Result { + if (!('result' in r)) throw new Error(`expected result, got error ${JSON.stringify(r)}`); + return r.result; +} + +function err(r: JSONRPCResponse | JSONRPCErrorResponse): { code: number; message: string; data?: unknown } { + if (!('error' in r)) throw new Error(`expected error, got result ${JSON.stringify(r)}`); + return r.error; +} + +describe('Server._dispatchStateless', () => { + it('handles server/discover via the registered handler', async () => { + const server = makeServer(); + const r = await dispatch(server)(req('server/discover'), ctx()); + expect(ok(r)).toMatchObject({ + serverInfo: { name: 's', version: '1' }, + capabilities: { tools: { listChanged: true }, prompts: {}, logging: {} }, + instructions: 'hi', + supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS] + }); + }); + + it('does not add resultType to server/discover', async () => { + const server = makeServer(); + const r = await dispatch(server)(req('server/discover'), ctx()); + expect(ok(r)).not.toHaveProperty('resultType'); + }); + + it('rejects removed methods with -32601', async () => { + const server = makeServer(); + const r = await dispatch(server)(req('initialize'), ctx()); + expect(err(r).code).toBe(ProtocolErrorCode.MethodNotFound); + }); + + it('rejects unsupported protocol versions', async () => { + const server = makeServer(); + const r = await dispatch(server)( + { jsonrpc: JSONRPC_VERSION, id: 1, method: 'tools/list', params: { _meta: { [META_KEYS.protocolVersion]: '1999-01-01' } } }, + ctx() + ); + expect(err(r).code).toBe(ProtocolErrorCode.InvalidParams); + expect(err(r).data).toMatchObject({ requested: '1999-01-01' }); + }); + + it('fills resultType: complete when handler omits it', async () => { + const server = makeServer(); + setHandler(server, 'tools/list', async () => ({ tools: [] })); + const r = await dispatch(server)(req('tools/list'), ctx()); + expect(ok(r).resultType).toBe('complete'); + }); + + it('routes ctx.mcpReq.notify via dctx.notify, server-stamps subscriptionId last', async () => { + const server = makeServer(); + const seen: JSONRPCNotification[] = []; + setHandler(server, 'prompts/list', async (_, sctx) => { + await sctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progress: 1, progressToken: 't', _meta: { [META_KEYS.subscriptionId]: 'handler-override-attempt' } } + }); + return { prompts: [] }; + }); + await dispatch(server)(req('prompts/list', {}, 42), ctx({ notify: n => seen.push(n) })); + expect(seen).toHaveLength(1); + expect((seen[0]!.params!._meta as Record)[META_KEYS.subscriptionId]).toBe('42'); + }); + + it('log gating: drops without _meta.logLevel; emits when level >= threshold', async () => { + const server = makeServer(); + const seen: JSONRPCNotification[] = []; + setHandler(server, 'prompts/list', async (_, sctx) => { + await sctx.mcpReq.log('debug', 'd'); + await sctx.mcpReq.log('error', 'e'); + return { prompts: [] }; + }); + await dispatch(server)(req('prompts/list'), ctx({ notify: n => seen.push(n) })); + expect(seen).toHaveLength(0); + await dispatch(server)(req('prompts/list', { _meta: { [META_KEYS.logLevel]: 'warning' } }), ctx({ notify: n => seen.push(n) })); + expect(seen.map(n => (n.params as { level: string }).level)).toEqual(['error']); + }); + + it('mcpReq.send throws (no push channel)', async () => { + const server = makeServer(); + let threw = false; + setHandler(server, 'prompts/list', async (_, sctx) => { + try { + await sctx.mcpReq.send({ method: 'roots/list' } as never, {} as never); + } catch { + threw = true; + } + return { prompts: [] }; + }); + await dispatch(server)(req('prompts/list'), ctx()); + expect(threw).toBe(true); + }); + + it('catches InputRequiredError into resultType: input_required', async () => { + const server = makeServer(); + setHandler(server, 'prompts/list', async () => { + throw new InputRequiredError({ + 'elicitation/create#0': { method: 'elicitation/create', params: { message: 'q' } } as never + }); + }); + const r = await dispatch(server)(req('prompts/list', { _meta: { [META_KEYS.clientCapabilities]: { elicitation: {} } } }), ctx()); + expect(ok(r)).toMatchObject({ + resultType: 'input_required', + inputRequests: { 'elicitation/create#0': { method: 'elicitation/create' } } + }); + }); + + it('cap-gates InputRequiredError with -32003 when client lacks capability', async () => { + const server = makeServer(); + setHandler(server, 'prompts/list', async () => { + throw new InputRequiredError({ + 'sampling/createMessage#0': { method: 'sampling/createMessage', params: { messages: [] } } as never + }); + }); + const r = await dispatch(server)(req('prompts/list'), ctx()); + expect(err(r).code).toBe(ProtocolErrorCode.MissingRequiredClientCapability); + expect(err(r).data).toEqual({ requiredCapabilities: { sampling: {} } }); + }); + + it('mrtrOrThrow returns validated cached inputResponses', async () => { + const server = makeServer(); + setHandler(server, 'prompts/list', async (_, sctx) => { + const r = await sctx.mcpReq.elicitInput({ message: 'q', requestedSchema: { type: 'object', properties: {} } }); + return { prompts: [{ name: r.action }] }; + }); + const r = await dispatch(server)( + req('prompts/list', { + _meta: { [META_KEYS.clientCapabilities]: { elicitation: { form: {} } } }, + inputResponses: { 'elicitation/create#0': { action: 'accept' } } + }), + ctx() + ); + expect((ok(r) as { prompts: Array<{ name: string }> }).prompts[0]?.name).toBe('accept'); + }); + + it('passes ProtocolError through with its code/data', async () => { + const server = makeServer(); + setHandler(server, 'prompts/list', async () => { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad', { which: 'x' }); + }); + const r = await dispatch(server)(req('prompts/list'), ctx()); + expect(err(r)).toMatchObject({ code: ProtocolErrorCode.InvalidParams, message: 'bad', data: { which: 'x' } }); + }); + + it('threads dctx.authInfo to handler ctx.http.authInfo', async () => { + const server = makeServer(); + let seen: unknown; + setHandler(server, 'prompts/list', async (_, sctx) => { + seen = sctx.http?.authInfo; + return { prompts: [] }; + }); + await dispatch(server)(req('prompts/list'), ctx({ authInfo: { token: 't', clientId: 'c', scopes: [] } })); + expect(seen).toMatchObject({ token: 't' }); + }); + + it('threads dctx.signal to handler ctx.mcpReq.signal', async () => { + const server = makeServer(); + const ac = new AbortController(); + let aborted = false; + setHandler(server, 'prompts/list', async (_, sctx) => { + sctx.mcpReq.signal.addEventListener('abort', () => { + aborted = true; + }); + ac.abort(); + return { prompts: [] }; + }); + await dispatch(server)(req('prompts/list'), ctx({ signal: ac.signal })); + expect(aborted).toBe(true); + }); + + it('is concurrent-safe on a shared instance', async () => { + const server = makeServer(); + setHandler(server, 'prompts/list', async (_, sctx) => ({ prompts: [{ name: String(sctx.mcpReq.id) }] })); + const d = dispatch(server); + const rs = await Promise.all([1, 2, 3, 4, 5].map(i => d(req('prompts/list', {}, i), ctx()))); + expect(rs.map(r => (ok(r) as { prompts: Array<{ name: string }> }).prompts[0]?.name)).toEqual(['1', '2', '3', '4', '5']); + }); +}); diff --git a/packages/server/test/server/statelessHttp.test.ts b/packages/server/test/server/statelessHttp.test.ts new file mode 100644 index 0000000000..e3f00401a4 --- /dev/null +++ b/packages/server/test/server/statelessHttp.test.ts @@ -0,0 +1,187 @@ +import type { JSONRPCResultResponse, StatelessHandlers } from '@modelcontextprotocol/core'; +import { DRAFT_PROTOCOL_VERSION, JSONRPC_VERSION, META_KEYS } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { handleHttp } from '../../src/server/handleHttp.js'; +import { Server } from '../../src/server/server.js'; +import { statelessHttpHandler } from '../../src/server/statelessHttp.js'; + +const _meta = { + [META_KEYS.protocolVersion]: DRAFT_PROTOCOL_VERSION, + [META_KEYS.clientInfo]: { name: 'c', version: '1' }, + [META_KEYS.clientCapabilities]: {} +}; + +function rpc(method: string, id: number | string = 1, extra: object = {}): object { + return { jsonrpc: JSONRPC_VERSION, id, method, params: { _meta, ...extra } }; +} + +function post(body: unknown, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body) + }); +} + +const echoHandlers: StatelessHandlers = { + dispatch: async req => ({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { method: req.method } }), + listen: () => ({ stream: (async function* () {})(), close: () => {} }) +}; + +describe('statelessHttpHandler', () => { + it('returns 415 for wrong Content-Type', async () => { + const req = new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: '{}' + }); + const res = await statelessHttpHandler(echoHandlers, req); + expect(res.status).toBe(415); + }); + + it('returns 405 for non-POST', async () => { + const res = await statelessHttpHandler(echoHandlers, new Request('http://localhost/mcp', { method: 'GET' })); + expect(res.status).toBe(405); + expect(res.headers.get('Allow')).toBe('POST'); + }); + + it('returns 400 for empty batch', async () => { + const res = await statelessHttpHandler(echoHandlers, post([])); + expect(res.status).toBe(400); + }); + + it('returns 400 for batch over cap', async () => { + const batch = Array.from({ length: 65 }, (_, i) => rpc('server/discover', i)); + const res = await statelessHttpHandler(echoHandlers, post(batch)); + expect(res.status).toBe(400); + }); + + it('returns 413 when body exceeds maxBodyBytes', async () => { + const big = 'x'.repeat(200); + const res = await statelessHttpHandler(echoHandlers, post(big), { maxBodyBytes: 100 }); + expect(res.status).toBe(413); + }); + + it('rejects messages that are neither request nor notification', async () => { + const res = await statelessHttpHandler(echoHandlers, post([{ jsonrpc: '2.0', id: 1, result: {} }])); + expect(res.status).toBe(400); + }); + + it('returns 400 when _meta.protocolVersion missing', async () => { + const res = await statelessHttpHandler(echoHandlers, post({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: {} })); + expect(res.status).toBe(400); + }); + + it('returns 400 on header/meta version mismatch', async () => { + const res = await statelessHttpHandler(echoHandlers, post(rpc('server/discover'), { 'MCP-Protocol-Version': '1999-01-01' })); + expect(res.status).toBe(400); + }); + + it('dispatches a single request and returns JSON', async () => { + const res = await statelessHttpHandler(echoHandlers, post(rpc('server/discover'))); + expect(res.status).toBe(200); + const body = (await res.json()) as JSONRPCResultResponse; + expect(body.result).toEqual({ method: 'server/discover' }); + }); + + it('subscriptions/listen requires SSE accept', async () => { + const res = await statelessHttpHandler(echoHandlers, post(rpc('subscriptions/listen', 1, { notifications: {} }))); + expect(res.status).toBe(406); + }); + + it('subscriptions/listen cannot be batched', async () => { + const res = await statelessHttpHandler( + echoHandlers, + post([rpc('subscriptions/listen', 1, { notifications: {} }), rpc('server/discover', 2)], { + Accept: 'text/event-stream' + }) + ); + expect(res.status).toBe(400); + }); + + it('returns 202 for notification-only POST', async () => { + const res = await statelessHttpHandler(echoHandlers, post([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: {} }])); + expect(res.status).toBe(202); + }); +}); + +describe('handleHttp', () => { + function srv() { + return new Server({ name: 's', version: '1' }, { capabilities: {} }); + } + + it('rejects forbidden Host before auth', async () => { + let authCalled = false; + const h = handleHttp(srv(), { + allowedHosts: ['localhost'], + auth: async () => { + authCalled = true; + return undefined; + } + }); + const res = await h(post(rpc('server/discover'), { Host: 'evil.com' })); + expect(res.status).toBe(403); + expect(authCalled).toBe(false); + }); + + it('allowedHosts uses validateHostHeader convention (bracketed IPv6)', async () => { + const h = handleHttp(srv(), { allowedHosts: ['[::1]'] }); + const res = await h(post(rpc('server/discover'), { Host: '[::1]:3000' })); + expect(res.status).toBe(200); + }); + + it('rejects missing Origin when allowedOrigins set', async () => { + const h = handleHttp(srv(), { allowedOrigins: ['http://localhost'] }); + const res = await h(post(rpc('server/discover'))); + expect(res.status).toBe(403); + }); + + it('passes auth result through to dispatch', async () => { + const server = srv(); + server.fallbackRequestHandler = async (_, ctx) => ({ token: ctx.http?.authInfo?.token }); + const h = handleHttp(server, { auth: async () => ({ token: 't', clientId: 'c', scopes: [] }) }); + const res = await h(post(rpc('acme/x'))); + const body = (await res.json()) as JSONRPCResultResponse; + expect((body.result as { token: string }).token).toBe('t'); + }); + + it('auth returning Response short-circuits', async () => { + const h = handleHttp(srv(), { auth: async () => new Response('nope', { status: 401 }) }); + const res = await h(post(rpc('server/discover'))); + expect(res.status).toBe(401); + }); +}); + +describe('sseResponse cleanup', () => { + it('releases listener registration on for-await throw', async () => { + let closed = false; + const handlers: StatelessHandlers = { + dispatch: async () => ({ jsonrpc: JSONRPC_VERSION, id: 1, result: {} }), + listen: () => ({ + stream: (async function* () { + yield { jsonrpc: JSONRPC_VERSION, method: 'notifications/subscriptions/acknowledged', params: { notifications: {} } }; + throw new Error('boom'); + })(), + close: () => { + closed = true; + } + }) + }; + const res = await statelessHttpHandler( + handlers, + post(rpc('subscriptions/listen', 1, { notifications: {} }), { Accept: 'text/event-stream' }) + ); + // Drain the SSE stream until it errors/ends. + const reader = res.body!.getReader(); + try { + for (;;) { + const { done } = await reader.read(); + if (done) break; + } + } catch { + // expected: source threw + } + expect(closed).toBe(true); + }); +}); diff --git a/packages/server/test/server/subscriptions.test.ts b/packages/server/test/server/subscriptions.test.ts new file mode 100644 index 0000000000..10c9924464 --- /dev/null +++ b/packages/server/test/server/subscriptions.test.ts @@ -0,0 +1,182 @@ +import type { JSONRPCMessage, ServerCapabilities, SubscriptionsListenRequest } from '@modelcontextprotocol/core'; +import { JSONRPC_VERSION, META_KEYS } from '@modelcontextprotocol/core'; + +import { InMemorySubscriptions } from '../../src/server/subscriptions.js'; + +const caps: ServerCapabilities = { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true } +}; + +function listenReq(filter: SubscriptionsListenRequest['params']['notifications'], id = 1): SubscriptionsListenRequest & { id: number } { + return { + jsonrpc: JSONRPC_VERSION, + id, + method: 'subscriptions/listen', + params: { notifications: filter } + } as SubscriptionsListenRequest & { id: number }; +} + +/** Reads `n` messages without closing the stream (no `for await...break`). */ +function reader(stream: AsyncIterable): (n: number) => Promise { + const it = stream[Symbol.asyncIterator](); + return async n => { + const out: JSONRPCMessage[] = []; + for (let i = 0; i < n; i++) { + const r = await it.next(); + if (r.done) break; + out.push(r.value); + } + return out; + }; +} + +describe('InMemorySubscriptions', () => { + it('first event is acknowledged with subscriptionId == request id (SEP-2575)', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle(listenReq({ toolsListChanged: true }, 42), {}, caps); + const [ack] = await reader(stream)(1); + expect(ack).toMatchObject({ method: 'notifications/subscriptions/acknowledged' }); + const sid = (ack as { params: { _meta: Record } }).params._meta[META_KEYS.subscriptionId]; + expect(sid).toBe('42'); + close(); + }); + + it('ack reflects the intersection of requested and server capabilities', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle( + listenReq({ toolsListChanged: true, promptsListChanged: true }), + {}, + { tools: { listChanged: true } } + ); + const [ack] = await reader(stream)(1); + expect((ack as unknown as { params: { notifications: unknown } }).params.notifications).toEqual({ toolsListChanged: true }); + close(); + }); + + it('notify routes only to listeners whose filter matches', async () => { + const subs = new InMemorySubscriptions(); + const a = subs.handle(listenReq({ toolsListChanged: true }), {}, caps); + const b = subs.handle(listenReq({ promptsListChanged: true }), {}, caps); + const readA = reader(a.stream); + const readB = reader(b.stream); + await readA(1); + await readB(1); + subs.notify({ type: 'toolsListChanged' }); + const [aMsg] = await readA(1); + expect(aMsg).toMatchObject({ method: 'notifications/tools/list_changed' }); + subs.notify({ type: 'promptsListChanged' }); + const [bMsg] = await readB(1); + expect(bMsg).toMatchObject({ method: 'notifications/prompts/list_changed' }); + a.close(); + b.close(); + }); + + it('resourceSubscriptions are denied without an authorization hook (fail-closed)', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle(listenReq({ resourceSubscriptions: ['file:///a'] }), {}, caps); + const [ack] = await reader(stream)(1); + expect((ack as unknown as { params: { notifications: unknown } }).params.notifications).toEqual({}); + close(); + }); + + it('resourceSubscriptions are filtered through onAuthorizeResourceSubscription', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle( + listenReq({ resourceSubscriptions: ['file:///a', 'file:///b'] }), + { onAuthorizeResourceSubscription: uri => uri === 'file:///a' }, + caps + ); + const read = reader(stream); + const [ack] = await read(1); + expect((ack as unknown as { params: { notifications: unknown } }).params.notifications).toEqual({ + resourceSubscriptions: ['file:///a'] + }); + subs.notify({ type: 'resourceUpdated', uri: 'file:///b' }); + subs.notify({ type: 'resourceUpdated', uri: 'file:///a' }); + const [evt] = await read(1); + expect(evt).toMatchObject({ method: 'notifications/resources/updated', params: { uri: 'file:///a' } }); + close(); + }); + + it('threads ctx.authInfo to onAuthorizeResourceSubscription', async () => { + const subs = new InMemorySubscriptions(); + let seen: unknown; + const { close } = subs.handle( + listenReq({ resourceSubscriptions: ['file:///a'] }), + { + authInfo: { token: 't', clientId: 'c', scopes: [] }, + onAuthorizeResourceSubscription: (_, c) => { + seen = c.authInfo; + return true; + } + }, + caps + ); + expect(seen).toMatchObject({ token: 't' }); + close(); + }); + + it('close ends the stream and is idempotent', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle(listenReq({ toolsListChanged: true }), {}, caps); + const read = reader(stream); + await read(1); + close(); + close(); + await expect(read(1)).resolves.toEqual([]); + }); + + it('close drops the registration so notify after close is a no-op', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle(listenReq({ toolsListChanged: true }), {}, caps); + await reader(stream)(1); + close(); + expect(() => subs.notify({ type: 'toolsListChanged' })).not.toThrow(); + }); + + it('iterator return() ends the stream (early break)', async () => { + const subs = new InMemorySubscriptions(); + const { stream } = subs.handle(listenReq({ toolsListChanged: true }), {}, caps); + for await (const _ of stream) { + break; + } + const it = stream[Symbol.asyncIterator](); + await expect(it.next()).resolves.toEqual({ value: undefined, done: true }); + }); + + it('queues events delivered before next() is called', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle(listenReq({ toolsListChanged: true }), {}, caps); + subs.notify({ type: 'toolsListChanged' }); + subs.notify({ type: 'toolsListChanged' }); + const got = await reader(stream)(3); + expect(got.map(m => (m as { method: string }).method)).toEqual([ + 'notifications/subscriptions/acknowledged', + 'notifications/tools/list_changed', + 'notifications/tools/list_changed' + ]); + close(); + }); + + it('concurrent listeners with the same JSON-RPC id each receive events (internal map keyed by UUID)', async () => { + const subs = new InMemorySubscriptions(); + // Two clients on a shared backend may both choose id=1. Both should + // receive events; the wire subscriptionId is "1" for each (per their + // own request) and the internal map key is collision-safe. + const a = subs.handle(listenReq({ toolsListChanged: true }, 1), {}, caps); + const b = subs.handle(listenReq({ toolsListChanged: true }, 1), {}, caps); + const readA = reader(a.stream); + const readB = reader(b.stream); + await readA(1); + await readB(1); + subs.notify({ type: 'toolsListChanged' }); + const [evtA] = await readA(1); + const [evtB] = await readB(1); + expect(evtA).toMatchObject({ method: 'notifications/tools/list_changed' }); + expect(evtB).toMatchObject({ method: 'notifications/tools/list_changed' }); + a.close(); + b.close(); + }); +}); diff --git a/test/conformance/package.json b/test/conformance/package.json index db0f04a4db..d9a1c4c0f2 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -34,6 +34,8 @@ "test:conformance:server": "scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml", "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", "test:conformance:server:run": "npx tsx ./src/everythingServer.ts", + "test:conformance:server:dispatchv2": "PORT=3036 SERVER_ENTRY=./src/everythingServerDispatchV2.ts scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml", + "test:conformance:server:dispatchv2:run": "PORT=3036 npx tsx ./src/everythingServerDispatchV2.ts", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { diff --git a/test/conformance/scripts/run-server-conformance.sh b/test/conformance/scripts/run-server-conformance.sh index 203a0145b5..41e4c9d064 100755 --- a/test/conformance/scripts/run-server-conformance.sh +++ b/test/conformance/scripts/run-server-conformance.sh @@ -6,14 +6,15 @@ set -e PORT="${PORT:-3000}" SERVER_URL="http://localhost:${PORT}/mcp" +SERVER_ENTRY="${SERVER_ENTRY:-./src/everythingServer.ts}" # Navigate to the repo root SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR/.." # Start the server in the background -echo "Starting conformance test server on port ${PORT}..." -npx tsx ./src/everythingServer.ts & +echo "Starting conformance test server (${SERVER_ENTRY}) on port ${PORT}..." +PORT="${PORT}" npx tsx "${SERVER_ENTRY}" & SERVER_PID=$! # Function to cleanup on exit diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index f3925aeea8..67b401eeea 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -11,867 +11,18 @@ import { randomUUID } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import type { McpServer } from '@modelcontextprotocol/server'; +import { isInitializeRequest } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; -import * as z from 'zod/v4'; -// Server state -const resourceSubscriptions = new Set(); -const watchedResourceContent = 'Watched resource content'; +import { createEventStore, createMcpServer } from './everythingServerSetup.js'; // Session management const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; const servers: { [sessionId: string]: McpServer } = {}; -// In-memory event store for SEP-1699 resumability -const eventStoreData = new Map(); - -function createEventStore(): EventStore { - return { - async storeEvent(streamId: StreamId, message: unknown): Promise { - const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; - eventStoreData.set(eventId, { eventId, message, streamId }); - return eventId; - }, - async replayEventsAfter( - lastEventId: EventId, - { send }: { send: (eventId: EventId, message: unknown) => Promise } - ): Promise { - const streamId = lastEventId.split('::')[0] || lastEventId; - const eventsToReplay: Array<[string, { message: unknown }]> = []; - for (const [eventId, data] of eventStoreData.entries()) { - if (data.streamId === streamId && eventId > lastEventId) { - eventsToReplay.push([eventId, data]); - } - } - eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); - for (const [eventId, { message }] of eventsToReplay) { - if (message && typeof message === 'object' && Object.keys(message).length > 0) { - await send(eventId, message); - } - } - return streamId; - } - }; -} - -// Sample base64 encoded 1x1 red PNG pixel for testing -const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; - -// Sample base64 encoded minimal WAV file for testing -const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; - -// Function to create a new MCP server instance (one per session) -function createMcpServer() { - const mcpServer = new McpServer( - { - name: 'mcp-conformance-test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: { - listChanged: true - }, - resources: { - subscribe: true, - listChanged: true - }, - prompts: { - listChanged: true - }, - logging: {}, - completions: {} - } - } - ); - - // Helper to send log messages using the underlying server - function sendLog( - level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', - message: string, - _data?: unknown - ) { - mcpServer.server - .notification({ - method: 'notifications/message', - params: { - level, - logger: 'conformance-test-server', - data: _data || message - } - }) - .catch(() => { - // Ignore error if no client is connected - }); - } - - // ===== TOOLS ===== - - // Simple text tool - mcpServer.registerTool( - 'test_simple_text', - { - description: 'Tests simple text content response' - }, - async (): Promise => { - return { - content: [{ type: 'text', text: 'This is a simple text response for testing.' }] - }; - } - ); - - // Image content tool - mcpServer.registerTool( - 'test_image_content', - { - description: 'Tests image content response' - }, - async (): Promise => { - return { - content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }] - }; - } - ); - - // Audio content tool - mcpServer.registerTool( - 'test_audio_content', - { - description: 'Tests audio content response' - }, - async (): Promise => { - return { - content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }] - }; - } - ); - - // Embedded resource tool - mcpServer.registerTool( - 'test_embedded_resource', - { - description: 'Tests embedded resource content response' - }, - async (): Promise => { - return { - content: [ - { - type: 'resource', - resource: { - uri: 'test://embedded-resource', - mimeType: 'text/plain', - text: 'This is an embedded resource content.' - } - } - ] - }; - } - ); - - // Multiple content types tool - mcpServer.registerTool( - 'test_multiple_content_types', - { - description: 'Tests response with multiple content types (text, image, resource)' - }, - async (): Promise => { - return { - content: [ - { type: 'text', text: 'Multiple content types test:' }, - { type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }, - { - type: 'resource', - resource: { - uri: 'test://mixed-content-resource', - mimeType: 'application/json', - text: JSON.stringify({ test: 'data', value: 123 }) - } - } - ] - }; - } - ); - - // Tool with logging - mcpServer.registerTool( - 'test_tool_with_logging', - { - description: 'Tests tool that emits log messages during execution', - inputSchema: z.object({}) - }, - async (_args, ctx): Promise => { - await ctx.mcpReq.notify({ - method: 'notifications/message', - params: { - level: 'info', - data: 'Tool execution started' - } - }); - await new Promise(resolve => setTimeout(resolve, 50)); - - await ctx.mcpReq.notify({ - method: 'notifications/message', - params: { - level: 'info', - data: 'Tool processing data' - } - }); - await new Promise(resolve => setTimeout(resolve, 50)); - - await ctx.mcpReq.notify({ - method: 'notifications/message', - params: { - level: 'info', - data: 'Tool execution completed' - } - }); - return { - content: [{ type: 'text', text: 'Tool with logging executed successfully' }] - }; - } - ); - - // Tool with progress - mcpServer.registerTool( - 'test_tool_with_progress', - { - description: 'Tests tool that reports progress notifications', - inputSchema: z.object({}) - }, - async (_args, ctx): Promise => { - const progressToken = ctx.mcpReq._meta?.progressToken ?? 0; - console.log('Progress token:', progressToken); - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: 0, - total: 100, - message: `Completed step ${0} of ${100}` - } - }); - await new Promise(resolve => setTimeout(resolve, 50)); - - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100, - message: `Completed step ${50} of ${100}` - } - }); - await new Promise(resolve => setTimeout(resolve, 50)); - - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: 100, - total: 100, - message: `Completed step ${100} of ${100}` - } - }); - - return { - content: [{ type: 'text', text: String(progressToken) }] - }; - } - ); - - // Error handling tool - mcpServer.registerTool( - 'test_error_handling', - { - description: 'Tests error response handling' - }, - async (): Promise => { - throw new Error('This tool intentionally returns an error for testing'); - } - ); - - // SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection - mcpServer.registerTool( - 'test_reconnection', - { - description: - 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', - inputSchema: z.object({}) - }, - async (_args, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - console.log(`[${ctx.sessionId}] Starting test_reconnection tool...`); - - // Get the transport for this session - const transport = ctx.sessionId ? transports[ctx.sessionId] : undefined; - if (transport && ctx.mcpReq.id) { - // Close the SSE stream to trigger client reconnection - console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); - transport.closeSSEStream(ctx.mcpReq.id); - } - - // Wait for client to reconnect (should respect retry field) - await sleep(100); - - console.log(`[${ctx.sessionId}] test_reconnection tool complete`); - - return { - content: [ - { - type: 'text', - text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.' - } - ] - }; - } - ); - - // Sampling tool - requests LLM completion from client - mcpServer.registerTool( - 'test_sampling', - { - description: 'Tests server-initiated sampling (LLM completion request)', - inputSchema: z.object({ - prompt: z.string().describe('The prompt to send to the LLM') - }) - }, - async (args: { prompt: string }, ctx): Promise => { - try { - // Request sampling from client - const result = (await ctx.mcpReq.send({ - method: 'sampling/createMessage', - params: { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: args.prompt - } - } - ], - maxTokens: 100 - } - })) as { content?: { text?: string }; message?: { content?: { text?: string } } }; - - const modelResponse = result.content?.text || result.message?.content?.text || 'No response'; - - return { - content: [ - { - type: 'text', - text: `LLM response: ${modelResponse}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Sampling not supported or error: ${error instanceof Error ? error.message : String(error)}` - } - ] - }; - } - } - ); - - // Elicitation tool - requests user input from client - mcpServer.registerTool( - 'test_elicitation', - { - description: 'Tests server-initiated elicitation (user input request)', - inputSchema: z.object({ - message: z.string().describe('The message to show the user') - }) - }, - async (args: { message: string }, ctx): Promise => { - try { - // Request user input from client - const result = await ctx.mcpReq.send({ - method: 'elicitation/create', - params: { - message: args.message, - requestedSchema: { - type: 'object', - properties: { - response: { - type: 'string', - description: "User's response" - } - }, - required: ['response'] - } - } - }); - - const elicitResult = result as { action?: string; content?: unknown }; - return { - content: [ - { - type: 'text', - text: `User response: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` - } - ] - }; - } - } - ); - - // SEP-1034: Elicitation with default values for all primitive types - mcpServer.registerTool( - 'test_elicitation_sep1034_defaults', - { - description: 'Tests elicitation with default values per SEP-1034', - inputSchema: z.object({}) - }, - async (_args, ctx): Promise => { - try { - // Request user input with default values for all primitive types - const result = await ctx.mcpReq.send({ - method: 'elicitation/create', - params: { - message: 'Please review and update the form fields with defaults', - requestedSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'User name', - default: 'John Doe' - }, - age: { - type: 'integer', - description: 'User age', - default: 30 - }, - score: { - type: 'number', - description: 'User score', - default: 95.5 - }, - status: { - type: 'string', - description: 'User status', - enum: ['active', 'inactive', 'pending'], - default: 'active' - }, - verified: { - type: 'boolean', - description: 'Verification status', - default: true - } - }, - required: [] - } - } - }); - - const elicitResult = result as { action?: string; content?: unknown }; - return { - content: [ - { - type: 'text', - text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` - } - ] - }; - } - } - ); - - // SEP-1330: Elicitation with enum schema improvements - mcpServer.registerTool( - 'test_elicitation_sep1330_enums', - { - description: 'Tests elicitation with enum schema improvements per SEP-1330', - inputSchema: z.object({}) - }, - async (_args, ctx): Promise => { - try { - // Request user input with all 5 enum schema variants - const result = await ctx.mcpReq.send({ - method: 'elicitation/create', - params: { - message: 'Please select options from the enum fields', - requestedSchema: { - type: 'object', - properties: { - // Untitled single-select enum (basic) - untitledSingle: { - type: 'string', - description: 'Select one option', - enum: ['option1', 'option2', 'option3'] - }, - // Titled single-select enum (using oneOf with const/title) - titledSingle: { - type: 'string', - description: 'Select one option with titles', - oneOf: [ - { const: 'value1', title: 'First Option' }, - { const: 'value2', title: 'Second Option' }, - { const: 'value3', title: 'Third Option' } - ] - }, - // Legacy titled enum (using enumNames - deprecated) - legacyEnum: { - type: 'string', - description: 'Select one option (legacy)', - enum: ['opt1', 'opt2', 'opt3'], - enumNames: ['Option One', 'Option Two', 'Option Three'] - }, - // Untitled multi-select enum - untitledMulti: { - type: 'array', - description: 'Select multiple options', - minItems: 1, - maxItems: 3, - items: { - type: 'string', - enum: ['option1', 'option2', 'option3'] - } - }, - // Titled multi-select enum (using anyOf with const/title) - titledMulti: { - type: 'array', - description: 'Select multiple options with titles', - minItems: 1, - maxItems: 3, - items: { - anyOf: [ - { const: 'value1', title: 'First Choice' }, - { const: 'value2', title: 'Second Choice' }, - { const: 'value3', title: 'Third Choice' } - ] - } - } - }, - required: [] - } - } - }); - - const elicitResult = result as { action?: string; content?: unknown }; - return { - content: [ - { - type: 'text', - text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` - } - ] - }; - } - } - ); - - // SEP-1613: JSON Schema 2020-12 conformance test tool - mcpServer.registerTool( - 'json_schema_2020_12_tool', - { - description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', - inputSchema: z.object({ - name: z.string().optional(), - address: z - .object({ - street: z.string().optional(), - city: z.string().optional() - }) - .optional() - }) - }, - async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { - return { - content: [ - { - type: 'text', - text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}` - } - ] - }; - } - ); - - // ===== RESOURCES ===== - - // Static text resource - mcpServer.registerResource( - 'static-text', - 'test://static-text', - { - title: 'Static Text Resource', - description: 'A static text resource for testing', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'test://static-text', - mimeType: 'text/plain', - text: 'This is the content of the static text resource.' - } - ] - }; - } - ); - - // Static binary resource - mcpServer.registerResource( - 'static-binary', - 'test://static-binary', - { - title: 'Static Binary Resource', - description: 'A static binary resource (image) for testing', - mimeType: 'image/png' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'test://static-binary', - mimeType: 'image/png', - blob: TEST_IMAGE_BASE64 - } - ] - }; - } - ); - - // Resource template - mcpServer.registerResource( - 'template', - new ResourceTemplate('test://template/{id}/data', { list: undefined }), - { - title: 'Resource Template', - description: 'A resource template with parameter substitution', - mimeType: 'application/json' - }, - async (uri, variables): Promise => { - const id = variables.id; - return { - contents: [ - { - uri: uri.toString(), - mimeType: 'application/json', - text: JSON.stringify({ - id, - templateTest: true, - data: `Data for ID: ${id}` - }) - } - ] - }; - } - ); - - // Watched resource - mcpServer.registerResource( - 'watched-resource', - 'test://watched-resource', - { - title: 'Watched Resource', - description: 'A resource that auto-updates every 3 seconds', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'test://watched-resource', - mimeType: 'text/plain', - text: watchedResourceContent - } - ] - }; - } - ); - - // Subscribe/Unsubscribe handlers - mcpServer.server.setRequestHandler('resources/subscribe', async request => { - const uri = request.params.uri; - resourceSubscriptions.add(uri); - sendLog('info', `Subscribed to resource: ${uri}`); - return {}; - }); - - mcpServer.server.setRequestHandler('resources/unsubscribe', async request => { - const uri = request.params.uri; - resourceSubscriptions.delete(uri); - sendLog('info', `Unsubscribed from resource: ${uri}`); - return {}; - }); - - // ===== PROMPTS ===== - - // Simple prompt - mcpServer.registerPrompt( - 'test_simple_prompt', - { - title: 'Simple Test Prompt', - description: 'A simple prompt without arguments' - }, - async (): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: 'This is a simple prompt for testing.' - } - } - ] - }; - } - ); - - // Prompt with arguments - mcpServer.registerPrompt( - 'test_prompt_with_arguments', - { - title: 'Prompt With Arguments', - description: 'A prompt with required arguments', - argsSchema: z.object({ - arg1: z.string().describe('First test argument'), - arg2: z.string().describe('Second test argument') - }) - }, - async (args: { arg1: string; arg2: string }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'` - } - } - ] - }; - } - ); - - // Prompt with embedded resource - mcpServer.registerPrompt( - 'test_prompt_with_embedded_resource', - { - title: 'Prompt With Embedded Resource', - description: 'A prompt that includes an embedded resource', - argsSchema: z.object({ - resourceUri: z.string().describe('URI of the resource to embed') - }) - }, - async (args: { resourceUri: string }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'resource', - resource: { - uri: args.resourceUri, - mimeType: 'text/plain', - text: 'Embedded resource content for testing.' - } - } - }, - { - role: 'user', - content: { - type: 'text', - text: 'Please process the embedded resource above.' - } - } - ] - }; - } - ); - - // Prompt with image - mcpServer.registerPrompt( - 'test_prompt_with_image', - { - title: 'Prompt With Image', - description: 'A prompt that includes image content' - }, - async (): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'image', - data: TEST_IMAGE_BASE64, - mimeType: 'image/png' - } - }, - { - role: 'user', - content: { type: 'text', text: 'Please analyze the image above.' } - } - ] - }; - } - ); - - // ===== LOGGING ===== - - mcpServer.server.setRequestHandler('logging/setLevel', async request => { - const level = request.params.level; - sendLog('info', `Log level set to: ${level}`); - return {}; - }); - - // ===== COMPLETION ===== - - mcpServer.server.setRequestHandler('completion/complete', async () => { - // Basic completion support - returns empty array for conformance - // Real implementations would provide contextual suggestions - return { - completion: { - values: [], - total: 0, - hasMore: false - } - }; - }); - - return mcpServer; -} - // ===== EXPRESS APP ===== const app = express(); @@ -901,7 +52,12 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // Create new transport for initialization requests - const mcpServer = createMcpServer(); + const mcpServer = createMcpServer({ + closeSSEForReconnectTest: ctx => { + const t = ctx.sessionId ? transports[ctx.sessionId] : undefined; + if (t && ctx.mcpReq.id !== undefined) t.closeSSEStream(ctx.mcpReq.id); + } + }); transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), diff --git a/test/conformance/src/everythingServerDispatchV2.ts b/test/conformance/src/everythingServerDispatchV2.ts new file mode 100644 index 0000000000..a5bcbed31c --- /dev/null +++ b/test/conformance/src/everythingServerDispatchV2.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +/** + * MCP conformance server for the dispatch-v2 architecture. + * + * Stateless (2026-06) requests are served by `handleHttp(server)` — one shared + * `Server` instance, no transport class, no connect(). Legacy (pre-2026) + * requests are served by the existing user-session-map pattern with a + * per-session `NodeStreamableHTTPServerTransport`. The `app.all` handler routes + * by `MCP-Protocol-Version` header (falling back to `_meta.protocolVersion` + * from the parsed body). + * + * This demonstrates the design intent: 2026-06 path is RFC-simple (handleHttp), + * pre-2026 path is unchanged. + */ + +import { localhostHostValidation } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { isInitializeRequest, isStatelessProtocolVersion, META_KEYS, statelessHttpHandler } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import express from 'express'; + +import { createEventStore, createMcpServer } from './everythingServerSetup.js'; + +// One shared server for the stateless path. +const sharedServer = createMcpServer({ closeSSEForReconnectTest: () => {} }).server; +const handlers = sharedServer.statelessHandlers(); + +// Per-session transports for the legacy path. +const sessions: Record = {}; + +function statelessVersion(req: Request): string | undefined { + const h = req.get('mcp-protocol-version'); + if (h) return isStatelessProtocolVersion(h) ? h : undefined; + const body = req.body; + const first = Array.isArray(body) ? body[0] : body; + const v = first?.params?._meta?.[META_KEYS.protocolVersion]; + return typeof v === 'string' && isStatelessProtocolVersion(v) ? v : undefined; +} + +const app = express(); +app.use(express.json()); +app.use(localhostHostValidation()); +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version'] + }) +); + +app.all('/mcp', async (req: Request, res: Response) => { + try { + // 2026-06 → statelessHttpHandler (no Transport, no session). + if (statelessVersion(req)) { + const fReq = new globalThis.Request(`http://${req.get('host') ?? 'localhost'}${req.url}`, { + method: req.method, + headers: Object.entries(req.headers).flatMap(([k, v]) => (typeof v === 'string' ? [[k, v]] : [])) as [string, string][] + }); + const fRes = await statelessHttpHandler(handlers, fReq, { parsedBody: req.body }); + res.status(fRes.status); + for (const [k, v] of fRes.headers.entries()) res.setHeader(k, v); + if (fRes.body) { + const reader = fRes.body.getReader(); + req.on('close', () => void reader.cancel().catch(() => {})); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + } + res.end(); + return; + } + + // Pre-2026 → user-owned session map (unchanged from existing pattern). + const sid = req.get('mcp-session-id'); + if (sid && sessions[sid]) { + return sessions[sid]!.handleRequest(req, res, req.body); + } + if (req.method === 'POST' && isInitializeRequest(req.body)) { + const mcp = createMcpServer({ closeSSEForReconnectTest: ctx => ctx.http?.closeSSE?.() }); + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + eventStore: createEventStore(), + onsessioninitialized: id => { + sessions[id] = transport; + } + }); + await mcp.connect(transport); + return transport.handleRequest(req, res, req.body); + } + if (sid) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_600, message: 'Bad Request: missing session ID' }, id: null }); + } + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id: null }); + } + } +}); + +const PORT = Number(process.env.PORT ?? 3000); +app.listen(PORT, () => { + console.log(`MCP Conformance Test Server (dispatch-v2: handleHttp + legacy session map) on http://localhost:${PORT}/mcp`); +}); diff --git a/test/conformance/src/everythingServerSetup.ts b/test/conformance/src/everythingServerSetup.ts new file mode 100644 index 0000000000..4c67f3d8c2 --- /dev/null +++ b/test/conformance/src/everythingServerSetup.ts @@ -0,0 +1,563 @@ +/** + * Shared MCP conformance server setup. Registers all tools/resources/prompts on a + * fresh {@linkcode McpServer}. Consumed by both conformance entry points so the + * harness exercises the same handler set against: + * - {@linkcode ./everythingServer.ts} — `transport.connect()` / `NodeStreamableHTTPServerTransport` + * - {@linkcode ./everythingServerDispatchV2.ts} — per-message router (pre-2026 + 2026-06) + */ + +import { randomUUID } from 'node:crypto'; + +import type { + CallToolResult, + EventId, + EventStore, + GetPromptResult, + ReadResourceResult, + ServerContext, + StreamId +} from '@modelcontextprotocol/server'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const resourceSubscriptions = new Set(); +const watchedResourceContent = 'Watched resource content'; + +/** Sample base64-encoded 1×1 red PNG pixel for testing. */ +export const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; + +/** Sample base64-encoded minimal WAV file for testing. */ +export const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + +/** In-memory {@linkcode EventStore} for SEP-1699 SSE resumability. */ +export function createEventStore(): EventStore { + const data = new Map(); + return { + async storeEvent(streamId: StreamId, message: unknown): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + data.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async getStreamIdForEventId(eventId: EventId): Promise { + return data.get(eventId)?.streamId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: unknown) => Promise } + ): Promise { + const streamId = lastEventId.split('::')[0] || lastEventId; + const eventsToReplay: Array<[string, { message: unknown }]> = []; + for (const [eventId, ev] of data.entries()) { + if (ev.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, ev]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (message && typeof message === 'object' && Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; +} + +export interface SetupOptions { + /** + * Hook for the SEP-1699 reconnection test. Called mid-handler to forcibly close the + * current SSE stream so the client must reconnect with `Last-Event-ID`. + * The two entry points wire this differently (transport-map lookup vs `ctx.http?.closeSSE`). + */ + closeSSEForReconnectTest: (ctx: ServerContext) => void; +} + +/** + * Builds a fully-registered conformance {@linkcode McpServer}. All registrations are + * stateless on the server instance, so the entry point decides whether to create one + * per session (transport.connect path) or per request (handleStatelessHttp path). + */ +export function createMcpServer(opts: SetupOptions): McpServer { + const mcpServer = new McpServer( + { name: 'mcp-conformance-test-server', version: '1.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + resources: { subscribe: true, listChanged: true }, + prompts: { listChanged: true }, + logging: {}, + completions: {} + } + } + ); + + function sendLog( + level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', + message: string, + _data?: unknown + ) { + mcpServer.server + .notification({ method: 'notifications/message', params: { level, logger: 'conformance-test-server', data: _data || message } }) + .catch(() => { + // Ignore error if no client is connected. + }); + } + + // ===== TOOLS ===== + + mcpServer.registerTool('test_simple_text', { description: 'Tests simple text content response' }, async (): Promise => { + return { content: [{ type: 'text', text: 'This is a simple text response for testing.' }] }; + }); + + mcpServer.registerTool('test_image_content', { description: 'Tests image content response' }, async (): Promise => { + return { content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }] }; + }); + + mcpServer.registerTool('test_audio_content', { description: 'Tests audio content response' }, async (): Promise => { + return { content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }] }; + }); + + mcpServer.registerTool( + 'test_embedded_resource', + { description: 'Tests embedded resource content response' }, + async (): Promise => { + return { + content: [ + { + type: 'resource', + resource: { uri: 'test://embedded-resource', mimeType: 'text/plain', text: 'This is an embedded resource content.' } + } + ] + }; + } + ); + + mcpServer.registerTool( + 'test_multiple_content_types', + { description: 'Tests response with multiple content types (text, image, resource)' }, + async (): Promise => { + return { + content: [ + { type: 'text', text: 'Multiple content types test:' }, + { type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }, + { + type: 'resource', + resource: { + uri: 'test://mixed-content-resource', + mimeType: 'application/json', + text: JSON.stringify({ test: 'data', value: 123 }) + } + } + ] + }; + } + ); + + mcpServer.registerTool( + 'test_tool_with_logging', + { description: 'Tests tool that emits log messages during execution', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'Tool execution started' } }); + await new Promise(resolve => setTimeout(resolve, 50)); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'Tool processing data' } }); + await new Promise(resolve => setTimeout(resolve, 50)); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'Tool execution completed' } }); + return { content: [{ type: 'text', text: 'Tool with logging executed successfully' }] }; + } + ); + + mcpServer.registerTool( + 'test_tool_with_progress', + { description: 'Tests tool that reports progress notifications', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + const progressToken = ctx.mcpReq._meta?.progressToken ?? 0; + console.log('Progress token:', progressToken); + for (const progress of [0, 50, 100]) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress, total: 100, message: `Completed step ${progress} of ${100}` } + }); + if (progress !== 100) await new Promise(resolve => setTimeout(resolve, 50)); + } + return { content: [{ type: 'text', text: String(progressToken) }] }; + } + ); + + mcpServer.registerTool('test_error_handling', { description: 'Tests error response handling' }, async (): Promise => { + throw new Error('This tool intentionally returns an error for testing'); + }); + + mcpServer.registerTool( + 'test_reconnection', + { + description: + 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + const sid = ctx.sessionId; + console.log(`[${sid}] Starting test_reconnection tool...`); + console.log(`[${sid}] Closing SSE stream to trigger client polling...`); + opts.closeSSEForReconnectTest(ctx); + await sleep(100); + console.log(`[${sid}] test_reconnection tool complete`); + return { + content: [ + { + type: 'text', + text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.' + } + ] + }; + } + ); + + mcpServer.registerTool( + 'test_sampling', + { + description: 'Tests server-initiated sampling (LLM completion request)', + inputSchema: z.object({ prompt: z.string().describe('The prompt to send to the LLM') }) + }, + async (args: { prompt: string }, ctx): Promise => { + try { + const result = (await ctx.mcpReq.send({ + method: 'sampling/createMessage', + params: { messages: [{ role: 'user', content: { type: 'text', text: args.prompt } }], maxTokens: 100 } + })) as { content?: { text?: string }; message?: { content?: { text?: string } } }; + const modelResponse = result.content?.text || result.message?.content?.text || 'No response'; + return { content: [{ type: 'text', text: `LLM response: ${modelResponse}` }] }; + } catch (error) { + return { + content: [ + { type: 'text', text: `Sampling not supported or error: ${error instanceof Error ? error.message : String(error)}` } + ] + }; + } + } + ); + + mcpServer.registerTool( + 'test_elicitation', + { + description: 'Tests server-initiated elicitation (user input request)', + inputSchema: z.object({ message: z.string().describe('The message to show the user') }) + }, + async (args: { message: string }, ctx): Promise => { + try { + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + message: args.message, + requestedSchema: { + type: 'object', + properties: { response: { type: 'string', description: "User's response" } }, + required: ['response'] + } + } + }); + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `User response: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + mcpServer.registerTool( + 'test_elicitation_sep1034_defaults', + { description: 'Tests elicitation with default values per SEP-1034', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + try { + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + message: 'Please review and update the form fields with defaults', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'User name', default: 'John Doe' }, + age: { type: 'integer', description: 'User age', default: 30 }, + score: { type: 'number', description: 'User score', default: 95.5 }, + status: { + type: 'string', + description: 'User status', + enum: ['active', 'inactive', 'pending'], + default: 'active' + }, + verified: { type: 'boolean', description: 'Verification status', default: true } + }, + required: [] + } + } + }); + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + mcpServer.registerTool( + 'test_elicitation_sep1330_enums', + { description: 'Tests elicitation with enum schema improvements per SEP-1330', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + try { + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + message: 'Please select options from the enum fields', + requestedSchema: { + type: 'object', + properties: { + untitledSingle: { + type: 'string', + description: 'Select one option', + enum: ['option1', 'option2', 'option3'] + }, + titledSingle: { + type: 'string', + description: 'Select one option with titles', + oneOf: [ + { const: 'value1', title: 'First Option' }, + { const: 'value2', title: 'Second Option' }, + { const: 'value3', title: 'Third Option' } + ] + }, + legacyEnum: { + type: 'string', + description: 'Select one option (legacy)', + enum: ['opt1', 'opt2', 'opt3'], + enumNames: ['Option One', 'Option Two', 'Option Three'] + }, + untitledMulti: { + type: 'array', + description: 'Select multiple options', + minItems: 1, + maxItems: 3, + items: { type: 'string', enum: ['option1', 'option2', 'option3'] } + }, + titledMulti: { + type: 'array', + description: 'Select multiple options with titles', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: 'value1', title: 'First Choice' }, + { const: 'value2', title: 'Second Choice' }, + { const: 'value3', title: 'Third Choice' } + ] + } + } + }, + required: [] + } + } + }); + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + mcpServer.registerTool( + 'json_schema_2020_12_tool', + { + description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', + inputSchema: z.object({ + name: z.string().optional(), + address: z.object({ street: z.string().optional(), city: z.string().optional() }).optional() + }) + }, + async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + return { content: [{ type: 'text', text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}` }] }; + } + ); + + // ===== RESOURCES ===== + + mcpServer.registerResource( + 'static-text', + 'test://static-text', + { title: 'Static Text Resource', description: 'A static text resource for testing', mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [{ uri: 'test://static-text', mimeType: 'text/plain', text: 'This is the content of the static text resource.' }] + }; + } + ); + + mcpServer.registerResource( + 'static-binary', + 'test://static-binary', + { title: 'Static Binary Resource', description: 'A static binary resource (image) for testing', mimeType: 'image/png' }, + async (): Promise => { + return { contents: [{ uri: 'test://static-binary', mimeType: 'image/png', blob: TEST_IMAGE_BASE64 }] }; + } + ); + + mcpServer.registerResource( + 'template', + new ResourceTemplate('test://template/{id}/data', { list: undefined }), + { title: 'Resource Template', description: 'A resource template with parameter substitution', mimeType: 'application/json' }, + async (uri, variables): Promise => { + const id = variables.id; + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'application/json', + text: JSON.stringify({ id, templateTest: true, data: `Data for ID: ${id}` }) + } + ] + }; + } + ); + + mcpServer.registerResource( + 'watched-resource', + 'test://watched-resource', + { title: 'Watched Resource', description: 'A resource that auto-updates every 3 seconds', mimeType: 'text/plain' }, + async (): Promise => { + return { contents: [{ uri: 'test://watched-resource', mimeType: 'text/plain', text: watchedResourceContent }] }; + } + ); + + mcpServer.server.setRequestHandler('resources/subscribe', async request => { + const uri = request.params.uri; + resourceSubscriptions.add(uri); + sendLog('info', `Subscribed to resource: ${uri}`); + return {}; + }); + + mcpServer.server.setRequestHandler('resources/unsubscribe', async request => { + const uri = request.params.uri; + resourceSubscriptions.delete(uri); + sendLog('info', `Unsubscribed from resource: ${uri}`); + return {}; + }); + + // ===== PROMPTS ===== + + mcpServer.registerPrompt( + 'test_simple_prompt', + { title: 'Simple Test Prompt', description: 'A simple prompt without arguments' }, + async (): Promise => { + return { messages: [{ role: 'user', content: { type: 'text', text: 'This is a simple prompt for testing.' } }] }; + } + ); + + mcpServer.registerPrompt( + 'test_prompt_with_arguments', + { + title: 'Prompt With Arguments', + description: 'A prompt with required arguments', + argsSchema: z.object({ arg1: z.string().describe('First test argument'), arg2: z.string().describe('Second test argument') }) + }, + async (args: { arg1: string; arg2: string }): Promise => { + return { + messages: [ + { role: 'user', content: { type: 'text', text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'` } } + ] + }; + } + ); + + mcpServer.registerPrompt( + 'test_prompt_with_embedded_resource', + { + title: 'Prompt With Embedded Resource', + description: 'A prompt that includes an embedded resource', + argsSchema: z.object({ resourceUri: z.string().describe('URI of the resource to embed') }) + }, + async (args: { resourceUri: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { uri: args.resourceUri, mimeType: 'text/plain', text: 'Embedded resource content for testing.' } + } + }, + { role: 'user', content: { type: 'text', text: 'Please process the embedded resource above.' } } + ] + }; + } + ); + + mcpServer.registerPrompt( + 'test_prompt_with_image', + { title: 'Prompt With Image', description: 'A prompt that includes image content' }, + async (): Promise => { + return { + messages: [ + { role: 'user', content: { type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' } }, + { role: 'user', content: { type: 'text', text: 'Please analyze the image above.' } } + ] + }; + } + ); + + // ===== LOGGING ===== + + mcpServer.server.setRequestHandler('logging/setLevel', async request => { + const level = request.params.level; + sendLog('info', `Log level set to: ${level}`); + return {}; + }); + + // ===== COMPLETION ===== + + mcpServer.server.setRequestHandler('completion/complete', async () => { + return { completion: { values: [], total: 0, hasMore: false } }; + }); + + return mcpServer; +} diff --git a/test/integration/test/__fixtures__/testClient.ts b/test/integration/test/__fixtures__/testClient.ts new file mode 100644 index 0000000000..444d2a77f1 --- /dev/null +++ b/test/integration/test/__fixtures__/testClient.ts @@ -0,0 +1,19 @@ +import type { ClientOptions } from '@modelcontextprotocol/client'; +import { Client } from '@modelcontextprotocol/client'; +import type { Implementation } from '@modelcontextprotocol/core'; +import { STATEFUL_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; + +/** + * A {@linkcode Client} that only advertises pre-2026 protocol versions, so + * `connect()` skips the `server/discover` probe and goes straight to legacy + * `initialize`. Use in tests that exercise the connection-model + * (server-to-client RPCs via `Protocol`, `oninitialized`, in-band logging). + */ +export class LegacyTestClient extends Client { + constructor(clientInfo: Implementation, options?: ClientOptions) { + super(clientInfo, { + ...options, + supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS.filter(v => STATEFUL_PROTOCOL_VERSIONS.includes(v)) + }); + } +} diff --git a/test/integration/test/stateManagementStreamableHttp.test.ts b/test/integration/test/stateManagementStreamableHttp.test.ts index 3f32c64a34..30166f1953 100644 --- a/test/integration/test/stateManagementStreamableHttp.test.ts +++ b/test/integration/test/stateManagementStreamableHttp.test.ts @@ -2,12 +2,14 @@ import { randomUUID } from 'node:crypto'; import type { Server } from 'node:http'; import { createServer } from 'node:http'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { LATEST_PROTOCOL_VERSION, McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import * as z from 'zod/v4'; +import { LegacyTestClient } from './__fixtures__/testClient.js'; + async function setupServer(withSessionManagement: boolean) { const server: Server = createServer(); const mcpServer = new McpServer( @@ -104,7 +106,7 @@ describe('Zod v4', () => { it('should support multiple client connections', async () => { // Create and connect a client - const client1 = new Client({ + const client1 = new LegacyTestClient({ name: 'test-client', version: '1.0.0' }); @@ -121,7 +123,7 @@ describe('Zod v4', () => { params: {} }); - const client2 = new Client({ + const client2 = new LegacyTestClient({ name: 'test-client', version: '1.0.0' }); @@ -140,7 +142,7 @@ describe('Zod v4', () => { }); it('should operate without session management', async () => { // Create and connect a client - const client = new Client({ + const client = new LegacyTestClient({ name: 'test-client', version: '1.0.0' }); @@ -207,7 +209,7 @@ describe('Zod v4', () => { it('should set protocol version after connecting', async () => { // Create and connect a client - const client = new Client({ + const client = new LegacyTestClient({ name: 'test-client', version: '1.0.0' }); @@ -250,7 +252,7 @@ describe('Zod v4', () => { it('should operate with session management', async () => { // Create and connect a client - const client = new Client({ + const client = new LegacyTestClient({ name: 'test-client', version: '1.0.0' }); diff --git a/test/integration/test/statelessAcceptance.test.ts b/test/integration/test/statelessAcceptance.test.ts new file mode 100644 index 0000000000..1096fd8154 --- /dev/null +++ b/test/integration/test/statelessAcceptance.test.ts @@ -0,0 +1,281 @@ +import type { JSONRPCNotification } from '@modelcontextprotocol/core'; +import { META_KEYS, ProtocolErrorCode } from '@modelcontextprotocol/core'; +import type { StatelessHandlers } from '@modelcontextprotocol/server'; +import { handleHttp, InMemorySubscriptions, McpServer, Server } from '@modelcontextprotocol/server'; +import { describe, expect, test } from 'vitest'; + +const STATELESS_META = { + [META_KEYS.protocolVersion]: 'DRAFT-2026-v1', + [META_KEYS.clientInfo]: { name: 't', version: '1' }, + [META_KEYS.clientCapabilities]: {} +}; + +function jreq(id: number, method: string, params?: Record) { + return { jsonrpc: '2.0' as const, id, method, params: { ...params, _meta: STATELESS_META } }; +} + +function makeServer(): Server { + const mcp = new McpServer({ name: 'srv', version: '1' }, { capabilities: { tools: { listChanged: true }, logging: {} } }); + mcp.registerTool('echo', { description: 'echo', inputSchema: {} }, async (_args, ctx) => { + await ctx.mcpReq.log('info', 'handling echo'); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + mcp.registerTool('elicit', { description: 'elicit', inputSchema: {} }, async (_args, ctx) => { + const r = await ctx.mcpReq.elicitInput({ message: 'q', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: r.action }] }; + }); + return mcp.server; +} + +describe('Server stateless dispatch', () => { + const d: StatelessHandlers['dispatch'] = new Server({ name: 's', version: '1' }, { capabilities: {} }).statelessHandlers().dispatch; + + test('R-2575: server/discover returns supportedVersions/capabilities/serverInfo', async () => { + const r = await d(jreq(1, 'server/discover'), { notify: () => {} }); + expect('result' in r && (r.result as { supportedVersions: string[] }).supportedVersions).toContain('DRAFT-2026-v1'); + }); + + test('R-2575: removed methods return -32601', async () => { + for (const m of ['initialize', 'ping', 'logging/setLevel', 'resources/subscribe']) { + const r = await d(jreq(1, m), { notify: () => {} }); + expect('error' in r && r.error.code).toBe(ProtocolErrorCode.MethodNotFound); + } + }); + + test('R-2575: resultType filled to complete when absent', async () => { + const server = new Server({ name: 's', version: '1' }, { capabilities: {} }); + server.fallbackRequestHandler = async () => ({}); + const r = await server.statelessHandlers().dispatch(jreq(1, 'x'), { notify: () => {} }); + expect('result' in r && (r.result as { resultType: string }).resultType).toBe('complete'); + }); + + test('R-2322: InputRequiredError without client cap → -32003', async () => { + const server = makeServer(); + const r = await server.statelessHandlers().dispatch(jreq(1, 'tools/call', { name: 'elicit', arguments: {} }), { + notify: () => {} + }); + expect('error' in r && r.error.code).toBe(ProtocolErrorCode.MissingRequiredClientCapability); + expect('error' in r && (r.error.data as { requiredCapabilities: object }).requiredCapabilities).toEqual({ elicitation: {} }); + }); + + test('R-2575: ctx.mcpReq.log gates on _meta.logLevel', async () => { + const server = makeServer(); + const dd = server.statelessHandlers().dispatch; + const seen: JSONRPCNotification[] = []; + const r = await dd(jreq(1, 'tools/call', { name: 'echo', arguments: {} }), { + notify: n => seen.push(n) + }); + expect('result' in r).toBe(true); + expect(seen).toHaveLength(0); // no logLevel in _meta → suppressed + // With logLevel: + const seen2: JSONRPCNotification[] = []; + await dd( + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: { ...STATELESS_META, [META_KEYS.logLevel]: 'info' } } + }, + { notify: n => seen2.push(n) } + ); + expect(seen2.map(n => n.method)).toContain('notifications/message'); + }); +}); + +describe('SubscriptionBackend', () => { + test('handle → ack first; notify delivers; close ends', async () => { + const subs = new InMemorySubscriptions(); + const { stream, close } = subs.handle( + { id: 7, method: 'subscriptions/listen', params: { notifications: { toolsListChanged: true } } }, + {}, + { tools: { listChanged: true } } + ); + const it = stream[Symbol.asyncIterator](); + const ack = await it.next(); + expect((ack.value as JSONRPCNotification).method).toBe('notifications/subscriptions/acknowledged'); + const subId = ((ack.value as JSONRPCNotification).params!._meta as Record)[META_KEYS.subscriptionId]; + expect(subId).toBe('7'); // SEP-2575: subscriptionId is the listen request's JSON-RPC id + subs.notify({ type: 'toolsListChanged' }); + const n = await it.next(); + expect((n.value as JSONRPCNotification).method).toBe('notifications/tools/list_changed'); + close(); + const last = await it.next(); + expect(last.done).toBe(true); + }); + + test('resourceSubscriptions fail-closed without onAuthorizeResourceSubscription', async () => { + const subs = new InMemorySubscriptions(); + const { stream } = subs.handle( + { id: 8, method: 'subscriptions/listen', params: { notifications: { resourceSubscriptions: ['file:///a'] } } }, + {}, + { resources: { subscribe: true } } + ); + // Ack filter should NOT include resourceSubscriptions. + const first = await stream[Symbol.asyncIterator]().next(); + const ack = first.value as JSONRPCNotification; + expect((ack.params as { notifications: Record }).notifications.resourceSubscriptions).toBeUndefined(); + }); +}); + +describe('handleHttp', () => { + const handler = handleHttp(makeServer()); + + test('POST tools/list → 200 JSON', async () => { + const res = await handler( + new Request('http://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', 'mcp-protocol-version': 'DRAFT-2026-v1' }, + body: JSON.stringify(jreq(1, 'tools/list')) + }) + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { tools: unknown[] } }; + expect(body.result.tools.length).toBeGreaterThan(0); + }); + + test('R-2575: GET → 405', async () => { + const res = await handler(new Request('http://x/mcp', { method: 'GET' })); + expect(res.status).toBe(405); + }); + + test('R-2575: unknown method → 404', async () => { + const res = await handler( + new Request('http://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(jreq(1, 'unknown/method')) + }) + ); + expect(res.status).toBe(404); + }); + + test('R-2243: header/body version mismatch → 400 -32001', async () => { + const res = await handler( + new Request('http://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', 'mcp-protocol-version': 'DRAFT-2026-v2' }, + body: JSON.stringify(jreq(1, 'tools/list')) + }) + ); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: { code: number } }).error.code).toBe(ProtocolErrorCode.HeaderMismatch); + }); + + test('subscriptions/listen without Accept SSE → 406', async () => { + const res = await handler( + new Request('http://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(jreq(1, 'subscriptions/listen', { notifications: { toolsListChanged: true } })) + }) + ); + expect(res.status).toBe(406); + }); + + test('subscriptions/listen in batch → 400', async () => { + const res = await handler( + new Request('http://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify([jreq(1, 'subscriptions/listen', { notifications: {} }), jreq(2, 'tools/list')]) + }) + ); + expect(res.status).toBe(400); + }); + + test('empty batch → 400', async () => { + const res = await handler( + new Request('http://x/mcp', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '[]' }) + ); + expect(res.status).toBe(400); + }); + + test('allowedHosts handles bracketed IPv6 (validateHostHeader convention)', async () => { + const h = handleHttp(makeServer(), { allowedHosts: ['[::1]'] }); + const res = await h( + new Request('http://x/mcp', { + method: 'POST', + headers: { host: '[::1]:3000', 'content-type': 'application/json' }, + body: JSON.stringify(jreq(1, 'server/discover')) + }) + ); + expect(res.status).toBe(200); + }); + + test('allowedOrigins rejects missing Origin', async () => { + const h = handleHttp(makeServer(), { allowedOrigins: ['https://ok'] }); + const res = await h(new Request('http://x/mcp', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })); + expect(res.status).toBe(403); + }); + + test('rejects non-JSON Content-Type with 415', async () => { + const h = handleHttp(makeServer()); + const res = await h(new Request('http://x/mcp', { method: 'POST', headers: { 'content-type': 'text/plain' }, body: '{}' })); + expect(res.status).toBe(415); + }); + + test('rejects oversize batch with 400', async () => { + const h = handleHttp(makeServer()); + const big = Array.from({ length: 65 }, (_, i) => jreq(i, 'server/discover')); + const res = await h( + new Request('http://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(big) + }) + ); + expect(res.status).toBe(400); + }); +}); + +describe('Zero-change consumer (StreamableHTTP router serves both eras)', () => { + test('one transport.handleRequest serves legacy initialize AND stateless discover', async () => { + const { WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/server'); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => 'sid' }); + const server = makeServer(); + await server.connect(transport); + + // 2026-06 stateless: header present + const r1 = await transport.handleRequest( + new Request('http://x/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + 'mcp-protocol-version': 'DRAFT-2026-v1' + }, + body: JSON.stringify(jreq(1, 'server/discover')) + }) + ); + expect(r1.status).toBe(200); + expect(((await r1.json()) as { result: { supportedVersions: string[] } }).result.supportedVersions).toContain('DRAFT-2026-v1'); + + // Legacy: no header → handleStatefulRequest → initialize works + const r2 = await transport.handleRequest( + new Request('http://x/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '1' } } + }) + }) + ); + expect(r2.status).toBe(200); + // SSE body; just check it's not an error status. + }); +}); + +describe('Audit invariants', () => { + test('stateless dispatch writes nothing to per-client instance state', async () => { + const server = new Server({ name: 's', version: '1' }, { capabilities: {} }); + server.fallbackRequestHandler = async () => ({}); + const d = server.statelessHandlers().dispatch; + const before = server.getClientCapabilities(); + await Promise.all([1, 2, 3, 4, 5].map(i => d(jreq(i, 'x'), { notify: () => {} }))); + expect(server.getClientCapabilities()).toBe(before); + expect(server.getClientVersion()).toBeUndefined(); + }); +}); diff --git a/test/integration/test/taskResumability.test.ts b/test/integration/test/taskResumability.test.ts index f7b4174d18..da52bccf4e 100644 --- a/test/integration/test/taskResumability.test.ts +++ b/test/integration/test/taskResumability.test.ts @@ -2,13 +2,15 @@ import { randomUUID } from 'node:crypto'; import type { Server } from 'node:http'; import { createServer } from 'node:http'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server'; import { McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import * as z from 'zod/v4'; +import { LegacyTestClient } from './__fixtures__/testClient.js'; + /** * Simple in-memory EventStore for testing resumability. */ @@ -143,7 +145,7 @@ describe('Zod v4', () => { it('should store session ID when client connects', async () => { // Create and connect a client - const client = new Client({ + const client = new LegacyTestClient({ name: 'test-client', version: '1.0.0' }); @@ -160,7 +162,7 @@ describe('Zod v4', () => { it('should have session ID functionality', async () => { // The ability to store a session ID when connecting - const client = new Client({ + const client = new LegacyTestClient({ name: 'test-client-reconnection', version: '1.0.0' }); @@ -184,7 +186,7 @@ describe('Zod v4', () => { let lastEventId: string | undefined; // Create first client - const client1 = new Client({ + const client1 = new LegacyTestClient({ title: clientTitle, name: 'test-client', version: '1.0.0' @@ -261,7 +263,7 @@ describe('Zod v4', () => { await catchPromise; // Create second client with same client ID - const client2 = new Client({ + const client2 = new LegacyTestClient({ title: clientTitle, name: 'test-client', version: '1.0.0'