Skip to content

Commit 3fd6ef1

Browse files
fix(client): scope transport headers to MCP requests and handle redirects explicitly
The client transports applied their configured requestInit headers to every request they issue and relied on the platform's implicit redirect handling, so connection-scoped headers rode along to OAuth endpoints and to redirect targets on other origins. - requestInit now applies only to MCP requests. The OAuth requests the authorization flow issues (metadata discovery, token, registration) are configured separately via the new oauthRequestInit option, and the CORS preflight-sidestep retry during discovery stays a bare simple request instead of re-acquiring merged headers. - MCP requests are issued under the new redirectPolicy option (default 'manual'): GET redirects are followed up to 3 hops, carrying only request-descriptive headers (Accept, MCP-Protocol-Version, Last-Event-ID) once a hop leaves the endpoint's origin; POST/DELETE redirects surface as SdkHttpError with the new code ClientHttpRedirectNotFollowed instead of being re-sent; a redirect response can no longer supply the session id the transport adopts. 'follow' restores delegation to the fetch implementation. - Token and client registration responses are terminal (RFC 6749 §5, RFC 7591 §3.2): a 3xx answer rejects instead of re-sending the request, including the cross-app access token exchanges. - Terminal redirect errors stop SSE retry loops in both transports, and the version negotiation probe surfaces them instead of misreading a redirect answer as legacy-era evidence.
1 parent 6dac272 commit 3fd6ef1

20 files changed

Lines changed: 1490 additions & 83 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
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.
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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,56 @@ clients always sent the correct header and are unaffected. Custom entries that
828828
compose `classifyInboundRequest` / `PerRequestHTTPServerTransport` directly must
829829
apply the same validation themselves — use the exported `isJsonContentType(header)`.
830830
831+
#### Redirect (3xx) responses on MCP requests
832+
833+
`StreamableHTTPClientTransport` and `SSEClientTransport` no longer delegate redirect
834+
handling on their MCP requests to the `fetch` implementation (v1 let the platform
835+
follow up to 20 hops with every header riding along). Data-plane requests — `POST`
836+
message sends, the `GET` that opens or resumes an SSE stream, the session-terminating
837+
`DELETE` — now go out with `redirect: 'manual'` and the transport applies RFC 9110
838+
redirect semantics itself:
839+
840+
- **`GET`** redirects are followed, bounded at **3 hops**. A same-origin `Location`
841+
target is requested with the original headers. A hop that leaves the endpoint's
842+
origin is requested with only the headers that describe the request itself
843+
(`Accept`, `MCP-Protocol-Version`, and — on `StreamableHTTPClientTransport`
844+
stream resumption — `Last-Event-ID`: what the target needs to negotiate the
845+
media type, parse the request, and resume the stream). Headers
846+
configured for the _connection_ — `requestInit` headers, the auth provider's
847+
`Authorization`, the server-issued `Mcp-Session-Id` — are scoped to the configured
848+
origin and are not applied across origins. Once a chain has left the origin,
849+
the reduced header set applies to every remaining hop, including one that
850+
returns to the original origin.
851+
- **`POST` / `DELETE`** requests are **never re-sent** to a `Location` target
852+
(RFC 9110 leaves redirecting a request with a body to the sender's discretion). A
853+
redirect answer surfaces as `SdkHttpError` with the new code
854+
`SdkErrorCode.ClientHttpRedirectNotFollowed`; an MCP endpoint that answers `POST`
855+
with a redirect is a configuration to fix by pointing the transport at the new
856+
endpoint URL. A redirect response also can no longer supply the `mcp-session-id`
857+
value the transport adopts — session ids are only read off responses the
858+
configured endpoint answered directly.
859+
860+
The escape hatch is the new `redirectPolicy` transport option (both transports):
861+
862+
```ts
863+
const transport = new StreamableHTTPClientTransport(url, {
864+
redirectPolicy: 'follow' // default: 'manual'
865+
});
866+
```
867+
868+
`'follow'` restores the v1 request shape exactly — no `redirect` field is set, so
869+
`requestInit.redirect` or the platform default (`'follow'`) applies and every
870+
request's headers ride along to wherever the chain ends. Set it for deployments
871+
behind redirecting infrastructure that requires the configured headers on the
872+
redirect target, and for browser runtimes: a browser `fetch` answers
873+
`redirect: 'manual'` with an opaque redirect (Fetch `opaqueredirect`: status 0, no
874+
readable `Location` header), so the transport cannot observe the redirect target to
875+
apply the rules above. Under the default `'manual'` policy any redirect answer —
876+
initial or on a followed hop, any method — therefore fails there with
877+
`ClientHttpRedirectNotFollowed` (`status` is the literal `0`), its message naming the
878+
two ways out: set `redirectPolicy: 'follow'`, or serve the MCP endpoint without
879+
redirects.
880+
831881
### Errors
832882
833883
The SDK now distinguishes three error kinds:
@@ -1111,6 +1161,22 @@ OAuth `onUnauthorized` behavior, for composing your own adapter).
11111161
- **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()`
11121162
treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of
11131163
throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw.
1164+
- **Transport `requestInit` headers stay off OAuth requests.** Headers configured via
1165+
the `requestInit` option on `StreamableHTTPClientTransport` / `SSEClientTransport`
1166+
apply only to MCP requests; the OAuth requests the transport's authorization flow
1167+
issues (protected-resource metadata, authorization-server metadata, token, and client
1168+
registration requests) are sent without them — those may target a different origin
1169+
than the MCP server, so connection-level headers do not carry over. A deployment that
1170+
needs extra headers on those requests (e.g. gateway headers on well-known endpoints)
1171+
sets the new `oauthRequestInit` transport option.
1172+
- **Token and registration endpoint redirects are not followed.** The token-exchange
1173+
and client-registration POSTs (`exchangeAuthorization()` / `refreshAuthorization()` /
1174+
`fetchToken()` / `registerClient()`, transitively `auth()`, and the cross-app access
1175+
token exchanges `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()`) are issued with
1176+
`redirect: 'manual'`; a 3xx answer rejects with an error instead of re-sending the
1177+
request to the redirect target. Token responses are terminal (RFC 6749 §5) — an
1178+
authorization server that redirects these requests must be addressed at its final
1179+
endpoint URL (via its metadata document).
11141180
- **Scoped credential invalidation on `invalid_client` / `unauthorized_client`.** The
11151181
`auth()` retry for these errors now issues two scoped calls —
11161182
`invalidateCredentials('client')` then `invalidateCredentials('tokens')` — instead of

packages/client/src/client/auth.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ import {
2525
OAuthTokensSchema,
2626
OpenIdProviderDiscoveryMetadataSchema,
2727
resourceUrlFromServerUrl,
28-
stampErrorBrands
28+
stampErrorBrands,
29+
unmergedFetch
2930
} from '@modelcontextprotocol/core-internal';
3031
import pkceChallenge from 'pkce-challenge';
3132

3233
import { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
34+
import { REDIRECT_STATUS_CODES } from './transportRedirect';
3335

3436
// Re-exported for back-compat — the canonical home is ./authErrors.js.
3537
export { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
@@ -53,7 +55,7 @@ export interface UnauthorizedContext {
5355
response: Response;
5456
/** The MCP server URL, for passing to {@linkcode auth} or discovery helpers. */
5557
serverUrl: URL;
56-
/** Fetch function configured with the transport's `requestInit`, for making auth requests. */
58+
/** Fetch function configured with the transport's `oauthRequestInit`, for making auth requests. */
5759
fetchFn: FetchLike;
5860
}
5961

@@ -1565,9 +1567,11 @@ async function fetchWithCorsRetry(url: URL, headers?: Record<string, string>, fe
15651567
}
15661568
if (headers) {
15671569
// Could be a CORS preflight rejection caused by our custom header. Retry as a simple
1568-
// request: if that succeeds, we've sidestepped the preflight.
1570+
// request: if that succeeds, we've sidestepped the preflight. The retry bypasses any
1571+
// `createFetchWithInit` merging (`oauthRequestInit`) — a retry that re-acquires
1572+
// configured headers preflights identically and can never sidestep anything.
15691573
try {
1570-
return await fetchFn(url, {});
1574+
return await unmergedFetch(fetchFn)(url, {});
15711575
} catch (retryError) {
15721576
if (!(retryError instanceof TypeError)) {
15731577
throw retryError;
@@ -2046,6 +2050,20 @@ export function prepareAuthorizationCodeRequest(
20462050
});
20472051
}
20482052

2053+
/**
2054+
* Token and registration responses are terminal (RFC 6749 §5, RFC 7591 §3.2):
2055+
* a redirect — including one the runtime filters to `opaqueredirect` — is an error.
2056+
*/
2057+
export async function assertNotRedirected(response: Response, endpoint: 'Token' | 'Registration'): Promise<void> {
2058+
if (response.type === 'opaqueredirect' || REDIRECT_STATUS_CODES.has(response.status)) {
2059+
// Release the redirect response before erroring.
2060+
await response.text?.().catch(() => {});
2061+
throw new Error(
2062+
`${endpoint} endpoint responded with a redirect (HTTP ${response.status || 'filtered by the runtime'}); ${endpoint.toLowerCase()} responses are terminal`
2063+
);
2064+
}
2065+
}
2066+
20492067
/**
20502068
* Internal helper to execute a token request with the given parameters.
20512069
* Used by {@linkcode exchangeAuthorization}, {@linkcode refreshAuthorization}, and {@linkcode fetchToken}.
@@ -2090,8 +2108,10 @@ export async function executeTokenRequest(
20902108
const response = await (fetchFn ?? fetch)(tokenUrl, {
20912109
method: 'POST',
20922110
headers,
2093-
body: tokenRequestParams
2111+
body: tokenRequestParams,
2112+
redirect: 'manual'
20942113
});
2114+
await assertNotRedirected(response, 'Token');
20952115

20962116
if (!response.ok) {
20972117
throw await parseErrorResponse(response);
@@ -2365,8 +2385,10 @@ export async function registerClient(
23652385
headers: {
23662386
'Content-Type': 'application/json'
23672387
},
2368-
body: JSON.stringify(submittedMetadata)
2388+
body: JSON.stringify(submittedMetadata),
2389+
redirect: 'manual'
23692390
});
2391+
await assertNotRedirected(response, 'Registration');
23702392

23712393
if (!response.ok) {
23722394
throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata });

packages/client/src/client/crossAppAccess.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal';
1212
import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal';
1313

1414
import type { ClientAuthMethod } from './auth';
15-
import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth';
15+
import { applyClientAuthentication, assertNotRedirected, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth';
1616

1717
/**
1818
* Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange.
@@ -152,8 +152,10 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
152152
headers: {
153153
'Content-Type': 'application/x-www-form-urlencoded'
154154
},
155-
body: params.toString()
155+
body: params.toString(),
156+
redirect: 'manual'
156157
});
158+
await assertNotRedirected(response, 'Token');
157159

158160
if (!response.ok) {
159161
const errorBody = await response.json().catch(() => ({}));
@@ -279,8 +281,10 @@ export async function exchangeJwtAuthGrant(options: {
279281
const response = await fetchFn(tokenUrl, {
280282
method: 'POST',
281283
headers,
282-
body: params.toString()
284+
body: params.toString(),
285+
redirect: 'manual'
283286
});
287+
await assertNotRedirected(response, 'Token');
284288

285289
if (!response.ok) {
286290
const errorBody = await response.json().catch(() => ({}));

0 commit comments

Comments
 (0)