Scope HTTP client redirect following to the request's origin
Clients created by the SDK's httpx factory always enabled follow_redirects=True, so everything configured on a client — headers, auth, request bodies — was re-sent to whatever host a Location header named, and a misconfigured URL failed with an unhelpful Unexpected content type: error. This PR scopes redirect following to the request's origin and makes every refused redirect fail loudly with the target URL and the remedy.
Motivation and Context
follow_redirects=True was added for operational convenience: trailing-slash 307s from Starlette-mounted servers (#105, #732) and hosted-server redirects (#283). The dominant case — the same-origin slash fix — keeps working exactly as before; the server-side cause was also fixed long ago in #1115. What the blanket default additionally did was silently re-send client configuration to arbitrary redirect targets and silently mask misconfigured URLs. httpx's own default is follow_redirects=False for the same reason (encode/httpx#2533 keeps its cross-origin strip list deliberately minimal and leaves per-application policy to the application).
Clients built by create_mcp_http_client now:
- follow redirects within the same origin (scheme, host, port) and same-host http→https upgrades on default ports — trailing-slash normalization and TLS-upgrade redirects keep working, verified end-to-end against real Starlette apps;
- raise the new
RedirectErrorfor any other redirect target, before the next request is sent, with a message naming the resolved target and how to proceed (connect to the final URL, or supply your own pre-configuredhttpx.AsyncClientafter verifying the destination).
Transports resolve a refused redirect in-band — a request gets a JSON-RPC error rather than a hung caller, notifications are delivered to the session's message handler, and the standalone GET stream stops retrying an endpoint that redirects on every attempt. Caller-supplied clients that follow no redirects (httpx's default) get the same clear error on POST, GET-stream, and SSE-connect paths. OAuth discovery, registration, token, refresh, and the identity-assertion token exchange fail loudly on redirect responses instead of silently trying the next URL or abandoning the discovery chain.
create_mcp_http_client and RedirectError are now exported from the top-level mcp package (a deliberate API addition): the docs previously told users to build their own httpx.AsyncClient for headers/auth, which quietly lost the SDK's defaults. Docs and examples now configure clients through the factory; the general-purpose simple-tool fetch example gets a browser-like client of its own, since it fetches arbitrary web pages rather than a configured MCP endpoint.
How Has This Been Tested?
- Real-server verification: booted
MCPServer.streamable_http_app()under uvicorn and drove it with the real client. A trailing-slash/mcp/URL initializes and completes a full session lifecycle (every request rides a genuine Starlette 307); a redirect to a different origin resolves initialization with a clear error naming the target; a typo'd path still yields the usual not-found error. - New unit and transport tests (all proven failing on the base revision): same-origin and https-upgrade redirects followed with method/body/headers intact; other-host, other-port, https→http, and empty-host absolute-form Locations refused before a second request is sent; unparsable Location defers to httpx's own error; per-request and client-level headers behave identically; request/notification/resumption/GET-stream/SSE paths each surface the in-band error; OAuth handlers raise on redirect responses; a caller-supplied
follow_redirects=Trueclient is untouched (opt-in preserved). - A parametrized test asserts the target-resolution helper matches
response.next_request.urlfrom httpx itself across relative, protocol-relative, absolute-form-without-host, and cross-origin shapes, so an httpx behavior change surfaces as a test failure rather than a silent policy divergence. - Full suite: 5458 passed, 100% coverage including
strict-no-cover, pyright and ruff clean.
Breaking Changes
Yes, deliberate and documented in docs/migration.md: a redirect to a different origin now raises mcp.RedirectError instead of being followed. Same-origin redirects and same-host https upgrades are unaffected. Deployments that genuinely need cross-origin redirect following can pass their own httpx.AsyncClient(follow_redirects=True) to streamable_http_client / sse_client.
Types of changes
- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to change)
- Documentation update
Checklist
- I have read the MCP Documentation
- My code follows the repository's style guidelines
- New and existing tests pass locally
- I have added appropriate error handling
- I have added or updated documentation as needed
Additional context
- Four
# pragma: no branch/# pragma: no covercomments are added in tests: three for the coverage.py nested-async withphantom-arc misreport documented in AGENTS.md, one marking an unreachable context-manager body in a connect-failure test. - Known, accepted scope edges: resumption/reconnection GETs with a caller-supplied non-following client surface httpx's generic status error rather than the typed one (the factory's clients get the typed error everywhere); the origin comparison is deliberately conservative and fails loud on exotic-but-equivalent forms (IPv6 long-form vs. shorthand, trailing-dot hosts, non-default-port https upgrades) — supply your own client for those chains.
- The
RedirectPolicyproposal in #2180 addressed the scheme-downgrade slice of the same default; with redirect targets now vetted at the factory for every consumer, that policy knob is superseded by this change.