Skip to content
Open
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: `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`.
68 changes: 67 additions & 1 deletion docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new '#### Redirect (3xx) responses on MCP requests' subsection is inserted mid-way through the 'HTTP & headers' section, so the pre-existing hostHeaderValidation/localhostHostValidation and Content-Type/415 paragraphs that follow it (before '### Errors') are now structurally nested under the redirect subsection in any rendered TOC/outline, despite being unrelated to redirects. Move the new subsection to the end of the 'HTTP & headers' section (after those paragraphs), or give the trailing paragraphs their own heading.

Extended reasoning...

What the issue is

The migration guide's ### HTTP & headers section (line 782 of docs/migration/upgrade-to-v2.md) previously contained only flat paragraphs — no #### subsections. This PR inserts the new #### Redirect (3xx) responses on MCP requests heading at line 814, in the middle of that section. In Markdown's implicit outline model, a section extends until the next heading of the same or higher level — and the next heading after line 814 is ### Errors at line 880. Everything in between is therefore now a structural child of the redirect subsection.

What gets misfiled

Two pre-existing, unrelated blocks fall inside that span:

  1. The hostHeaderValidation() / localhostHostValidation() moved-to-@modelcontextprotocol/express paragraph (line 862).
  2. The Content-Type / 415 media-type validation paragraph (lines 868–878).

Neither has anything to do with redirects, but any hierarchical rendering — a generated table of contents, a docs-site outline/sidebar, section-scoped anchors or search extraction — now files them under "Redirect (3xx) responses on MCP requests".

Step-by-step proof

  1. Before this PR, the heading sequence was: ### HTTP & headers (782) → ### Errors; the host-header and Content-Type paragraphs were direct children of ### HTTP & headers.
  2. This PR inserts #### Redirect (3xx) responses on MCP requests at line 814, above those paragraphs.
  3. A TOC generator walking the heading tree now produces: HTTP & headersRedirect (3xx) responses on MCP requests → (host-header paragraph, Content-Type paragraph) → Errors.
  4. A reader who clicks the "Redirect (3xx)" TOC entry (or extracts that subsection programmatically) finds host-header and media-type migration notes filed under redirects.

Why nothing prevents it

Markdown lint rules and the docs pipeline don't enforce topical grouping of heading spans, so the misfiled structure renders without any error.

How to fix

Either move the new #### Redirect (3xx) responses on MCP requests block to the end of the HTTP & headers section (immediately before ### Errors, after line 878), or give the two trailing paragraphs their own #### heading (e.g. #### Host validation & media types) so they are no longer implicit children of the redirect subsection.

Impact

Cosmetic/structural only — no behavior, code, or example is affected. This should not block the merge; it's a one-move fix.


`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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
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'
});
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'
});
assertNotRedirected(response, 'Token');

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