diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js index c2e4fa91d26f5336889f6afa416147db75fc4872..6fe80dd336252838adf47b0a8e022e55d11e3657 100644 --- a/dist/cjs/client/auth.js +++ b/dist/cjs/client/auth.js @@ -186,7 +186,7 @@ async function auth(provider, options) { throw error; } } -async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { +async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn, resourceMetadataFetchFn }) { // Check if the provider has cached discovery state to skip discovery const cachedState = await provider.discoveryState?.(); let resourceMetadata; @@ -198,6 +198,9 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); } + const protectedResourceFetchFn = !effectiveResourceMetadataUrl || effectiveResourceMetadataUrl.origin === new URL(serverUrl).origin + ? (resourceMetadataFetchFn ?? fetchFn) + : fetchFn; if (cachedState?.authorizationServerUrl) { // Restore discovery state from cache authorizationServerUrl = cachedState.authorizationServerUrl; @@ -207,7 +210,7 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res // If resource metadata wasn't cached, try to fetch it for selectResourceURL if (!resourceMetadata) { try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, fetchFn); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, protectedResourceFetchFn); } catch { // RFC 9728 not available — selectResourceURL will handle undefined @@ -225,7 +228,11 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res } else { // Full discovery via RFC 9728 - const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + const serverInfo = await discoverOAuthServerInfo(serverUrl, { + resourceMetadataUrl: effectiveResourceMetadataUrl, + fetchFn, + resourceMetadataFetchFn: protectedResourceFetchFn + }); authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; resourceMetadata = serverInfo.resourceMetadata; @@ -688,7 +695,7 @@ async function discoverOAuthServerInfo(serverUrl, opts) { let resourceMetadata; let authorizationServerUrl; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.fetchFn); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.resourceMetadataFetchFn ?? opts?.fetchFn); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } 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..14cc1b969ac51ecb6f894608f079f4f04d8acd62 100644 --- a/dist/cjs/client/sse.js +++ b/dist/cjs/client/sse.js @@ -27,7 +27,9 @@ 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); + this._resourceFetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, oauthRequestInit); } async _authThenStart() { if (!this._authProvider) { @@ -39,7 +41,8 @@ class SSEClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); } catch (error) { @@ -63,10 +66,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); @@ -149,7 +153,8 @@ class SSEClientTransport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new auth_js_1.UnauthorizedError('Failed to authorize'); @@ -185,7 +190,8 @@ class SSEClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new auth_js_1.UnauthorizedError(); diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js index a29a7d3a0f14d9cd800ef5b296485237350c666f..54ac759eb19df7c6f16fc031a689270acb5c3922 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js @@ -33,7 +33,9 @@ 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); + this._resourceFetchWithInit = (0, transport_js_1.createFetchWithInit)(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + 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; } @@ -47,7 +49,8 @@ class StreamableHTTPClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); } catch (error) { @@ -74,10 +77,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; @@ -275,7 +279,8 @@ class StreamableHTTPClientTransport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new auth_js_1.UnauthorizedError('Failed to authorize'); @@ -290,7 +295,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 +334,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 +347,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) { @@ -327,7 +373,8 @@ class StreamableHTTPClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new auth_js_1.UnauthorizedError(); @@ -335,7 +382,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); @@ -357,12 +404,13 @@ class StreamableHTTPClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); 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/auth.js b/dist/esm/client/auth.js index e183040fc2bba22ca1ccc784984f3310854403b7..6b88910ca88f9714abe7cad26fbf3a31c4fb376c 100644 --- a/dist/esm/client/auth.js +++ b/dist/esm/client/auth.js @@ -161,7 +161,7 @@ export async function auth(provider, options) { throw error; } } -async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { +async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn, resourceMetadataFetchFn }) { // Check if the provider has cached discovery state to skip discovery const cachedState = await provider.discoveryState?.(); let resourceMetadata; @@ -173,6 +173,9 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); } + const protectedResourceFetchFn = !effectiveResourceMetadataUrl || effectiveResourceMetadataUrl.origin === new URL(serverUrl).origin + ? (resourceMetadataFetchFn ?? fetchFn) + : fetchFn; if (cachedState?.authorizationServerUrl) { // Restore discovery state from cache authorizationServerUrl = cachedState.authorizationServerUrl; @@ -182,7 +185,7 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res // If resource metadata wasn't cached, try to fetch it for selectResourceURL if (!resourceMetadata) { try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, fetchFn); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl }, protectedResourceFetchFn); } catch { // RFC 9728 not available — selectResourceURL will handle undefined @@ -200,7 +203,11 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res } else { // Full discovery via RFC 9728 - const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + const serverInfo = await discoverOAuthServerInfo(serverUrl, { + resourceMetadataUrl: effectiveResourceMetadataUrl, + fetchFn, + resourceMetadataFetchFn: protectedResourceFetchFn + }); authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; resourceMetadata = serverInfo.resourceMetadata; @@ -663,7 +670,7 @@ export async function discoverOAuthServerInfo(serverUrl, opts) { let resourceMetadata; let authorizationServerUrl; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.fetchFn); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl: opts?.resourceMetadataUrl }, opts?.resourceMetadataFetchFn ?? opts?.fetchFn); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } 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..b813e467b497697208167ad9bfccdecd1200ffac 100644 --- a/dist/esm/client/sse.js +++ b/dist/esm/client/sse.js @@ -23,7 +23,9 @@ export class SSEClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._resourceFetchWithInit = createFetchWithInit(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + const oauthRequestInit = opts?.requestInit ? { ...opts.requestInit, headers: undefined } : undefined; + this._fetchWithInit = createFetchWithInit(opts?.fetch, oauthRequestInit); } async _authThenStart() { if (!this._authProvider) { @@ -35,7 +37,8 @@ export class SSEClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); } catch (error) { @@ -59,10 +62,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); @@ -145,7 +149,8 @@ export class SSEClientTransport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -181,7 +186,8 @@ export class SSEClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js index 624172aa24ae255a67c083f9c19053343e4a0581..88f7f5c3528d0ce6115efbebfdda98eba7077b8b 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,9 @@ export class StreamableHTTPClientTransport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._resourceFetchWithInit = createFetchWithInit(opts?.fetch, { ...opts?.requestInit, redirect: 'error' }); + 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; } @@ -43,7 +45,8 @@ export class StreamableHTTPClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); } catch (error) { @@ -70,10 +73,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; @@ -271,7 +275,8 @@ export class StreamableHTTPClientTransport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -286,7 +291,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 +330,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 +343,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) { @@ -323,7 +369,8 @@ export class StreamableHTTPClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); @@ -331,7 +378,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); @@ -353,12 +400,13 @@ export class StreamableHTTPClientTransport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit, + resourceMetadataFetchFn: this._resourceFetchWithInit }); 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); });