Skip to content

Commit 62b2baa

Browse files
Handle data-plane redirects explicitly in the client transports
StreamableHTTPClientTransport and SSEClientTransport issue their MCP requests (POST sends, SSE stream GETs, session DELETE) with redirect: 'manual' and apply RFC 9110 redirect semantics themselves: GET redirects are followed for up to 3 hops, with the connection's configured headers scoped to the endpoint's origin (a cross-origin hop carries only Accept, MCP-Protocol-Version and Last-Event-ID); POST/DELETE requests are never re-sent to a Location target and surface the redirect as SdkHttpError with the new code ClientHttpRedirectNotFollowed. A redirect response can no longer supply the mcp-session-id the transport adopts. The new redirectPolicy transport option ('manual' | 'follow', default 'manual') restores delegation to the fetch implementation for deployments behind redirecting infrastructure and for browser runtimes.
1 parent 5c8b73e commit 62b2baa

9 files changed

Lines changed: 740 additions & 27 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
'@modelcontextprotocol/core-internal': patch
4+
---
5+
6+
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`.

docs/migration/upgrade-to-v2.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,54 @@ value to the spec-required `application/json, text/event-stream` (v1 let it repl
811811
them). The required media types are always present; additional types are kept for
812812
proxy/gateway routing.
813813
814+
#### Redirect (3xx) responses on MCP requests
815+
816+
`StreamableHTTPClientTransport` and `SSEClientTransport` no longer delegate redirect
817+
handling on their MCP requests to the `fetch` implementation (v1 let the platform
818+
follow up to 20 hops with every header riding along). Data-plane requests — `POST`
819+
message sends, the `GET` that opens or resumes an SSE stream, the session-terminating
820+
`DELETE` — now go out with `redirect: 'manual'` and the transport applies RFC 9110
821+
redirect semantics itself:
822+
823+
- **`GET`** redirects are followed, bounded at **3 hops**. A same-origin `Location`
824+
target is requested with the original headers. A hop that leaves the endpoint's
825+
origin is requested with only the headers that describe the request itself
826+
(`Accept`, `MCP-Protocol-Version`, and — on `StreamableHTTPClientTransport`
827+
stream resumption — `Last-Event-ID`: what the target needs to negotiate the
828+
media type, parse the request, and resume the stream). Headers
829+
configured for the *connection*`requestInit` headers, the auth provider's
830+
`Authorization`, the server-issued `Mcp-Session-Id` — are scoped to the configured
831+
origin and are not applied across origins.
832+
- **`POST` / `DELETE`** requests are **never re-sent** to a `Location` target
833+
(RFC 9110 leaves redirecting a request with a body to the sender's discretion). A
834+
redirect answer surfaces as `SdkHttpError` with the new code
835+
`SdkErrorCode.ClientHttpRedirectNotFollowed`; an MCP endpoint that answers `POST`
836+
with a redirect is a configuration to fix by pointing the transport at the new
837+
endpoint URL. A redirect response also can no longer supply the `mcp-session-id`
838+
value the transport adopts — session ids are only read off responses the
839+
configured endpoint answered directly.
840+
841+
The escape hatch is the new `redirectPolicy` transport option (both transports):
842+
843+
```ts
844+
const transport = new StreamableHTTPClientTransport(url, {
845+
redirectPolicy: 'follow' // default: 'manual'
846+
});
847+
```
848+
849+
`'follow'` restores the v1 request shape exactly — no `redirect` field is set, so
850+
`requestInit.redirect` or the platform default (`'follow'`) applies and every
851+
request's headers ride along to wherever the chain ends. Set it for deployments
852+
behind redirecting infrastructure that requires the configured headers on the
853+
redirect target, and for browser runtimes: a browser `fetch` answers
854+
`redirect: 'manual'` with an opaque redirect (Fetch `opaqueredirect`: status 0, no
855+
readable `Location` header), so the transport cannot observe the redirect target to
856+
apply the rules above. Under the default `'manual'` policy any redirect answer —
857+
initial or on a followed hop, any method — therefore fails there with
858+
`ClientHttpRedirectNotFollowed` (`status` is the literal `0`), its message naming the
859+
two ways out: set `redirectPolicy: 'follow'`, or serve the MCP endpoint without
860+
redirects.
861+
814862
`hostHeaderValidation()` and `localhostHostValidation()` moved to
815863
`@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the
816864
same as every released v1.x — only the import path changes. Framework-agnostic helpers

packages/client/src/client/sse.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
} from './auth';
2424
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
2525
import type { IssuerMismatchError } from './authErrors';
26+
import type { RedirectPolicy } from './transportRedirect';
27+
import { fetchWithRedirectPolicy } from './transportRedirect';
2628

2729
export class SseError extends Error {
2830
static {
@@ -114,6 +116,14 @@ export type SSEClientTransportOptions = {
114116
* Custom fetch implementation used for all network requests.
115117
*/
116118
fetch?: FetchLike;
119+
120+
/**
121+
* How the transport reacts to redirect (3xx) responses on its MCP requests.
122+
* See {@linkcode RedirectPolicy}.
123+
*
124+
* @default 'manual'
125+
*/
126+
redirectPolicy?: RedirectPolicy;
117127
};
118128

119129
/**
@@ -136,6 +146,7 @@ export class SSEClientTransport implements Transport {
136146
private _fetch?: FetchLike;
137147
/** Fetch for OAuth requests — deliberately merges `oauthRequestInit`, never `requestInit`. */
138148
private _authFetch: FetchLike;
149+
private _redirectPolicy: RedirectPolicy;
139150
private _protocolVersion?: string;
140151

141152
onclose?: () => void;
@@ -158,6 +169,7 @@ export class SSEClientTransport implements Transport {
158169
this._authProvider = opts?.authProvider;
159170
}
160171
this._fetch = opts?.fetch;
172+
this._redirectPolicy = opts?.redirectPolicy ?? 'manual';
161173
this._authFetch = createFetchWithInit(opts?.fetch, opts?.oauthRequestInit);
162174
}
163175

@@ -189,10 +201,32 @@ export class SSEClientTransport implements Transport {
189201
fetch: async (url, init) => {
190202
const headers = await this._commonHeaders();
191203
headers.set('Accept', 'text/event-stream');
192-
const response = await fetchImpl(url, {
193-
...init,
194-
headers
195-
});
204+
const crossOriginHeaders = new Headers({ accept: 'text/event-stream' });
205+
if (this._protocolVersion) {
206+
crossOriginHeaders.set('mcp-protocol-version', this._protocolVersion);
207+
}
208+
let response: Response;
209+
try {
210+
response = await fetchWithRedirectPolicy({
211+
fetchFn: fetchImpl,
212+
url,
213+
method: 'GET',
214+
headers,
215+
requestInit: init as RequestInit,
216+
signal: (init as RequestInit | undefined)?.signal ?? undefined,
217+
redirectPolicy: this._redirectPolicy,
218+
crossOriginHeaders
219+
});
220+
} catch (error) {
221+
if (error instanceof SdkHttpError && error.code === SdkErrorCode.ClientHttpRedirectNotFollowed) {
222+
// Terminal, not transient: close so the EventSource does not
223+
// schedule reconnects against the same redirecting endpoint.
224+
reject(error);
225+
this.onerror?.(error);
226+
void this.close();
227+
}
228+
throw error;
229+
}
196230

197231
if (response.status === 401) {
198232
this._last401Response = response;
@@ -349,15 +383,16 @@ export class SSEClientTransport implements Transport {
349383
try {
350384
const headers = await this._commonHeaders();
351385
headers.set('content-type', 'application/json');
352-
const init = {
353-
...this._requestInit,
386+
const response = await fetchWithRedirectPolicy({
387+
fetchFn: this._fetch ?? fetch,
388+
url: this._endpoint,
354389
method: 'POST',
355390
headers,
391+
requestInit: this._requestInit,
356392
body: JSON.stringify(message),
357-
signal: this._abortController?.signal
358-
};
359-
360-
const response = await (this._fetch ?? fetch)(this._endpoint, init);
393+
signal: this._abortController?.signal,
394+
redirectPolicy: this._redirectPolicy
395+
});
361396
if (!response.ok) {
362397
if (response.status === 401 && this._authProvider) {
363398
if (response.headers.has('www-authenticate')) {

packages/client/src/client/streamableHttp.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import {
3333
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc
3434
import type { IssuerMismatchError } from './authErrors';
3535
import { InsufficientScopeError } from './authErrors';
36+
import type { RedirectPolicy } from './transportRedirect';
37+
import { fetchWithRedirectPolicy } from './transportRedirect';
3638

3739
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
3840
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
@@ -188,6 +190,14 @@ export type StreamableHTTPClientTransportOptions = {
188190
*/
189191
fetch?: FetchLike;
190192

193+
/**
194+
* How the transport reacts to redirect (3xx) responses on its MCP requests.
195+
* See {@linkcode RedirectPolicy}.
196+
*
197+
* @default 'manual'
198+
*/
199+
redirectPolicy?: RedirectPolicy;
200+
191201
/**
192202
* Options to configure the reconnection behavior.
193203
*/
@@ -317,6 +327,7 @@ export class StreamableHTTPClientTransport implements Transport {
317327
private _oauthProvider?: OAuthClientProvider;
318328
private _skipIssuerMetadataValidation?: boolean;
319329
private _fetch?: FetchLike;
330+
private _redirectPolicy: RedirectPolicy;
320331
/** Fetch for OAuth requests — deliberately merges `oauthRequestInit`, never `requestInit`. */
321332
private _authFetch: FetchLike;
322333
private _sessionId?: string;
@@ -355,6 +366,7 @@ export class StreamableHTTPClientTransport implements Transport {
355366
this._authProvider = opts?.authProvider;
356367
}
357368
this._fetch = opts?.fetch;
369+
this._redirectPolicy = opts?.redirectPolicy ?? 'manual';
358370
this._authFetch = createFetchWithInit(opts?.fetch, opts?.oauthRequestInit);
359371
this._sessionId = opts?.sessionId;
360372
this._protocolVersion = opts?.protocolVersion;
@@ -446,6 +458,22 @@ export class StreamableHTTPClientTransport implements Transport {
446458
});
447459
}
448460

461+
/**
462+
* Headers a followed `GET` redirect carries to another origin —
463+
* connection-scoped headers (`Authorization`, `Mcp-Session-Id`,
464+
* `requestInit`) are deliberately omitted.
465+
*/
466+
private _crossOriginGetHeaders(resumptionToken?: string): Headers {
467+
const headers = new Headers({ accept: 'text/event-stream' });
468+
if (this._protocolVersion) {
469+
headers.set('mcp-protocol-version', this._protocolVersion);
470+
}
471+
if (resumptionToken) {
472+
headers.set('last-event-id', resumptionToken);
473+
}
474+
return headers;
475+
}
476+
449477
/**
450478
* Body-derived per-request headers: when an outgoing request carries a
451479
* protocol-version claim in its `_meta` envelope (the version negotiation
@@ -529,11 +557,15 @@ export class StreamableHTTPClientTransport implements Transport {
529557
requestSignal !== undefined && transportSignal !== undefined
530558
? anySignal(transportSignal, requestSignal)
531559
: (requestSignal ?? transportSignal);
532-
const response = await (this._fetch ?? fetch)(this._url, {
533-
...this._requestInit,
560+
const response = await fetchWithRedirectPolicy({
561+
fetchFn: this._fetch ?? fetch,
562+
url: this._url,
534563
method: 'GET',
535564
headers,
536-
signal
565+
requestInit: this._requestInit,
566+
signal,
567+
redirectPolicy: this._redirectPolicy,
568+
crossOriginHeaders: this._crossOriginGetHeaders(resumptionToken)
537569
});
538570

539571
if (!response.ok) {
@@ -968,15 +1000,16 @@ export class StreamableHTTPClientTransport implements Transport {
9681000
options?.requestSignal !== undefined && transportSignal !== undefined
9691001
? anySignal(transportSignal, options.requestSignal)
9701002
: (options?.requestSignal ?? transportSignal);
971-
const init = {
972-
...this._requestInit,
1003+
const response = await fetchWithRedirectPolicy({
1004+
fetchFn: this._fetch ?? fetch,
1005+
url: this._url,
9731006
method: 'POST',
9741007
headers,
1008+
requestInit: this._requestInit,
9751009
body: JSON.stringify(message),
976-
signal
977-
};
978-
979-
const response = await (this._fetch ?? fetch)(this._url, init);
1010+
signal,
1011+
redirectPolicy: this._redirectPolicy
1012+
});
9801013

9811014
// Handle session ID received during initialization
9821015
const sessionId = response.headers.get('mcp-session-id');
@@ -1161,14 +1194,15 @@ export class StreamableHTTPClientTransport implements Transport {
11611194
try {
11621195
const headers = await this._commonHeaders();
11631196

1164-
const init = {
1165-
...this._requestInit,
1197+
const response = await fetchWithRedirectPolicy({
1198+
fetchFn: this._fetch ?? fetch,
1199+
url: this._url,
11661200
method: 'DELETE',
11671201
headers,
1168-
signal: this._abortController?.signal
1169-
};
1170-
1171-
const response = await (this._fetch ?? fetch)(this._url, init);
1202+
requestInit: this._requestInit,
1203+
signal: this._abortController?.signal,
1204+
redirectPolicy: this._redirectPolicy
1205+
});
11721206
await response.text?.().catch(() => {});
11731207

11741208
// We specifically handle 405 as a valid response according to the spec,

0 commit comments

Comments
 (0)