diff --git a/.changeset/token-registration-redirects-terminal.md b/.changeset/token-registration-redirects-terminal.md new file mode 100644 index 0000000000..6fbf0000f5 --- /dev/null +++ b/.changeset/token-registration-redirects-terminal.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Token and client registration requests no longer follow HTTP redirects — including the cross-app access token exchanges. Token responses are terminal (RFC 6749 §5), so a 3xx answer from a token or registration endpoint now rejects with an error instead of being re-sent to the redirect target. diff --git a/.changeset/transport-headers-oauth-requests.md b/.changeset/transport-headers-oauth-requests.md new file mode 100644 index 0000000000..191502200a --- /dev/null +++ b/.changeset/transport-headers-oauth-requests.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Transport `requestInit` headers now apply only to MCP requests, not to the OAuth requests issued by the transport's authorization flow (protected-resource metadata, authorization-server metadata, token, and client registration requests) — those may target a different origin than the MCP server, so connection-level headers do not carry over. A new `oauthRequestInit` transport option configures those requests explicitly. diff --git a/.changeset/transport-redirect-policy.md b/.changeset/transport-redirect-policy.md new file mode 100644 index 0000000000..cde881ae92 --- /dev/null +++ b/.changeset/transport-redirect-policy.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/core-internal': patch +--- + +Handle redirect (3xx) responses on the client transports' MCP requests explicitly instead of delegating them to the `fetch` implementation. `StreamableHTTPClientTransport` and `SSEClientTransport` now issue their data-plane requests (`POST` message sends, the `GET` that opens or resumes an SSE stream, the session-terminating `DELETE`) with `redirect: 'manual'` and apply RFC 9110 redirect semantics themselves: `GET` redirects are followed for up to 3 hops — a same-origin `Location` target is requested with the original headers, while a hop that leaves the endpoint's origin is requested with only the headers that describe the request itself (`Accept`, `MCP-Protocol-Version`, and — on `StreamableHTTPClientTransport` stream resumption — `Last-Event-ID`); headers configured for the connection (`requestInit` headers, the auth provider's `Authorization`, the server-issued `Mcp-Session-Id`) are not applied across origins. `POST` and `DELETE` requests are never re-sent to a `Location` target: a redirect answer to those surfaces as an `SdkHttpError` with the new code `SdkErrorCode.ClientHttpRedirectNotFollowed` (MCP endpoints that answer `POST` with a redirect are not followed — point the transport at the new endpoint instead), and a redirect response can no longer supply the `mcp-session-id` the transport adopts. The new transport option `redirectPolicy: 'manual' | 'follow'` (default `'manual'`) is the escape hatch: `'follow'` restores the previous behavior — no `redirect` field is set, so `requestInit.redirect` or the platform default applies — for deployments behind redirecting infrastructure that requires the configured headers on the redirect target, and for browser runtimes: there `redirect: 'manual'` yields an opaque redirect (Fetch `opaqueredirect`: status 0, no readable `Location` header) whose target the transport cannot observe, so under the default `'manual'` policy any redirect answer — initial or on a followed hop — fails with `ClientHttpRedirectNotFollowed` and a message naming the remedies (set `redirectPolicy: 'follow'`, or serve the MCP endpoint without redirects) instead of an unexplained status-0 failure. See the "HTTP & headers" section of `docs/migration/upgrade-to-v2.md`. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 89188aa893..e0a516ada9 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -811,6 +811,56 @@ value to the spec-required `application/json, text/event-stream` (v1 let it repl them). The required media types are always present; additional types are kept for proxy/gateway routing. +#### Redirect (3xx) responses on MCP requests + +`StreamableHTTPClientTransport` and `SSEClientTransport` no longer delegate redirect +handling on their MCP requests to the `fetch` implementation (v1 let the platform +follow up to 20 hops with every header riding along). Data-plane requests — `POST` +message sends, the `GET` that opens or resumes an SSE stream, the session-terminating +`DELETE` — now go out with `redirect: 'manual'` and the transport applies RFC 9110 +redirect semantics itself: + +- **`GET`** redirects are followed, bounded at **3 hops**. A same-origin `Location` + target is requested with the original headers. A hop that leaves the endpoint's + origin is requested with only the headers that describe the request itself + (`Accept`, `MCP-Protocol-Version`, and — on `StreamableHTTPClientTransport` + stream resumption — `Last-Event-ID`: what the target needs to negotiate the + media type, parse the request, and resume the stream). Headers + configured for the _connection_ — `requestInit` headers, the auth provider's + `Authorization`, the server-issued `Mcp-Session-Id` — are scoped to the configured + origin and are not applied across origins. Once a chain has left the origin, + the reduced header set applies to every remaining hop, including one that + returns to the original origin. +- **`POST` / `DELETE`** requests are **never re-sent** to a `Location` target + (RFC 9110 leaves redirecting a request with a body to the sender's discretion). A + redirect answer surfaces as `SdkHttpError` with the new code + `SdkErrorCode.ClientHttpRedirectNotFollowed`; an MCP endpoint that answers `POST` + with a redirect is a configuration to fix by pointing the transport at the new + endpoint URL. A redirect response also can no longer supply the `mcp-session-id` + value the transport adopts — session ids are only read off responses the + configured endpoint answered directly. + +The escape hatch is the new `redirectPolicy` transport option (both transports): + +```ts +const transport = new StreamableHTTPClientTransport(url, { + redirectPolicy: 'follow' // default: 'manual' +}); +``` + +`'follow'` restores the v1 request shape exactly — no `redirect` field is set, so +`requestInit.redirect` or the platform default (`'follow'`) applies and every +request's headers ride along to wherever the chain ends. Set it for deployments +behind redirecting infrastructure that requires the configured headers on the +redirect target, and for browser runtimes: a browser `fetch` answers +`redirect: 'manual'` with an opaque redirect (Fetch `opaqueredirect`: status 0, no +readable `Location` header), so the transport cannot observe the redirect target to +apply the rules above. Under the default `'manual'` policy any redirect answer — +initial or on a followed hop, any method — therefore fails there with +`ClientHttpRedirectNotFollowed` (`status` is the literal `0`), its message naming the +two ways out: set `redirectPolicy: 'follow'`, or serve the MCP endpoint without +redirects. + `hostHeaderValidation()` and `localhostHostValidation()` moved to `@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the same as every released v1.x — only the import path changes. Framework-agnostic helpers @@ -848,7 +898,7 @@ hierarchy also exposes the same check as an explicit static guard TypeScript — use whichever style your codebase prefers; both read the same brand. Fine print (applies equally to `instanceof` and `isInstance`): -- **Version skew** — matching needs *both* copies at a brand-aware release; against an +- **Version skew** — matching needs _both_ copies at a brand-aware release; against an older copy, behavior degrades to plain prototype `instanceof` (false across bundles). During mixed-version rollouts, recognize errors without class identity: match `error.name` plus the class's discriminant field (`code`, `status`), or reconstruct @@ -1112,6 +1162,22 @@ OAuth `onUnauthorized` behavior, for composing your own adapter). - **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()` treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw. +- **Transport `requestInit` headers stay off OAuth requests.** Headers configured via + the `requestInit` option on `StreamableHTTPClientTransport` / `SSEClientTransport` + apply only to MCP requests; the OAuth requests the transport's authorization flow + issues (protected-resource metadata, authorization-server metadata, token, and client + registration requests) are sent without them — those may target a different origin + than the MCP server, so connection-level headers do not carry over. A deployment that + needs extra headers on those requests (e.g. gateway headers on well-known endpoints) + sets the new `oauthRequestInit` transport option. +- **Token and registration endpoint redirects are not followed.** The token-exchange + and client-registration POSTs (`exchangeAuthorization()` / `refreshAuthorization()` / + `fetchToken()` / `registerClient()`, transitively `auth()`, and the cross-app access + token exchanges `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()`) are issued with + `redirect: 'manual'`; a 3xx answer rejects with an error instead of re-sending the + request to the redirect target. Token responses are terminal (RFC 6749 §5) — an + authorization server that redirects these requests must be addressed at its final + endpoint URL (via its metadata document). - **Scoped credential invalidation on `invalid_client` / `unauthorized_client`.** The `auth()` retry for these errors now issues two scoped calls — `invalidateCredentials('client')` then `invalidateCredentials('tokens')` — instead of diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..9b267f3fce 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -2046,6 +2046,18 @@ export function prepareAuthorizationCodeRequest( }); } +/** + * Token and registration responses are terminal (RFC 6749 §5, RFC 7591 §3.2): + * a redirect — including one the runtime filters to `opaqueredirect` — is an error. + */ +export function assertNotRedirected(response: Response, endpoint: 'Token' | 'Registration'): void { + if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) { + throw new Error( + `${endpoint} endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); ${endpoint.toLowerCase()} responses are terminal` + ); + } +} + /** * Internal helper to execute a token request with the given parameters. * Used by {@linkcode exchangeAuthorization}, {@linkcode refreshAuthorization}, and {@linkcode fetchToken}. @@ -2090,8 +2102,10 @@ export async function executeTokenRequest( const response = await (fetchFn ?? fetch)(tokenUrl, { method: 'POST', headers, - body: tokenRequestParams + body: tokenRequestParams, + redirect: 'manual' }); + assertNotRedirected(response, 'Token'); if (!response.ok) { throw await parseErrorResponse(response); @@ -2365,8 +2379,10 @@ export async function registerClient( headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(submittedMetadata) + body: JSON.stringify(submittedMetadata), + redirect: 'manual' }); + assertNotRedirected(response, 'Registration'); if (!response.ok) { throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata }); diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index a7703dbf15..cde4d29915 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -12,7 +12,7 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal'; import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; import type { ClientAuthMethod } from './auth'; -import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth'; +import { applyClientAuthentication, assertNotRedirected, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth'; /** * Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange. @@ -152,8 +152,10 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString() + body: params.toString(), + redirect: 'manual' }); + assertNotRedirected(response, 'Token'); if (!response.ok) { const errorBody = await response.json().catch(() => ({})); @@ -279,8 +281,10 @@ export async function exchangeJwtAuthGrant(options: { const response = await fetchFn(tokenUrl, { method: 'POST', headers, - body: params.toString() + body: params.toString(), + redirect: 'manual' }); + assertNotRedirected(response, 'Token'); if (!response.ok) { const errorBody = await response.json().catch(() => ({})); diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 1c81928f10..f2a1f140a2 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,9 +1,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal'; import { brandedHasInstance, - createFetchWithInit, JSONRPCMessageSchema, - normalizeHeaders, SdkError, SdkErrorCode, SdkHttpError, @@ -23,6 +21,8 @@ import { } from './auth'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode} import type { IssuerMismatchError } from './authErrors'; +import type { RedirectPolicy, TransportHttp } from './transportRedirect'; +import { createTransportHttp } from './transportRedirect'; export class SseError extends Error { static { @@ -102,14 +102,26 @@ export type SSEClientTransportOptions = { eventSourceInit?: EventSourceInit; /** - * Customizes recurring `POST` requests to the server. + * Customizes recurring `POST` requests to the server. Not applied to OAuth requests — + * use {@linkcode SSEClientTransportOptions.oauthRequestInit | oauthRequestInit} for those. */ requestInit?: RequestInit; + /** Customizes the OAuth requests issued by the transport's authorization flow. */ + oauthRequestInit?: RequestInit; + /** * Custom fetch implementation used for all network requests. */ fetch?: FetchLike; + + /** + * How the transport reacts to redirect (3xx) responses on its MCP requests. + * See {@linkcode RedirectPolicy}. + * + * @default 'manual' + */ + redirectPolicy?: RedirectPolicy; }; /** @@ -119,18 +131,15 @@ export type SSEClientTransportOptions = { */ export class SSEClientTransport implements Transport { private _eventSource?: EventSource; - private _endpoint?: URL; private _abortController?: AbortController; private _url: URL; private _resourceMetadataUrl?: URL; private _scope?: string; private _eventSourceInit?: EventSourceInit; - private _requestInit?: RequestInit; private _authProvider?: AuthProvider; private _oauthProvider?: OAuthClientProvider; private _skipIssuerMetadataValidation?: boolean; - private _fetch?: FetchLike; - private _fetchWithInit: FetchLike; + private readonly _http: TransportHttp; private _protocolVersion?: string; onclose?: () => void; @@ -142,7 +151,6 @@ export class SSEClientTransport implements Transport { this._resourceMetadataUrl = undefined; this._scope = undefined; this._eventSourceInit = opts?.eventSourceInit; - this._requestInit = opts?.requestInit; this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation; if (isOAuthClientProvider(opts?.authProvider)) { this._oauthProvider = opts.authProvider; @@ -152,8 +160,13 @@ export class SSEClientTransport implements Transport { } else { this._authProvider = opts?.authProvider; } - this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._http = createTransportHttp({ + url, + fetch: opts?.fetch, + requestInit: opts?.requestInit, + oauthRequestInit: opts?.oauthRequestInit, + redirectPolicy: opts?.redirectPolicy + }); } private _last401Response?: Response; @@ -168,7 +181,7 @@ export class SSEClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } - const extraHeaders = normalizeHeaders(this._requestInit?.headers); + const extraHeaders = this._http.requestInitHeaders(); return new Headers({ ...headers, @@ -177,17 +190,36 @@ export class SSEClientTransport implements Transport { } private _startOrAuth(): Promise { - const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch; return new Promise((resolve, reject) => { this._eventSource = new EventSource(this._url.href, { ...this._eventSourceInit, fetch: async (url, init) => { const headers = await this._commonHeaders(); headers.set('Accept', 'text/event-stream'); - const response = await fetchImpl(url, { - ...init, - headers - }); + const crossOriginHeaders = new Headers({ accept: 'text/event-stream' }); + if (this._protocolVersion) { + crossOriginHeaders.set('mcp-protocol-version', this._protocolVersion); + } + let response: Response; + try { + response = await this._http.eventSourceGet({ + fetchOverride: this._eventSourceInit?.fetch as FetchLike | undefined, + url, + headers, + requestInit: init as RequestInit, + signal: (init as RequestInit | undefined)?.signal ?? undefined, + crossOriginHeaders + }); + } catch (error) { + if (error instanceof SdkHttpError && error.code === SdkErrorCode.ClientHttpRedirectNotFollowed) { + // Terminal, not transient: close so the EventSource does not + // schedule reconnects against the same redirecting endpoint. + reject(error); + this.onerror?.(error); + void this.close(); + } + throw error; + } if (response.status === 401) { this._last401Response = response; @@ -209,7 +241,7 @@ export class SSEClientTransport implements Transport { const response = this._last401Response; this._last401Response = undefined; this._eventSource?.close(); - this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( + this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._http.oauthFetch }).then( // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. () => this._startOrAuth().then(resolve, reject), // onUnauthorized failed → not yet reported. @@ -239,10 +271,7 @@ export class SSEClientTransport implements Transport { const messageEvent = event as MessageEvent; try { - this._endpoint = new URL(messageEvent.data, this._url); - if (this._endpoint.origin !== this._url.origin) { - throw new Error(`Endpoint origin does not match connection origin: ${this._endpoint.origin}`); - } + this._http.setMessageEndpoint(new URL(messageEvent.data, this._url)); } catch (error) { reject(error); this.onerror?.(error as Error); @@ -309,7 +338,7 @@ export class SSEClientTransport implements Transport { iss, this._oauthProvider, this._url, - { fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl } + { fetchFn: this._http.oauthFetch, resourceMetadataUrl: this._resourceMetadataUrl } ); const result = await auth(this._oauthProvider, { @@ -318,7 +347,7 @@ export class SSEClientTransport implements Transport { iss: issParam, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit, + fetchFn: this._http.oauthFetch, skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); if (result !== 'AUTHORIZED') { @@ -337,22 +366,14 @@ export class SSEClientTransport implements Transport { } private async _send(message: JSONRPCMessage, isAuthRetry: boolean): Promise { - if (!this._endpoint) { + if (!this._http.messageEndpoint) { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } try { const headers = await this._commonHeaders(); headers.set('content-type', 'application/json'); - const init = { - ...this._requestInit, - method: 'POST', - headers, - body: JSON.stringify(message), - signal: this._abortController?.signal - }; - - const response = await (this._fetch ?? fetch)(this._endpoint, init); + const response = await this._http.mcpRequest('POST', headers, JSON.stringify(message), this._abortController?.signal); if (!response.ok) { if (response.status === 401 && this._authProvider) { if (response.headers.has('www-authenticate')) { @@ -365,7 +386,7 @@ export class SSEClientTransport implements Transport { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._http.oauthFetch }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0d8eff917b..0589c1ae4c 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -2,7 +2,6 @@ import type { ReadableWritablePair } from 'node:stream/web'; import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal'; import { - createFetchWithInit, encodeMcpParamValue, isInitializedNotification, isJSONRPCErrorResponse, @@ -11,7 +10,6 @@ import { isModernProtocolVersion, JSONRPCMessageSchema, mediaTypeEssence, - normalizeHeaders, PROTOCOL_VERSION_META_KEY, SdkError, SdkErrorCode, @@ -33,6 +31,8 @@ import { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc import type { IssuerMismatchError } from './authErrors'; import { InsufficientScopeError } from './authErrors'; +import type { RedirectPolicy, TransportHttp } from './transportRedirect'; +import { createTransportHttp } from './transportRedirect'; /** Default cap on step-up re-authorization retries within a single send/stream-open. */ const DEFAULT_MAX_STEP_UP_RETRIES = 1; @@ -175,15 +175,27 @@ export type StreamableHTTPClientTransportOptions = { skipIssuerMetadataValidation?: boolean; /** - * Customizes HTTP requests to the server. + * Customizes HTTP requests to the MCP server. Not applied to OAuth requests — + * use {@linkcode StreamableHTTPClientTransportOptions.oauthRequestInit | oauthRequestInit} for those. */ requestInit?: RequestInit; + /** Customizes the OAuth requests issued by the transport's authorization flow. */ + oauthRequestInit?: RequestInit; + /** * Custom fetch implementation used for all network requests. */ fetch?: FetchLike; + /** + * How the transport reacts to redirect (3xx) responses on its MCP requests. + * See {@linkcode RedirectPolicy}. + * + * @default 'manual' + */ + redirectPolicy?: RedirectPolicy; + /** * Options to configure the reconnection behavior. */ @@ -308,12 +320,10 @@ export class StreamableHTTPClientTransport implements Transport { private _url: URL; private _resourceMetadataUrl?: URL; private _scope?: string; - private _requestInit?: RequestInit; private _authProvider?: AuthProvider; private _oauthProvider?: OAuthClientProvider; private _skipIssuerMetadataValidation?: boolean; - private _fetch?: FetchLike; - private _fetchWithInit: FetchLike; + private readonly _http: TransportHttp; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; @@ -339,7 +349,6 @@ export class StreamableHTTPClientTransport implements Transport { this._url = url; this._resourceMetadataUrl = undefined; this._scope = undefined; - this._requestInit = opts?.requestInit; this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation; if (isOAuthClientProvider(opts?.authProvider)) { this._oauthProvider = opts.authProvider; @@ -349,8 +358,13 @@ export class StreamableHTTPClientTransport implements Transport { } else { this._authProvider = opts?.authProvider; } - this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._http = createTransportHttp({ + url, + fetch: opts?.fetch, + requestInit: opts?.requestInit, + oauthRequestInit: opts?.oauthRequestInit, + redirectPolicy: opts?.redirectPolicy + }); this._sessionId = opts?.sessionId; this._protocolVersion = opts?.protocolVersion; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; @@ -414,7 +428,7 @@ export class StreamableHTTPClientTransport implements Transport { resourceMetadataUrl: this._resourceMetadataUrl, scope: unionScope, forceReauthorization, - fetchFn: this._fetchWithInit, + fetchFn: this._http.oauthFetch, skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); } @@ -433,7 +447,7 @@ export class StreamableHTTPClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } - const extraHeaders = normalizeHeaders(this._requestInit?.headers); + const extraHeaders = this._http.requestInitHeaders(); return new Headers({ ...headers, @@ -441,6 +455,22 @@ export class StreamableHTTPClientTransport implements Transport { }); } + /** + * Headers a followed `GET` redirect carries to another origin — + * connection-scoped headers (`Authorization`, `Mcp-Session-Id`, + * `requestInit`) are deliberately omitted. + */ + private _crossOriginGetHeaders(resumptionToken?: string): Headers { + const headers = new Headers({ accept: 'text/event-stream' }); + if (this._protocolVersion) { + headers.set('mcp-protocol-version', this._protocolVersion); + } + if (resumptionToken) { + headers.set('last-event-id', resumptionToken); + } + return headers; + } + /** * Body-derived per-request headers: when an outgoing request carries a * protocol-version claim in its `_meta` envelope (the version negotiation @@ -524,12 +554,7 @@ export class StreamableHTTPClientTransport implements Transport { requestSignal !== undefined && transportSignal !== undefined ? anySignal(transportSignal, requestSignal) : (requestSignal ?? transportSignal); - const response = await (this._fetch ?? fetch)(this._url, { - ...this._requestInit, - method: 'GET', - headers, - signal - }); + const response = await this._http.mcpRequest('GET', headers, undefined, signal, this._crossOriginGetHeaders(resumptionToken)); if (!response.ok) { if (response.status === 401 && this._authProvider) { @@ -545,7 +570,7 @@ export class StreamableHTTPClientTransport implements Transport { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._http.oauthFetch }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice @@ -863,7 +888,7 @@ export class StreamableHTTPClientTransport implements Transport { iss, this._oauthProvider, this._url, - { fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl } + { fetchFn: this._http.oauthFetch, resourceMetadataUrl: this._resourceMetadataUrl } ); const result = await auth(this._oauthProvider, { @@ -872,7 +897,7 @@ export class StreamableHTTPClientTransport implements Transport { iss: issParam, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit, + fetchFn: this._http.oauthFetch, skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); if (result !== 'AUTHORIZED') { @@ -963,15 +988,7 @@ export class StreamableHTTPClientTransport implements Transport { options?.requestSignal !== undefined && transportSignal !== undefined ? anySignal(transportSignal, options.requestSignal) : (options?.requestSignal ?? transportSignal); - const init = { - ...this._requestInit, - method: 'POST', - headers, - body: JSON.stringify(message), - signal - }; - - const response = await (this._fetch ?? fetch)(this._url, init); + const response = await this._http.mcpRequest('POST', headers, JSON.stringify(message), signal); // Handle session ID received during initialization const sessionId = response.headers.get('mcp-session-id'); @@ -994,7 +1011,7 @@ export class StreamableHTTPClientTransport implements Transport { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._http.oauthFetch }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice @@ -1156,14 +1173,7 @@ export class StreamableHTTPClientTransport implements Transport { try { const headers = await this._commonHeaders(); - const init = { - ...this._requestInit, - method: 'DELETE', - headers, - signal: this._abortController?.signal - }; - - const response = await (this._fetch ?? fetch)(this._url, init); + const response = await this._http.mcpRequest('DELETE', headers, undefined, this._abortController?.signal); await response.text?.().catch(() => {}); // We specifically handle 405 as a valid response according to the spec, diff --git a/packages/client/src/client/transportRedirect.ts b/packages/client/src/client/transportRedirect.ts new file mode 100644 index 0000000000..c118da7193 --- /dev/null +++ b/packages/client/src/client/transportRedirect.ts @@ -0,0 +1,202 @@ +import type { FetchLike } from '@modelcontextprotocol/core-internal'; +import { createFetchWithInit, normalizeHeaders, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; + +/** + * Redirect handling for MCP requests. `'manual'` (default): GET redirects are + * followed (bounded); cross-origin hops carry only request-descriptive + * headers; POST/DELETE redirects surface as errors. `'follow'` delegates + * redirect handling to the fetch implementation. See the migration guide for + * details. + */ +export type RedirectPolicy = 'manual' | 'follow'; + +/** Maximum `Location` hops a transport `GET` follows under the `'manual'` policy. */ +export const MAX_TRANSPORT_REDIRECT_HOPS = 3; + +const REDIRECT_STATUS_CODES: ReadonlySet = new Set([301, 302, 303, 307, 308]); + +export interface TransportFetchArgs { + fetchFn: FetchLike; + url: URL | string; + method: 'GET' | 'POST' | 'DELETE'; + /** Fully-derived headers for requests to the configured endpoint. */ + headers: Headers; + /** Connection-level `RequestInit`; applied to same-origin requests only. */ + requestInit?: RequestInit; + body?: string; + signal?: AbortSignal; + redirectPolicy: RedirectPolicy; + /** Headers used once a followed `GET` hop leaves the endpoint's origin. */ + crossOriginHeaders?: Headers; +} + +/** + * Issues the request under {@linkcode RedirectPolicy}; redirects that are not + * followed surface as `SdkHttpError` with code `ClientHttpRedirectNotFollowed`. + */ +export async function fetchWithRedirectPolicy(args: TransportFetchArgs): Promise { + const { fetchFn, method, headers, requestInit, body, signal, redirectPolicy } = args; + if (redirectPolicy === 'follow') { + return fetchFn(args.url, { ...requestInit, method, headers, body, signal }); + } + + const initialUrl = args.url instanceof URL ? args.url : new URL(args.url); + let currentUrl = initialUrl; + // Latched: once a chain leaves the origin, header dropping is never undone. + let leftOrigin = false; + let response = await fetchFn(currentUrl, { ...requestInit, method, headers, body, signal, redirect: 'manual' }); + throwIfRedirectFiltered(response, method); + + for (let hop = 0; REDIRECT_STATUS_CODES.has(response.status); hop++) { + const { status, statusText } = response; + const location = response.headers.get('location'); + // Release the redirect response before erroring or following. + await response.text?.().catch(() => {}); + + if (method !== 'GET') { + throw new SdkHttpError( + SdkErrorCode.ClientHttpRedirectNotFollowed, + `Server answered ${method} with a redirect (HTTP ${status}${location ? ` to ${location}` : ''}); ` + + `${method} requests are not re-sent to redirect targets — point the transport at the new endpoint or set redirectPolicy: 'follow'`, + { status, statusText } + ); + } + if (location === null) { + throw new SdkHttpError( + SdkErrorCode.ClientHttpRedirectNotFollowed, + `Server answered GET with a redirect (HTTP ${status}) without a Location header`, + { status, statusText } + ); + } + if (hop >= MAX_TRANSPORT_REDIRECT_HOPS) { + throw new SdkHttpError( + SdkErrorCode.ClientHttpRedirectNotFollowed, + `Redirect limit of ${MAX_TRANSPORT_REDIRECT_HOPS} hops exceeded (last target: ${location})`, + { status, statusText } + ); + } + let nextUrl: URL; + try { + nextUrl = new URL(location, currentUrl); + } catch { + throw new SdkHttpError(SdkErrorCode.ClientHttpRedirectNotFollowed, `Redirect Location "${location}" is not a valid URL`, { + status, + statusText + }); + } + + currentUrl = nextUrl; + leftOrigin ||= nextUrl.origin !== initialUrl.origin; + response = leftOrigin + ? await fetchFn(currentUrl, { + method: 'GET', + headers: args.crossOriginHeaders ?? new Headers(), + signal, + redirect: 'manual' + }) + : await fetchFn(currentUrl, { ...requestInit, method: 'GET', headers, signal, redirect: 'manual' }); + throwIfRedirectFiltered(response, 'GET'); + } + + return response; +} + +export interface TransportHttpOptions { + url: URL; + fetch?: FetchLike; + requestInit?: RequestInit; + oauthRequestInit?: RequestInit; + redirectPolicy?: RedirectPolicy; +} + +/** + * Sole holder of a client transport's HTTP configuration (endpoint URL, + * fetch, `requestInit`, redirect policy); transports issue requests through + * it rather than holding those as fields. + */ +export interface TransportHttp { + /** `POST` targets the message endpoint once set; `GET`/`DELETE` always target the configured URL. */ + mcpRequest( + method: 'GET' | 'POST' | 'DELETE', + headers: Headers, + body?: string, + signal?: AbortSignal, + crossOriginHeaders?: Headers + ): Promise; + /** Merges `oauthRequestInit`, never `requestInit`. */ + readonly oauthFetch: FetchLike; + /** Headers contributed by `requestInit`, for merging into derived request headers. */ + requestInitHeaders(): Record; + /** Legacy SSE only: where subsequent `POST`s go. Must share the configured URL's origin. */ + setMessageEndpoint(endpoint: URL): void; + readonly messageEndpoint: URL | undefined; + /** `GET` for the EventSource integration, which supplies its own fetch and per-request init. */ + eventSourceGet(args: { + fetchOverride?: FetchLike; + url: URL | string; + headers: Headers; + requestInit?: RequestInit; + signal?: AbortSignal; + crossOriginHeaders?: Headers; + }): Promise; +} + +export function createTransportHttp(opts: TransportHttpOptions): TransportHttp { + const { url, requestInit } = opts; + const redirectPolicy = opts.redirectPolicy ?? 'manual'; + let messageEndpoint: URL | undefined; + return { + mcpRequest(method, headers, body, signal, crossOriginHeaders) { + return fetchWithRedirectPolicy({ + fetchFn: opts.fetch ?? fetch, + url: method === 'POST' ? (messageEndpoint ?? url) : url, + method, + headers, + requestInit, + body, + signal, + redirectPolicy, + crossOriginHeaders + }); + }, + oauthFetch: createFetchWithInit(opts.fetch, opts.oauthRequestInit), + requestInitHeaders: () => normalizeHeaders(requestInit?.headers), + setMessageEndpoint(endpoint) { + if (endpoint.origin !== url.origin) { + throw new Error(`Endpoint origin does not match connection origin: ${endpoint.origin}`); + } + messageEndpoint = endpoint; + }, + get messageEndpoint() { + return messageEndpoint; + }, + eventSourceGet(args) { + return fetchWithRedirectPolicy({ + fetchFn: args.fetchOverride ?? opts.fetch ?? fetch, + url: args.url, + method: 'GET', + headers: args.headers, + requestInit: args.requestInit, + signal: args.signal, + redirectPolicy, + crossOriginHeaders: args.crossOriginHeaders + }); + } + }; +} + +/** + * A runtime-filtered redirect (Fetch `opaqueredirect`: status 0, no readable + * `Location`) would otherwise fail status handling as an unexplained HTTP 0. + */ +function throwIfRedirectFiltered(response: Response, method: 'GET' | 'POST' | 'DELETE'): void { + if (response.type !== 'opaqueredirect') { + return; + } + throw new SdkHttpError( + SdkErrorCode.ClientHttpRedirectNotFollowed, + `Server answered ${method} with a redirect this runtime filters (opaqueredirect), so the target cannot be observed; ` + + `set redirectPolicy: 'follow' or serve the MCP endpoint without redirects`, + { status: 0, statusText: response.statusText } + ); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c9088fc499..69801d26db 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -96,6 +96,7 @@ export type { StreamableHTTPReconnectionOptions } from './client/streamableHttp'; export { StreamableHTTPClientTransport } from './client/streamableHttp'; +export type { RedirectPolicy } from './client/transportRedirect'; // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema'; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..54c9ad745e 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1965,6 +1965,50 @@ describe('OAuth Authorization', () => { expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); + it('does not follow a token endpoint redirect and rejects', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 307, + headers: new Headers({ location: 'https://elsewhere.example.com/token' }), + text: async () => '' + }); + + await expect( + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback' + }) + ).rejects.toThrow(/redirect/); + + // The redirect target is never requested — a single POST was issued, + // with redirects disabled. + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]![1]).toMatchObject({ method: 'POST', redirect: 'manual' }); + }); + + it('rejects a token response the runtime filtered as an opaque redirect', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 0, + type: 'opaqueredirect', + headers: new Headers(), + text: async () => '' + }); + + await expect( + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback' + }) + ).rejects.toThrow(/redirect/); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('allows for string "expires_in" values', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -2330,6 +2374,26 @@ describe('OAuth Authorization', () => { }); }); + it('does not follow a registration endpoint redirect and rejects', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 307, + headers: new Headers({ location: 'https://elsewhere.example.com/register' }), + text: async () => '' + }); + + await expect( + registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata + }) + ).rejects.toThrow(/redirect/); + + // The redirect target is never requested — a single POST was issued, + // with redirects disabled. + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]![1]).toMatchObject({ method: 'POST', redirect: 'manual' }); + }); + it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => { const clientMetadataWithScope: OAuthClientMetadata = { ...validClientMetadata, diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index b81d3527c8..8c0879e18a 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -22,6 +22,48 @@ describe('crossAppAccess', () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it('does not follow a token endpoint redirect and rejects', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 307, + headers: new Headers({ location: 'https://elsewhere.example.com/token' }), + json: async () => ({}) + } as Response); + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow(/redirect/); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[1]?.redirect).toBe('manual'); + }); + + it('rejects a token response the runtime filtered as an opaque redirect', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 0, + type: 'opaqueredirect', + headers: new Headers(), + json: async () => ({}) + } as Response); + await expect( + exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'jag', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow(/redirect/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('permits a loopback http token endpoint (SEP-2207 exemption)', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index a0d4e7b6f9..2da0e7325d 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1548,6 +1548,130 @@ describe('SSEClientTransport', () => { }); }); + describe('authorization request header isolation', () => { + type RecordedCall = { url: string; init?: RequestInit }; + + const headerOnCall = (call: RecordedCall, name: string): string | null => new Headers(call.init?.headers).get(name); + + const isAuthCall = (call: RecordedCall): boolean => + call.url.includes('/.well-known/') || call.url.endsWith('/register') || call.url.endsWith('/token'); + + const createIsolationAuthProvider = (): Mocked => ({ + get redirectUrl() { + return 'http://localhost/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost/callback'] }; + }, + clientInformation: vi.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() + }); + + /** + * Serves protected-resource metadata, authorization-server metadata and token + * issuance from one fake origin and accepts data-plane POSTs, recording every + * request so header propagation can be asserted per leg. + */ + const createDispatcherFetch = (calls: RecordedCall[]): Mock => + vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = input.toString(); + calls.push({ url, init }); + if (url.includes('/.well-known/oauth-protected-resource')) { + return Response.json({ + resource: 'http://localhost:1234/mcp', + authorization_servers: ['http://localhost:1234'] + }); + } + if (url.includes('/.well-known/oauth-authorization-server')) { + return Response.json({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }); + } + if (url === 'http://localhost:1234/token') { + return Response.json({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600 + }); + } + // Data-plane POST to the message endpoint. + return new Response(null, { status: 200 }); + }); + + /** Exchanges a code (PRM + AS metadata + token) and then sends a data-plane POST. */ + const runAuthAndSend = async (transport: SSEClientTransport): Promise => { + await transport.finishAuth('test-auth-code'); + (transport as unknown as { _http: { setMessageEndpoint(endpoint: URL): void } })._http.setMessageEndpoint( + new URL('http://localhost:1234/messages') + ); + await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'req-1' }); + }; + + it('does not apply requestInit headers to authorization requests', async () => { + const calls: RecordedCall[] = []; + transport = new SSEClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: createIsolationAuthProvider(), + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } } + }); + + await runAuthAndSend(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.map(c => c.url)).toEqual( + expect.arrayContaining([ + expect.stringContaining('/.well-known/oauth-protected-resource'), + expect.stringContaining('/.well-known/oauth-authorization-server'), + 'http://localhost:1234/token' + ]) + ); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + } + }); + + it('applies oauthRequestInit headers to authorization requests only', async () => { + const calls: RecordedCall[] = []; + transport = new SSEClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: createIsolationAuthProvider(), + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } }, + oauthRequestInit: { headers: { 'X-Auth-Gateway': 'gateway-value' } } + }); + + await runAuthAndSend(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.length).toBeGreaterThan(0); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Auth-Gateway')).toBe('gateway-value'); + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + expect(headerOnCall(call, 'X-Auth-Gateway')).toBeNull(); + } + }); + }); + describe('minimal AuthProvider (non-OAuth)', () => { let postResponses: number[]; let postCount: number; diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..c790256976 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -7,6 +7,23 @@ import { UnauthorizedError } from '../../src/client/auth'; import type { ReconnectionScheduler, StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp'; import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; +/** + * fetchWithCorsRetry gates its retry heuristic on the `CORS_IS_POSSIBLE` shim constant. + * Tests run under the Node shim (`false`), where a fetch TypeError propagates as a real + * network error. The header-isolation test that exercises the browser retry leg flips + * the mocked constant to `true` for a single test; the suite's afterEach resets it. + */ +let mockedCorsIsPossible = false; +vi.mock('@modelcontextprotocol/client/_shims', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + get CORS_IS_POSSIBLE() { + return mockedCorsIsPossible; + } + }; +}); + describe('StreamableHTTPClientTransport', () => { let transport: StreamableHTTPClientTransport; let mockAuthProvider: Mocked; @@ -2202,6 +2219,177 @@ describe('StreamableHTTPClientTransport', () => { }); }); + describe('authorization request header isolation', () => { + type RecordedCall = { url: string; init?: RequestInit }; + + afterEach(() => { + mockedCorsIsPossible = false; + }); + + const headerOnCall = (call: RecordedCall, name: string): string | null => new Headers(call.init?.headers).get(name); + + const isAuthCall = (call: RecordedCall): boolean => + call.url.includes('/.well-known/') || call.url.endsWith('/register') || call.url.endsWith('/token'); + + /** + * Serves the full authorization sequence from one fake origin: 401 on the MCP + * GET, protected-resource metadata, authorization-server metadata, client + * registration, token issuance, and 202 on MCP POSTs. Every request is + * recorded so header propagation can be asserted per leg. + */ + const createDispatcherFetch = (calls: RecordedCall[]): Mock => + vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = input.toString(); + calls.push({ url, init }); + if (url.includes('/.well-known/oauth-protected-resource')) { + return Response.json({ + resource: 'http://localhost:1234/mcp', + authorization_servers: ['http://localhost:1234'] + }); + } + if (url.includes('/.well-known/oauth-authorization-server')) { + return Response.json({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + registration_endpoint: 'http://localhost:1234/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }); + } + if (url === 'http://localhost:1234/register') { + return Response.json({ + client_id: 'registered-client-id', + redirect_uris: ['http://localhost/callback'] + }); + } + if (url === 'http://localhost:1234/token') { + return Response.json({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600 + }); + } + // MCP endpoint: 401 the GET stream open to trigger the authorization + // flow; accept data-plane POSTs. + if (init?.method === 'GET') { + return new Response(null, { status: 401 }); + } + return new Response(null, { status: 202 }); + }); + + /** + * Drives the full sequence against the dispatcher: 401 → discovery → + * registration → (redirect), then code exchange, then a data-plane POST. + */ + const runFullAuthSequence = async (transport: StreamableHTTPClientTransport): Promise => { + mockAuthProvider.clientInformation.mockReturnValue(undefined); + mockAuthProvider.saveClientInformation = vi.fn(); + + await transport.start(); + await expect( + (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}) + ).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + mockAuthProvider.clientInformation.mockReturnValue({ client_id: 'registered-client-id' }); + await transport.finishAuth('test-auth-code'); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'req-1' }); + }; + + it('does not apply requestInit headers to authorization requests', async () => { + const calls: RecordedCall[] = []; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } } + }); + + await runFullAuthSequence(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.map(c => c.url)).toEqual( + expect.arrayContaining([ + expect.stringContaining('/.well-known/oauth-protected-resource'), + expect.stringContaining('/.well-known/oauth-authorization-server'), + 'http://localhost:1234/register', + 'http://localhost:1234/token' + ]) + ); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + } + }); + + it('applies oauthRequestInit headers to authorization requests only', async () => { + const calls: RecordedCall[] = []; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } }, + oauthRequestInit: { headers: { 'X-Auth-Gateway': 'gateway-value' } } + }); + + await runFullAuthSequence(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.length).toBeGreaterThan(0); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Auth-Gateway')).toBe('gateway-value'); + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + expect(headerOnCall(call, 'X-Auth-Gateway')).toBeNull(); + } + }); + + it('keeps requestInit headers off the retried simple discovery request', async () => { + // Browser-like runtime: a TypeError from a discovery fetch that carried + // custom headers is retried as a simple request without headers. The + // retry must go through the same un-merged fetch, so connection-level + // requestInit headers must not reappear on it. + mockedCorsIsPossible = true; + + const calls: RecordedCall[] = []; + const dispatcher = createDispatcherFetch(calls); + const fetchImpl = vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = input.toString(); + if (url.includes('/.well-known/oauth-protected-resource') && init?.headers !== undefined) { + calls.push({ url, init }); + throw new TypeError('preflight rejected'); + } + return dispatcher(input, init) as Promise; + }); + + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: fetchImpl, + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } } + }); + + await transport.finishAuth('test-auth-code'); + + const prmCalls = calls.filter(c => c.url.includes('/.well-known/oauth-protected-resource')); + expect(prmCalls).toHaveLength(2); + // First attempt carried the discovery headers (and only those). + expect(headerOnCall(prmCalls[0]!, 'MCP-Protocol-Version')).not.toBeNull(); + expect(headerOnCall(prmCalls[0]!, 'X-Api-Key')).toBeNull(); + // The retried simple request carries no headers at all. + expect(prmCalls[1]!.init?.headers).toBeUndefined(); + }); + }); + describe('SSE retry field handling', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/client/test/client/transportRedirect.test.ts b/packages/client/test/client/transportRedirect.test.ts new file mode 100644 index 0000000000..58e8794cc9 --- /dev/null +++ b/packages/client/test/client/transportRedirect.test.ts @@ -0,0 +1,465 @@ +import type { IncomingMessage, Server } from 'node:http'; +import { createServer } from 'node:http'; + +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import type { Mock } from 'vitest'; + +import { SSEClientTransport } from '../../src/client/sse'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; + +/** + * Pins the transports' explicit redirect handling: MCP requests go out with + * `redirect: 'manual'`; GET redirects are followed (same-origin with headers + * intact, cross-origin with only the request-describing set), bounded at + * 3 hops; POST/DELETE redirects are never re-sent and surface as + * `SdkHttpError` (`ClientHttpRedirectNotFollowed`); a runtime-filtered + * redirect (Fetch `opaqueredirect`) surfaces as the same code with the + * remedies named; `redirectPolicy: 'follow'` restores delegation to the fetch + * implementation. + */ + +const ENDPOINT_URL = 'http://localhost:1234/mcp'; + +const testMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' +}; + +function redirectResponse(location: string | null, status = 307): Response { + const headers = new Headers(); + if (location !== null) { + headers.set('location', location); + } + return new Response(null, { status, headers }); +} + +/** + * The shape browser runtimes resolve a `redirect: 'manual'` fetch with when the + * response status is a redirect: an opaque redirect — status 0, no readable + * headers (Fetch "opaqueredirect" filtered response). + */ +function opaqueRedirectResponse(): Response { + return { + ok: false, + status: 0, + statusText: '', + type: 'opaqueredirect', + headers: new Headers(), + text: async () => '' + } as unknown as Response; +} + +function sseResponse(): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.close(); + } + }), + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ); +} + +describe('StreamableHTTPClientTransport redirect handling', () => { + let fetchMock: Mock; + let transport: StreamableHTTPClientTransport; + + const makeTransport = (opts?: { redirectPolicy?: 'manual' | 'follow'; requestInit?: RequestInit }): StreamableHTTPClientTransport => + new StreamableHTTPClientTransport(new URL(ENDPOINT_URL), { + fetch: fetchMock as unknown as typeof fetch, + authProvider: { token: async () => 'test-token' }, + sessionId: 'session-1', + protocolVersion: '2025-06-18', + requestInit: opts?.requestInit ?? { headers: { 'x-custom-header': 'custom-value' }, cache: 'no-store' }, + redirectPolicy: opts?.redirectPolicy + }); + + beforeEach(() => { + fetchMock = vi.fn(); + transport = makeTransport(); + transport.onerror = vi.fn(); + }); + + afterEach(async () => { + await transport.close().catch(() => {}); + }); + + it('issues POST, GET and DELETE requests with redirect: manual by default', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 202 })); + await transport.start(); + await transport.send(testMessage); + expect(fetchMock.mock.calls[0]![1].redirect).toBe('manual'); + + fetchMock.mockResolvedValue(sseResponse()); + await transport.resumeStream('token-1'); + expect(fetchMock.mock.calls[1]![1].redirect).toBe('manual'); + + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + await transport.terminateSession(); + expect(fetchMock.mock.calls[2]![1].redirect).toBe('manual'); + }); + + it('follows a same-origin GET redirect with the original headers intact', async () => { + fetchMock.mockResolvedValueOnce(redirectResponse('http://localhost:1234/mcp-moved')).mockResolvedValueOnce(sseResponse()); + await transport.start(); + await transport.resumeStream('token-123'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, init] = fetchMock.mock.calls[1]!; + expect(url.toString()).toBe('http://localhost:1234/mcp-moved'); + const headers = init.headers as Headers; + expect(headers.get('authorization')).toBe('Bearer test-token'); + expect(headers.get('x-custom-header')).toBe('custom-value'); + expect(headers.get('mcp-session-id')).toBe('session-1'); + expect(headers.get('mcp-protocol-version')).toBe('2025-06-18'); + expect(headers.get('last-event-id')).toBe('token-123'); + // The connection-level requestInit still applies on the same origin. + expect(init.cache).toBe('no-store'); + expect(init.redirect).toBe('manual'); + }); + + it('follows a cross-origin GET redirect without the connection-configured headers', async () => { + fetchMock.mockResolvedValueOnce(redirectResponse('http://elsewhere.example:9999/mcp')).mockResolvedValueOnce(sseResponse()); + await transport.start(); + await transport.resumeStream('token-123'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, init] = fetchMock.mock.calls[1]!; + expect(url.toString()).toBe('http://elsewhere.example:9999/mcp'); + const headers = init.headers as Headers; + // Connection-scoped values stay on the configured origin. + expect(headers.get('authorization')).toBeNull(); + expect(headers.get('x-custom-header')).toBeNull(); + expect(headers.get('mcp-session-id')).toBeNull(); + // The request-describing set the target needs is carried. + expect(headers.get('accept')).toBe('text/event-stream'); + expect(headers.get('mcp-protocol-version')).toBe('2025-06-18'); + expect(headers.get('last-event-id')).toBe('token-123'); + // The connection-level requestInit does not apply either. + expect('cache' in init).toBe(false); + expect(init.redirect).toBe('manual'); + }); + + it('keeps the minimal header set for the rest of the chain once a hop leaves the origin', async () => { + fetchMock + .mockResolvedValueOnce(redirectResponse('http://elsewhere.example:9999/mcp')) + .mockResolvedValueOnce(redirectResponse('http://localhost:1234/mcp-return')) + .mockResolvedValueOnce(sseResponse()); + await transport.start(); + await transport.resumeStream('token-123'); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const [url, init] = fetchMock.mock.calls[2]!; + // The chain pointed back to the configured origin, but header dropping + // is never undone within a chain. + expect(url.toString()).toBe('http://localhost:1234/mcp-return'); + const headers = init.headers as Headers; + expect(headers.get('authorization')).toBeNull(); + expect(headers.get('x-custom-header')).toBeNull(); + expect(headers.get('accept')).toBe('text/event-stream'); + }); + + it.each([ + ['same-origin', 'http://localhost:1234/mcp-moved'], + ['cross-origin', 'http://elsewhere.example:9999/mcp'] + ])('does not re-send a POST answered with a %s redirect', async (_kind, location) => { + fetchMock.mockResolvedValue(redirectResponse(location)); + await transport.start(); + + const error = await transport.send(testMessage).catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).status).toBe(307); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not adopt a session id from a redirect response', async () => { + const headers = new Headers({ location: 'http://localhost:1234/mcp-moved', 'mcp-session-id': 'minted-elsewhere' }); + fetchMock.mockResolvedValue(new Response(null, { status: 307, headers })); + const freshTransport = new StreamableHTTPClientTransport(new URL(ENDPOINT_URL), { + fetch: fetchMock as unknown as typeof fetch + }); + freshTransport.onerror = vi.fn(); + await freshTransport.start(); + + await expect(freshTransport.send(testMessage)).rejects.toThrow(SdkHttpError); + expect(freshTransport.sessionId).toBeUndefined(); + await freshTransport.close(); + }); + + it('does not re-send a DELETE answered with a redirect', async () => { + fetchMock.mockResolvedValue(redirectResponse('http://localhost:1234/mcp-moved', 308)); + await transport.start(); + + const error = await transport.terminateSession().catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).status).toBe(308); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('stops following GET redirects after 3 hops', async () => { + fetchMock.mockResolvedValue(redirectResponse('http://localhost:1234/again')); + await transport.start(); + + const error = await transport.resumeStream('token-123').catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).message).toMatch(/Redirect limit of 3 hops exceeded/); + // Initial request plus the 3 followed hops. + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('surfaces a GET redirect without a Location header as an error', async () => { + fetchMock.mockResolvedValue(redirectResponse(null, 302)); + await transport.start(); + + const error = await transport.resumeStream('token-123').catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).message).toMatch(/without a Location header/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['GET', (): Promise => transport.resumeStream('token-123')], + ['POST', (): Promise => transport.send(testMessage)] + ] as const)( + 'surfaces a runtime-filtered redirect (opaqueredirect) of a %s as a clear error naming redirectPolicy', + async (_method, request) => { + fetchMock.mockResolvedValue(opaqueRedirectResponse()); + await transport.start(); + + const error = await request().catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).status).toBe(0); + expect((error as SdkHttpError).message).toMatch(/redirect this runtime filters/); + expect((error as SdkHttpError).message).toMatch(/redirectPolicy: 'follow'/); + expect((error as SdkHttpError).message).toMatch(/without redirects/); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); + + it('surfaces a runtime-filtered redirect on a followed GET hop the same way', async () => { + fetchMock + .mockResolvedValueOnce(redirectResponse('http://localhost:1234/mcp-moved')) + .mockResolvedValueOnce(opaqueRedirectResponse()); + await transport.start(); + + const error = await transport.resumeStream('token-123').catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).status).toBe(0); + expect((error as SdkHttpError).message).toMatch(/redirectPolicy: 'follow'/); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("redirectPolicy: 'follow' is unaffected in runtimes that filter manual-redirect responses", async () => { + await transport.close(); + transport = makeTransport({ redirectPolicy: 'follow' }); + transport.onerror = vi.fn(); + await transport.start(); + + // A runtime that filters redirect responses does so only for + // redirect: 'manual' requests; the 'follow' policy never sets the + // field, so the platform follows the chain and the transport sees the + // chain-end response. + fetchMock.mockImplementation(async (_url: URL, init: RequestInit) => + init.redirect === 'manual' ? opaqueRedirectResponse() : new Response(null, { status: 202 }) + ); + await expect(transport.send(testMessage)).resolves.toBeUndefined(); + + fetchMock.mockImplementation(async (_url: URL, init: RequestInit) => + init.redirect === 'manual' ? opaqueRedirectResponse() : sseResponse() + ); + await expect(transport.resumeStream('token-123')).resolves.toBeUndefined(); + }); + + it("redirectPolicy: 'follow' delegates redirect handling to the fetch implementation", async () => { + await transport.close(); + transport = makeTransport({ redirectPolicy: 'follow', requestInit: { redirect: 'error' } }); + transport.onerror = vi.fn(); + await transport.start(); + + fetchMock.mockResolvedValue(new Response(null, { status: 202 })); + await transport.send(testMessage); + // No manual override: the requestInit's own redirect mode flows through. + expect(fetchMock.mock.calls[0]![1].redirect).toBe('error'); + + // A 3xx that still reaches the transport is not intercepted or + // followed by the transport; it fails the ordinary status handling. + fetchMock.mockResolvedValue(redirectResponse('http://localhost:1234/mcp-moved')); + const error = await transport.resumeStream('token-123').catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpFailedToOpenStream); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('SSEClientTransport redirect handling', () => { + let serverA: Server; + let serverB: Server; + let baseA: URL; + let baseB: URL; + let requestsA: { method: string; url: string; headers: IncomingMessage['headers'] }[]; + let requestsB: { method: string; url: string; headers: IncomingMessage['headers'] }[]; + let handleA: (req: IncomingMessage, res: import('node:http').ServerResponse) => void; + let handleB: (req: IncomingMessage, res: import('node:http').ServerResponse) => void; + let transport: SSEClientTransport; + + const serveSseStream = (res: import('node:http').ServerResponse): void => { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + res.write('event: endpoint\n'); + res.write('data: /messages\n\n'); + }; + + beforeEach(async () => { + requestsA = []; + requestsB = []; + handleA = (_req, res) => res.writeHead(404).end(); + handleB = (_req, res) => res.writeHead(404).end(); + + serverA = createServer((req, res) => { + requestsA.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers }); + handleA(req, res); + }); + serverB = createServer((req, res) => { + requestsB.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers }); + handleB(req, res); + }); + baseA = await listenOnRandomPort(serverA); + baseB = await listenOnRandomPort(serverB); + }); + + afterEach(async () => { + await transport?.close().catch(() => {}); + serverA.closeAllConnections?.(); + serverB.closeAllConnections?.(); + await new Promise(resolve => serverA.close(resolve)); + await new Promise(resolve => serverB.close(resolve)); + }); + + const makeTransport = (): SSEClientTransport => + new SSEClientTransport(new URL('/sse', baseA), { + authProvider: { token: async () => 'test-token' }, + requestInit: { headers: { 'x-custom-header': 'custom-value' } } + }); + + it('follows a same-origin redirect of the stream GET with headers intact', async () => { + handleA = (req, res) => { + if (req.url === '/sse') { + res.writeHead(307, { Location: '/sse-moved' }).end(); + return; + } + if (req.url === '/sse-moved') { + serveSseStream(res); + return; + } + res.writeHead(404).end(); + }; + + transport = makeTransport(); + await transport.start(); + + expect(requestsA.map(r => r.url)).toEqual(['/sse', '/sse-moved']); + const followed = requestsA[1]!; + expect(followed.headers['authorization']).toBe('Bearer test-token'); + expect(followed.headers['x-custom-header']).toBe('custom-value'); + expect(followed.headers['accept']).toBe('text/event-stream'); + }); + + it('follows a cross-origin redirect of the stream GET without the connection-configured headers', async () => { + handleA = (req, res) => { + if (req.url === '/sse') { + res.writeHead(307, { Location: new URL('/sse', baseB).href }).end(); + return; + } + res.writeHead(404).end(); + }; + handleB = (req, res) => { + if (req.url === '/sse') { + serveSseStream(res); + return; + } + res.writeHead(404).end(); + }; + + transport = makeTransport(); + await transport.start(); + + expect(requestsB.map(r => r.url)).toEqual(['/sse']); + const followed = requestsB[0]!; + expect(followed.headers['authorization']).toBeUndefined(); + expect(followed.headers['x-custom-header']).toBeUndefined(); + expect(followed.headers['accept']).toBe('text/event-stream'); + }); + + it('does not re-send a message POST answered with a redirect', async () => { + handleA = (req, res) => { + if (req.url === '/sse') { + serveSseStream(res); + return; + } + if (req.url === '/messages' && req.method === 'POST') { + res.writeHead(307, { Location: '/messages-moved' }).end(); + return; + } + res.writeHead(404).end(); + }; + + transport = makeTransport(); + transport.onerror = vi.fn(); + await transport.start(); + + const error = await transport.send(testMessage).catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).status).toBe(307); + expect(requestsA.filter(r => r.url === '/messages-moved')).toHaveLength(0); + }); + + it('fails start() with the typed error (no reconnect) when the stream GET redirect cannot be followed', async () => { + // A redirect without a Location can never be followed; the EventSource + // must not schedule reconnects against it, and the typed error (not a + // flattened SseError) must reach the caller. + handleA = (req, res) => { + if (req.url === '/sse') { + res.writeHead(307).end(); + return; + } + res.writeHead(404).end(); + }; + + transport = makeTransport(); + transport.onerror = vi.fn(); + + const error = await transport.start().catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).status).toBe(307); + expect(requestsA).toHaveLength(1); + }); + + it('fails start() with the clear error naming redirectPolicy on a runtime-filtered redirect of the stream GET', async () => { + const fetchMock = vi.fn(async () => opaqueRedirectResponse()); + transport = new SSEClientTransport(new URL('/sse', baseA), { + fetch: fetchMock as unknown as typeof fetch + }); + transport.onerror = vi.fn(); + transport.onclose = vi.fn(); + + const error = await transport.start().catch((e: unknown) => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpRedirectNotFollowed); + expect((error as SdkHttpError).status).toBe(0); + expect((error as SdkHttpError).message).toMatch(/redirectPolicy: 'follow'/); + // Terminal, not retried: the transport closed before the error propagated. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(transport.onclose).toHaveBeenCalled(); + }); +}); diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 21f42f6ef8..a99d865107 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -78,7 +78,12 @@ export enum SdkErrorCode { ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN', ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT', ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', - ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' + ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION', + /** + * A redirect (3xx) the client transport does not follow; `status` carries + * the redirect status code, or `0` for a runtime-filtered opaque redirect. + */ + ClientHttpRedirectNotFollowed = 'CLIENT_HTTP_REDIRECT_NOT_FOLLOWED' } /** diff --git a/packages/core-internal/test/types/errorSurfacePins.test.ts b/packages/core-internal/test/types/errorSurfacePins.test.ts index cc01cf4c57..ba33df3642 100644 --- a/packages/core-internal/test/types/errorSurfacePins.test.ts +++ b/packages/core-internal/test/types/errorSurfacePins.test.ts @@ -86,7 +86,8 @@ describe('SdkErrorCode', () => { ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', ClientHttpUnexpectedContent: 'CLIENT_HTTP_UNEXPECTED_CONTENT', ClientHttpFailedToOpenStream: 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', - ClientHttpFailedToTerminateSession: 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' + ClientHttpFailedToTerminateSession: 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION', + ClientHttpRedirectNotFollowed: 'CLIENT_HTTP_REDIRECT_NOT_FOLLOWED' }); }); });