Skip to content

Commit 0462948

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', bounded at 3 hops): GET redirects are followed, carrying only request-descriptive headers (Accept, MCP-Protocol-Version, Last-Event-ID) once a hop leaves the endpoint's origin; POST/DELETE follow a same-origin 307/308 by re-sending the identical request (keeps trailing-slash-redirecting framework mounts working), while a same-origin 301/302/303 (platforms re-send those as GET) or any cross-origin redirect surfaces as SdkHttpError with the new code ClientHttpRedirectNotFollowed; a redirect response can no longer supply the session id the transport adopts. 'follow' delegates to the fetch implementation; 'error' makes every redirect terminal. - 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 f130e1a commit 0462948

20 files changed

Lines changed: 1712 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, bounded at 3 hops: `GET` redirects are followed — 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` follow a same-origin `307`/`308` by re-sending the identical request (method, body and headers preserved — this keeps deployments behind trailing-slash-redirecting framework mounts working); a same-origin `301`/`302`/`303` (platforms re-send those as `GET`, corrupting the request) or any cross-origin redirect — including an `http://` transport URL whose server upgrades to `https://`; origin is an exact scheme+host+port match — surfaces as an `SdkHttpError` with the new code `SdkErrorCode.ClientHttpRedirectNotFollowed`, and a redirect response can no longer supply the `mcp-session-id` the transport adopts. The new transport option `redirectPolicy: 'manual' | 'follow' | 'error'` (default `'manual'`) provides the escape hatches: `'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 instead of an unexplained status-0 failure. `'error'` treats every redirect answer as terminal. See the "HTTP & headers" section of `docs/migration/upgrade-to-v2.md`.

docs/migration/upgrade-to-v2.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,66 @@ clients always sent the correct header and are unaffected. Custom entries that
827827
compose `classifyInboundRequest` / `PerRequestHTTPServerTransport` directly must
828828
apply the same validation themselves — use the exported `isJsonContentType(header)`.
829829
830+
#### Redirect (3xx) responses on MCP requests
831+
832+
`StreamableHTTPClientTransport` and `SSEClientTransport` no longer delegate redirect
833+
handling on their MCP requests to the `fetch` implementation (v1 let the platform
834+
follow up to 20 hops with every header riding along). Data-plane requests — `POST`
835+
message sends, the `GET` that opens or resumes an SSE stream, the session-terminating
836+
`DELETE` — now go out with `redirect: 'manual'` and the transport applies RFC 9110
837+
redirect semantics itself:
838+
839+
- **`GET`** redirects are followed, bounded at **3 hops**. A same-origin `Location`
840+
target is requested with the original headers. A hop that leaves the endpoint's
841+
origin is requested with only the headers that describe the request itself
842+
(`Accept`, `MCP-Protocol-Version`, and — on `StreamableHTTPClientTransport`
843+
stream resumption — `Last-Event-ID`: what the target needs to negotiate the
844+
media type, parse the request, and resume the stream). Headers
845+
configured for the _connection_ — `requestInit` headers, the auth provider's
846+
`Authorization`, the server-issued `Mcp-Session-Id` — are scoped to the configured
847+
origin and are not applied across origins. Once a chain has left the origin,
848+
the reduced header set applies to every remaining hop, including one that
849+
returns to the original origin.
850+
- **`POST` / `DELETE`** follow a **same-origin `307`/`308`** by re-sending the
851+
identical request — method, body and headers preserved; same origin, so nothing
852+
needs scrubbing. This keeps deployments behind trailing-slash-redirecting
853+
framework mounts (an endpoint that answers `/mcp` with a `307` to `/mcp/`)
854+
working out of the box. Two shapes surface as `SdkHttpError` with the new code
855+
`SdkErrorCode.ClientHttpRedirectNotFollowed` instead of being re-sent:
856+
- a same-origin **`301`/`302`/`303`** — platforms re-send those as `GET`
857+
(RFC 9110 §15.4), which would corrupt an MCP request; the error names the
858+
trailing-slash mismatch when the target differs only by one, since fixing
859+
the transport URL is the usual remedy;
860+
- **any cross-origin redirect** — origin is an exact scheme+host+port match,
861+
so this includes an `http://` transport URL whose server upgrades to
862+
`https://`.
863+
864+
A redirect response also can no longer supply the `mcp-session-id` value the
865+
transport adopts — session ids are only read off the response the chain's final
866+
endpoint answered directly.
867+
868+
The escape hatches are the new `redirectPolicy` transport option (both transports):
869+
870+
```ts
871+
const transport = new StreamableHTTPClientTransport(url, {
872+
redirectPolicy: 'follow' // default: 'manual'
873+
});
874+
```
875+
876+
`'follow'` restores the v1 request shape exactly — no `redirect` field is set, so
877+
`requestInit.redirect` or the platform default (`'follow'`) applies and every
878+
request's headers ride along to wherever the chain ends. Set it for deployments
879+
behind redirecting infrastructure that requires the configured headers on the
880+
redirect target, and for browser runtimes: a browser `fetch` answers
881+
`redirect: 'manual'` with an opaque redirect (Fetch `opaqueredirect`: status 0, no
882+
readable `Location` header), so the transport cannot observe the redirect target to
883+
apply the rules above. Under the default `'manual'` policy any redirect answer —
884+
initial or on a followed hop, any method — therefore fails there with
885+
`ClientHttpRedirectNotFollowed` (`status` is the literal `0`), its message naming the
886+
two ways out: set `redirectPolicy: 'follow'`, or serve the MCP endpoint without
887+
redirects. `'error'` goes the other way and treats **every** redirect answer as
888+
terminal — for deployments that want no redirect following at all.
889+
830890
### Errors
831891
832892
The SDK now distinguishes three error kinds:
@@ -1110,6 +1170,22 @@ OAuth `onUnauthorized` behavior, for composing your own adapter).
11101170
- **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()`
11111171
treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of
11121172
throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw.
1173+
- **Transport `requestInit` headers stay off OAuth requests.** Headers configured via
1174+
the `requestInit` option on `StreamableHTTPClientTransport` / `SSEClientTransport`
1175+
apply only to MCP requests; the OAuth requests the transport's authorization flow
1176+
issues (protected-resource metadata, authorization-server metadata, token, and client
1177+
registration requests) are sent without them — those may target a different origin
1178+
than the MCP server, so connection-level headers do not carry over. A deployment that
1179+
needs extra headers on those requests (e.g. gateway headers on well-known endpoints)
1180+
sets the new `oauthRequestInit` transport option.
1181+
- **Token and registration endpoint redirects are not followed.** The token-exchange
1182+
and client-registration POSTs (`exchangeAuthorization()` / `refreshAuthorization()` /
1183+
`fetchToken()` / `registerClient()`, transitively `auth()`, and the cross-app access
1184+
token exchanges `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()`) are issued with
1185+
`redirect: 'manual'`; a 3xx answer rejects with an error instead of re-sending the
1186+
request to the redirect target. Token responses are terminal (RFC 6749 §5) — an
1187+
authorization server that redirects these requests must be addressed at its final
1188+
endpoint URL (via its metadata document).
11131189
- **Scoped credential invalidation on `invalid_client` / `unauthorized_client`.** The
11141190
`auth()` retry for these errors now issues two scoped calls —
11151191
`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)