Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/token-registration-redirects-terminal.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/transport-headers-oauth-requests.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/transport-redirect-policy.md
Original file line number Diff line number Diff line change
@@ -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, 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`.
76 changes: 76 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,66 @@ clients always sent the correct header and are unaffected. Custom entries that
compose `classifyInboundRequest` / `PerRequestHTTPServerTransport` directly must
apply the same validation themselves — use the exported `isJsonContentType(header)`.

#### Redirect (3xx) responses on MCP requests
Comment thread
claude[bot] marked this conversation as resolved.

`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`** follow a **same-origin `307`/`308`** by re-sending the
identical request — method, body and headers preserved; same origin, so nothing
needs scrubbing. This keeps deployments behind trailing-slash-redirecting
framework mounts (an endpoint that answers `/mcp` with a `307` to `/mcp/`)
working out of the box. Two shapes surface as `SdkHttpError` with the new code
`SdkErrorCode.ClientHttpRedirectNotFollowed` instead of being re-sent:
- a same-origin **`301`/`302`/`303`** — platforms re-send those as `GET`
(RFC 9110 §15.4), which would corrupt an MCP request; the error names the
trailing-slash mismatch when the target differs only by one, since fixing
the transport URL is the usual remedy;
- **any cross-origin redirect** — origin is an exact scheme+host+port match,
so this includes an `http://` transport URL whose server upgrades to
`https://`.

A redirect response also can no longer supply the `mcp-session-id` value the
transport adopts — session ids are only read off the response the chain's final
endpoint answered directly.

The escape hatches are 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. `'error'` goes the other way and treats **every** redirect answer as
terminal — for deployments that want no redirect following at all.

### Errors

The SDK now distinguishes three error kinds:
Expand Down Expand Up @@ -1110,6 +1170,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
Expand Down
34 changes: 28 additions & 6 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import {
OAuthTokensSchema,
OpenIdProviderDiscoveryMetadataSchema,
resourceUrlFromServerUrl,
stampErrorBrands
stampErrorBrands,
unmergedFetch
} from '@modelcontextprotocol/core-internal';
import pkceChallenge from 'pkce-challenge';

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

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

Expand Down Expand Up @@ -1565,9 +1567,11 @@ async function fetchWithCorsRetry(url: URL, headers?: Record<string, string>, fe
}
if (headers) {
// Could be a CORS preflight rejection caused by our custom header. Retry as a simple
// request: if that succeeds, we've sidestepped the preflight.
// request: if that succeeds, we've sidestepped the preflight. The retry bypasses any
// `createFetchWithInit` merging (`oauthRequestInit`) — a retry that re-acquires
// configured headers preflights identically and can never sidestep anything.
try {
return await fetchFn(url, {});
return await unmergedFetch(fetchFn)(url, {});
} catch (retryError) {
if (!(retryError instanceof TypeError)) {
throw retryError;
Expand Down Expand Up @@ -2046,6 +2050,20 @@ 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 async function assertNotRedirected(response: Response, endpoint: 'Token' | 'Registration'): Promise<void> {
if (response.type === 'opaqueredirect' || REDIRECT_STATUS_CODES.has(response.status)) {
// Release the redirect response before erroring.
await response.text?.().catch(() => {});
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}.
Expand Down Expand Up @@ -2090,8 +2108,10 @@ export async function executeTokenRequest(
const response = await (fetchFn ?? fetch)(tokenUrl, {
method: 'POST',
headers,
body: tokenRequestParams
body: tokenRequestParams,
redirect: 'manual'
});
await assertNotRedirected(response, 'Token');

if (!response.ok) {
throw await parseErrorResponse(response);
Expand Down Expand Up @@ -2365,8 +2385,10 @@ export async function registerClient(
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(submittedMetadata)
body: JSON.stringify(submittedMetadata),
redirect: 'manual'
});
await assertNotRedirected(response, 'Registration');

if (!response.ok) {
throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata });
Expand Down
10 changes: 7 additions & 3 deletions packages/client/src/client/crossAppAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'
});
await assertNotRedirected(response, 'Token');

if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
Expand Down Expand Up @@ -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'
});
await assertNotRedirected(response, 'Token');

if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
Expand Down
Loading
Loading