diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644 --- a/dist/cjs/client/index.d.ts +++ b/dist/cjs/client/index.d.ts @@ -428,6 +428,8 @@ export declare class Client>; + callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ [x: string]: unknown; content: ({ diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js index 6ac1da14dc7f6211ae70f7711c124b76098816d8..88e58b90b673cb0f9c60920edec7d2ebb16f112b 100644 --- a/dist/cjs/client/index.js +++ b/dist/cjs/client/index.js @@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol { } async connect(transport, options) { await super.connect(transport); + transport.onsessionexpired = async () => { + await this._initialize(transport); + }; // When transport sessionId is already set this means we are trying to reconnect. // In this case we don't need to initialize again. if (transport.sessionId !== undefined) { return; } try { - const result = await this.request({ - method: 'initialize', - params: { - protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, - capabilities: this._capabilities, - clientInfo: this._clientInfo - } - }, types_js_1.InitializeResultSchema, options); - if (result === undefined) { - throw new Error(`Server sent invalid initialize result: ${result}`); - } - if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { - throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); - } - this._serverCapabilities = result.capabilities; - this._serverVersion = result.serverInfo; - // HTTP transports must set the protocol version in each header after initialization. - if (transport.setProtocolVersion) { - transport.setProtocolVersion(result.protocolVersion); - } - this._instructions = result.instructions; - await this.notification({ - method: 'notifications/initialized' - }); - // Set up list changed handlers now that we know server capabilities - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; - } + await this._initialize(transport, options); } catch (error) { // Disconnect if initialization fails. @@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol { throw error; } } + async _initialize(transport, options) { + const result = await this.request({ + method: 'initialize', + params: { + protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, types_js_1.InitializeResultSchema, options); + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } + if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } + this._serverCapabilities = result.capabilities; + this._serverVersion = result.serverInfo; + // HTTP transports must set the protocol version in each header after initialization. + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.protocolVersion); + } + this._instructions = result.instructions; + await this.notification({ + method: 'notifications/initialized' + }); + // Set up list changed handlers now that we know server capabilities + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } /** * After initialization has completed, this will be populated with the server's reported capabilities. */ diff --git a/dist/cjs/client/sse.js b/dist/cjs/client/sse.js index 8ca8dad652e76c299927f836cf622c0be877e1d4..cfbb08438a4e219fcd98d8d4ba8949079f3fcbe5 100644 --- a/dist/cjs/client/sse.js +++ b/dist/cjs/client/sse.js @@ -27,7 +27,8 @@ class SSEClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, opts?.requestInit); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, oauthRequestInit); } async _authThenStart() { if (!this._authProvider) { @@ -63,10 +64,11 @@ class SSEClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = (0, transport_js_1.normalizeHeaders)(this._requestInit?.headers); - return new Headers({ - ...headers, - ...extraHeaders - }); + const result = new Headers(extraHeaders); + for (const [name, value] of Object.entries(headers)) { + result.set(name, value); + } + return result; } _startOrAuth() { const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch); diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js index a29a7d3a0f14d9cd800ef5b296485237350c666f..f253f88023c10d44e6cdb74bcf2ffa722619fd0d 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js @@ -33,7 +33,8 @@ class StreamableHTTPClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, opts?.requestInit); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, oauthRequestInit); this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } @@ -74,10 +75,11 @@ class StreamableHTTPClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = (0, transport_js_1.normalizeHeaders)(this._requestInit?.headers); - return new Headers({ - ...headers, - ...extraHeaders - }); + const result = new Headers(extraHeaders); + for (const [name, value] of Object.entries(headers)) { + result.set(name, value); + } + return result; } async _startOrAuthSse(options) { const { resumptionToken } = options; @@ -290,7 +292,38 @@ class StreamableHTTPClientTransport { this.onclose?.(); } async send(message, options) { + return this._send(message, options, false); + } + async _recoverSession(expiredSessionId) { + if (this._sessionRecovery) { + await this._sessionRecovery; + return true; + } + if (this._sessionId !== expiredSessionId) + return true; + this._sessionId = undefined; + this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); try { + await this._sessionRecovery; + } + catch (error) { + this._sessionId = undefined; + await this.close(); + throw error; + } + finally { + this._sessionRecovery = undefined; + } + return true; + } + async _send(message, options, isSessionRetry) { + try { + if (this._sessionRecovery && !(0, types_js_1.isInitializeRequest)(message) && !(0, types_js_1.isInitializedNotification)(message)) { + await this._sessionRecovery; + if (options?.isRequestActive?.() === false) { + throw new Error('Request is no longer active'); + } + } const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream @@ -298,6 +331,7 @@ class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); + const requestSessionId = headers.get('mcp-session-id') ?? undefined; headers.set('content-type', 'application/json'); headers.set('accept', 'application/json, text/event-stream'); const init = { @@ -310,11 +344,20 @@ class StreamableHTTPClientTransport { const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get('mcp-session-id'); - if (sessionId) { + if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { this._sessionId = sessionId; } if (!response.ok) { const text = await response.text().catch(() => null); + if (response.status === 404 && requestSessionId && !isSessionRetry && !(0, types_js_1.isInitializedNotification)(message)) { + const recovered = await this._recoverSession(requestSessionId); + if (options?.isRequestActive?.() === false) { + throw new Error('Request is no longer active'); + } + if (recovered) { + return this._send(message, options, true); + } + } if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { @@ -335,7 +378,7 @@ class StreamableHTTPClientTransport { // Mark that we completed auth flow this._hasCompletedAuthFlow = true; // Purposely _not_ awaited, so we don't call onerror twice - return this.send(message); + return this._send(message, options, isSessionRetry); } if (response.status === 403 && this._authProvider) { const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response); @@ -362,7 +405,7 @@ class StreamableHTTPClientTransport { if (result !== 'AUTHORIZED') { throw new auth_js_1.UnauthorizedError(); } - return this.send(message); + return this._send(message, options, isSessionRetry); } } throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); diff --git a/dist/cjs/shared/protocol.js b/dist/cjs/shared/protocol.js index 3617e787f0ba70447c99501aee7aa67584d89758..4ee4d158391558fdc1f977f5134b7cacfc45e8c3 100644 --- a/dist/cjs/shared/protocol.js +++ b/dist/cjs/shared/protocol.js @@ -744,7 +744,12 @@ class Protocol { } else { // No related task - send through transport normally - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._transport.send(jsonrpcRequest, { + relatedRequestId, + resumptionToken, + onresumptiontoken, + isRequestActive: () => this._responseHandlers.has(messageId) + }).catch(error => { this._cleanupTimeout(messageId); reject(error); }); diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts index 6f567a193626587a2730b5a49293ca5dfd4181ea..5b7c841c000508e389ce617f559f7c2a5126ca9f 100644 --- a/dist/esm/client/index.d.ts +++ b/dist/esm/client/index.d.ts @@ -428,6 +428,8 @@ export declare class Client>; + callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ [x: string]: unknown; content: ({ diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js index 49b12c6cd918c457420fef7ad5528a9443d1a191..339153cb7e9299b7a9bdec0e56e41cedf425fb31 100644 --- a/dist/esm/client/index.js +++ b/dist/esm/client/index.js @@ -284,41 +284,16 @@ export class Client extends Protocol { } async connect(transport, options) { await super.connect(transport); + transport.onsessionexpired = async () => { + await this._initialize(transport); + }; // When transport sessionId is already set this means we are trying to reconnect. // In this case we don't need to initialize again. if (transport.sessionId !== undefined) { return; } try { - const result = await this.request({ - method: 'initialize', - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: this._capabilities, - clientInfo: this._clientInfo - } - }, InitializeResultSchema, options); - if (result === undefined) { - throw new Error(`Server sent invalid initialize result: ${result}`); - } - if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { - throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); - } - this._serverCapabilities = result.capabilities; - this._serverVersion = result.serverInfo; - // HTTP transports must set the protocol version in each header after initialization. - if (transport.setProtocolVersion) { - transport.setProtocolVersion(result.protocolVersion); - } - this._instructions = result.instructions; - await this.notification({ - method: 'notifications/initialized' - }); - // Set up list changed handlers now that we know server capabilities - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; - } + await this._initialize(transport, options); } catch (error) { // Disconnect if initialization fails. @@ -326,6 +301,37 @@ export class Client extends Protocol { throw error; } } + async _initialize(transport, options) { + const result = await this.request({ + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, InitializeResultSchema, options); + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } + this._serverCapabilities = result.capabilities; + this._serverVersion = result.serverInfo; + // HTTP transports must set the protocol version in each header after initialization. + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.protocolVersion); + } + this._instructions = result.instructions; + await this.notification({ + method: 'notifications/initialized' + }); + // Set up list changed handlers now that we know server capabilities + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } /** * After initialization has completed, this will be populated with the server's reported capabilities. */ diff --git a/dist/esm/client/sse.js b/dist/esm/client/sse.js index 58c474156ba4b33090ac092be6f3284e695a7ffd..ca088b48eeb04a9b65863d9a6dcd3bd11c4a8f71 100644 --- a/dist/esm/client/sse.js +++ b/dist/esm/client/sse.js @@ -23,7 +23,8 @@ export class SSEClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = createFetchWithInit(opts?.fetch, oauthRequestInit); } async _authThenStart() { if (!this._authProvider) { @@ -59,10 +60,11 @@ export class SSEClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = normalizeHeaders(this._requestInit?.headers); - return new Headers({ - ...headers, - ...extraHeaders - }); + const result = new Headers(extraHeaders); + for (const [name, value] of Object.entries(headers)) { + result.set(name, value); + } + return result; } _startOrAuth() { const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch); diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js index 624172aa24ae255a67c083f9c19053343e4a0581..d833dce32e6dc90a5b72dee03e70acd0676034da 100644 --- a/dist/esm/client/streamableHttp.js +++ b/dist/esm/client/streamableHttp.js @@ -1,5 +1,5 @@ import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; +import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; // Default reconnection options for StreamableHTTP connections @@ -29,7 +29,8 @@ export class StreamableHTTPClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = createFetchWithInit(opts?.fetch, oauthRequestInit); this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } @@ -70,10 +71,11 @@ export class StreamableHTTPClientTransport { headers['mcp-protocol-version'] = this._protocolVersion; } const extraHeaders = normalizeHeaders(this._requestInit?.headers); - return new Headers({ - ...headers, - ...extraHeaders - }); + const result = new Headers(extraHeaders); + for (const [name, value] of Object.entries(headers)) { + result.set(name, value); + } + return result; } async _startOrAuthSse(options) { const { resumptionToken } = options; @@ -286,7 +288,38 @@ export class StreamableHTTPClientTransport { this.onclose?.(); } async send(message, options) { + return this._send(message, options, false); + } + async _recoverSession(expiredSessionId) { + if (this._sessionRecovery) { + await this._sessionRecovery; + return true; + } + if (this._sessionId !== expiredSessionId) + return true; + this._sessionId = undefined; + this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); try { + await this._sessionRecovery; + } + catch (error) { + this._sessionId = undefined; + await this.close(); + throw error; + } + finally { + this._sessionRecovery = undefined; + } + return true; + } + async _send(message, options, isSessionRetry) { + try { + if (this._sessionRecovery && !isInitializeRequest(message) && !isInitializedNotification(message)) { + await this._sessionRecovery; + if (options?.isRequestActive?.() === false) { + throw new Error('Request is no longer active'); + } + } const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream @@ -294,6 +327,7 @@ export class StreamableHTTPClientTransport { return; } const headers = await this._commonHeaders(); + const requestSessionId = headers.get('mcp-session-id') ?? undefined; headers.set('content-type', 'application/json'); headers.set('accept', 'application/json, text/event-stream'); const init = { @@ -306,11 +340,20 @@ export class StreamableHTTPClientTransport { const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get('mcp-session-id'); - if (sessionId) { + if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { this._sessionId = sessionId; } if (!response.ok) { const text = await response.text().catch(() => null); + if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitializedNotification(message)) { + const recovered = await this._recoverSession(requestSessionId); + if (options?.isRequestActive?.() === false) { + throw new Error('Request is no longer active'); + } + if (recovered) { + return this._send(message, options, true); + } + } if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { @@ -331,7 +374,7 @@ export class StreamableHTTPClientTransport { // Mark that we completed auth flow this._hasCompletedAuthFlow = true; // Purposely _not_ awaited, so we don't call onerror twice - return this.send(message); + return this._send(message, options, isSessionRetry); } if (response.status === 403 && this._authProvider) { const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); @@ -358,7 +401,7 @@ export class StreamableHTTPClientTransport { if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); } - return this.send(message); + return this._send(message, options, isSessionRetry); } } throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); diff --git a/dist/esm/shared/protocol.js b/dist/esm/shared/protocol.js index bfa2b7120a0f50c569364ea5264e6f811076f44f..dec477d16a0fd796854542c1144279a6e86567f2 100644 --- a/dist/esm/shared/protocol.js +++ b/dist/esm/shared/protocol.js @@ -740,7 +740,12 @@ export class Protocol { } else { // No related task - send through transport normally - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._transport.send(jsonrpcRequest, { + relatedRequestId, + resumptionToken, + onresumptiontoken, + isRequestActive: () => this._responseHandlers.has(messageId) + }).catch(error => { this._cleanupTimeout(messageId); reject(error); });