Follow redirects only within the MCP endpoint's origin - #3397
Conversation
The HTTP client transports handled redirects differently depending on who built the httpx2 client: the SDK's default client followed every redirect, while a caller-supplied client followed none unless it set follow_redirects=True as the docs suggested. streamable_http_client and sse_client now handle redirects themselves, the same way for every client: a redirect that stays on the endpoint's origin (same scheme, host and port, or http to https on the same host with default ports) is followed, and a redirect anywhere else is not, so the message it answered fails with an error naming the location instead of the connection carrying on against a different host. - New private helpers in mcp.shared._httpx_utils send each request with follow_redirects=False and re-send httpx2's own next_request while it stays within the origin, bounded by the client's max_redirects. - create_mcp_http_client keeps only the timeouts; docs and examples no longer pass follow_redirects=True when building a client. - Requests an auth handler makes while an MCP request is in flight do not follow redirects either; the client transports and OAuth pages say so.
📚 Documentation preview
|
There was a problem hiding this comment.
Beyond the inline findings, I also checked whether requests yielded by an httpx2.Auth flow (e.g. OAuth metadata/token fetches) losing redirect following in stream_within_origin (src/mcp/shared/_httpx_utils.py:103) is an unintended regression — it is documented as deliberate in the helper's docstring and in both transports' public docstrings ("requests auth makes during an MCP request do not follow redirects"), so it was ruled out as intentional design rather than a bug.
Extended reasoning...
Findings were confirmed and posted inline (silent GET-stream redirect failure in streamable_http.py, SSE post_writer hang on refused redirects, and the example server's dropped timeouts), so this note only records the one additional concern investigated and ruled out: whether the new follow_redirects=False on client.send inside stream_within_origin silently changes behaviour for requests an httpx2.Auth flow makes mid-call. The behaviour is explicitly documented as intentional at src/mcp/shared/_httpx_utils.py:94-96 and in the sse_client/streamable_http_client docstrings, so it is a deliberate design decision, not a regression. The hunt exited on the bug cap with further verified findings unposted, so this is not an approval — the inline comments already indicate a human should look.
There was a problem hiding this comment.
1 issue found across 19 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="examples/servers/simple-tool/mcp_simple_tool/server.py">
<violation number="1" location="examples/servers/simple-tool/mcp_simple_tool/server.py:12">
P2: This swap drops the read timeout for the fetch tool from 300s (create_mcp_http_client's Timeout(30, read=300)) to httpx2's flat 5-second default, because no timeout is passed. For a tool fetching arbitrary user-supplied websites, a 5s read timeout will fail on slow or large pages. Keep the previous timeouts and add follow_redirects explicitly.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…very path Review follow-ups for the origin-scoped redirect handling: - A same-origin 301/302/303 answering a POST is no longer followed: httpx2's next-request rules turn those into a body-less GET, which would drop the JSON-RPC message. Only redirects that keep the method (307/308, or any status for a GET) are followed; the rest come back unfollowed like an off-origin redirect and the call fails naming the location. - The standalone GET stream and the resumption GET now handle an unfollowed redirect the way the message POST does: the GET stream logs it and stops instead of spending its reconnection attempts on the same answer, and a resumed request is resolved with the error instead of being left waiting. One helper builds the message for all three. - OAuth authorization-server metadata discovery treats a 3xx from a well-known candidate like a 4xx and tries the next candidate, matching the protected-resource metadata handler, now that these requests see redirect responses directly. - The simple-tool example keeps the 30s/300s timeouts it had before it stopped using the MCP client factory.
There was a problem hiding this comment.
Thanks — the follow-up commit resolves the points from my earlier review (unfollowed-redirect handling on the GET stream and resumption paths, method-preserving redirects only, OAuth metadata 3xx handling, restored example timeouts), and this run of the automated review found no new bugs. Since this changes redirect/origin security behaviour across both client transports, a human maintainer look is still worthwhile.
What was reviewed:
- The new
_unfollowed_redirecthelper and its call sites insrc/mcp/client/streamable_http.py; the resumption path's assert is safe given only requests carry resumption tokens. _handle_reconnection(streamable_http.py:537) is the onesse_within_originsite without the check — an unfollowed redirect there burns the reconnection attempts before resolving the request with the generic exhaustion error, so it degrades the message rather than hangs.- From my earlier comments, the
sse.pypost_writer redirect point is the one not reflected in code (the docstring was updated instead) — fine if deliberate, just flagging so it's a conscious call.
Extended reasoning...
Overview
This PR scopes HTTP redirect following for MCP client transports: src/mcp/shared/_httpx_utils.py gains origin-checking helpers (request_within_origin, sse_within_origin), and both src/mcp/client/streamable_http.py and src/mcp/client/sse.py route their requests through them so redirects are only followed same-origin (or http-to-https on the same host) and only when method-preserving. src/mcp/client/auth/utils.py, docs, docs_src tutorials, and examples are updated accordingly, with substantial new tests. The second commit (4a4b8fb) is an explicit review follow-up: it adds the _unfollowed_redirect helper and applies it to the standalone GET stream, the resumption GET, and the message POST; restricts followed redirects to method-preserving ones; makes OAuth metadata discovery treat 3xx like 4xx; and restores the simple-tool example's timeouts — addressing the findings from my prior review of the first push.
Security risks
The change is itself a security hardening: it prevents Authorization headers and JSON-RPC traffic from following cross-origin redirects (token exfiltration / request smuggling surface). The risk direction is therefore availability/correctness regressions rather than new exposure. I verified the three _unfollowed_redirect call sites resolve or log correctly, that the resumption path's assert original_request_id is not None is guarded by the request-only invariant on resumption tokens, and that _handle_reconnection — the one sse_within_origin site without the check — still terminates via its retry-exhaustion path (the request is resolved with an error after MAX_RECONNECTION_ATTEMPTS, so no hang, just a less specific error after retry delays). The bug hunt's two candidates this run were both that same _handle_reconnection gap, refuted as duplicates of earlier reporting.
Level of scrutiny
High. This alters observable redirect behaviour of a released 2.x public API (a compatibility contract per the repo's guidelines), touches auth-adjacent code paths, and encodes a deliberate policy decision (which redirects to follow, how unfollowed ones surface). Those are exactly the design calls a maintainer should sign off on, so approval by an automated review is not appropriate regardless of bug count.
Other factors
The hunt exited on dry_streak with zero findings on this version, and the new tests in tests/shared/test_httpx_utils.py, tests/client/test_streamable_http.py, and tests/shared/test_sse.py cover the redirect matrix well. One item from my prior review — the legacy sse.py post_writer dying on an unfollowed POST redirect — was answered with a docstring update rather than a code change; that may well be intentional for the legacy transport, but it is the one thread whose resolution I could not confirm from code, which further supports defer over approve.
There was a problem hiding this comment.
2 issues found across 21 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/sse.py">
<violation number="1" location="src/mcp/client/sse.py:69">
P1: When an SSE endpoint upgrades from HTTP to HTTPS, this wrapper connects the stream over HTTPS but `sse_reader` still resolves the relative endpoint event against the original HTTP URL. The first MCP POST therefore targets HTTP and can fail on common 301/302 upgrades or send the message over an insecure connection; resolve and validate the endpoint against the final SSE response URL.</violation>
</file>
<file name="docs/migration.md">
<violation number="1" location="docs/migration.md:2105">
P3: This line says the transport follows any redirect that stays on the endpoint's origin, but stream_within_origin also requires the method to be unchanged. A same-origin 301/302/303 that httpx2 turns into a GET (common for a POST) is treated as unfollowed, not followed. Qualify the wording to mention that only method-preserving redirects (e.g. 307/308) are followed.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout) | ||
| ) as client: | ||
| async with client.sse(url) as event_source: | ||
| async with sse_within_origin(client, url) as event_source: |
There was a problem hiding this comment.
P1: When an SSE endpoint upgrades from HTTP to HTTPS, this wrapper connects the stream over HTTPS but sse_reader still resolves the relative endpoint event against the original HTTP URL. The first MCP POST therefore targets HTTP and can fail on common 301/302 upgrades or send the message over an insecure connection; resolve and validate the endpoint against the final SSE response URL.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/sse.py, line 69:
<comment>When an SSE endpoint upgrades from HTTP to HTTPS, this wrapper connects the stream over HTTPS but `sse_reader` still resolves the relative endpoint event against the original HTTP URL. The first MCP POST therefore targets HTTP and can fail on common 301/302 upgrades or send the message over an insecure connection; resolve and validate the endpoint against the final SSE response URL.</comment>
<file context>
@@ -47,15 +52,21 @@ async def sse_client(
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
) as client:
- async with client.sse(url) as event_source:
+ async with sse_within_origin(client, url) as event_source:
event_source.response.raise_for_status()
logger.debug("SSE connection established")
</file context>
| ``` | ||
|
|
||
| v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx2.AsyncClient` to preserve that behavior. | ||
| v1's internal client set `follow_redirects=True`. You don't need it on your own client: the transport follows a redirect within the endpoint's origin (a trailing-slash redirect, say) itself, and does not follow one anywhere else, whatever the client is configured to do. |
There was a problem hiding this comment.
P3: This line says the transport follows any redirect that stays on the endpoint's origin, but stream_within_origin also requires the method to be unchanged. A same-origin 301/302/303 that httpx2 turns into a GET (common for a POST) is treated as unfollowed, not followed. Qualify the wording to mention that only method-preserving redirects (e.g. 307/308) are followed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 2105:
<comment>This line says the transport follows any redirect that stays on the endpoint's origin, but stream_within_origin also requires the method to be unchanged. A same-origin 301/302/303 that httpx2 turns into a GET (common for a POST) is treated as unfollowed, not followed. Qualify the wording to mention that only method-preserving redirects (e.g. 307/308) are followed.</comment>
<file context>
@@ -2103,11 +2102,11 @@ async with http_client:
-v1's internal client set follow_redirects=True; set it explicitly when supplying your own httpx2.AsyncClient to preserve that behavior.
+v1's internal client set follow_redirects=True. You don't need it on your own client: the transport follows a redirect within the endpoint's origin (a trailing-slash redirect, say) itself, and does not follow one anywhere else, whatever the client is configured to do.
streamable_http_client itself keeps a small signature — streamable_http_client(url, *, http_client=None, terminate_on_close=True) — and now yields a 2-tuple (next section). The removed function's other parameters map onto the client you build:
</file context>
</details>
```suggestion
v1's internal client set `follow_redirects=True`. You don't need it on your own client: the transport follows a method-preserving redirect within the endpoint's origin (a trailing-slash 307/308, say) itself, and does not follow one anywhere else, whatever the client is configured to do.
…an https-to-http redirect Second round of review follow-ups for the origin-scoped redirect handling: - The rule "follow a redirect only within the request's origin, only when it keeps the method" now lives in one predicate, next_request_within_origin, which also declines a Location that carries userinfo (httpx2 would send it as Basic auth). - OAuthClientProvider and IdentityAssertionOAuthProvider apply that rule to the requests their flows make (metadata discovery, registration, token, refresh) through a small RedirectAwareAuth base, instead of those requests following nothing. A redirect elsewhere is still handed to the flow as a non-success, and the registration/token/refresh errors now name it. - When the redirect budget (the client's max_redirects) is spent, the last redirect is handed back unfollowed like any other, so a redirect loop fails the one call with the usual error instead of raising TooManyRedirects out of the transport. - The "not followed" error for an https endpoint redirected to plain http on the same host explains the likely cause (a TLS-terminating proxy the server does not trust, often plus a trailing slash) and suggests the https form of the location rather than the http one; locations are printed without query or userinfo. - docs: the OAuth and transports pages describe the shared rule; the ASGI mounting example points clients at /notes/ (the path that does not redirect).
# Conflicts: # src/mcp/client/auth/oauth2.py
There was a problem hiding this comment.
2 issues found across 13 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/streamable_http.py">
<violation number="1" location="src/mcp/client/streamable_http.py:697">
P3: The public `streamable_http_client` docstring incorrectly says only 307/308 redirects are followed. Same-origin 301/302/303 redirects for GET streams are also followed, so update this wording to distinguish method-preserving POST redirects from GET redirects.</violation>
</file>
<file name="src/mcp/shared/_httpx_utils.py">
<violation number="1" location="src/mcp/shared/_httpx_utils.py:170">
P2: `redirect_note` interpolates the raw `next_request.url`, so a redirect Location carrying userinfo (`https://user:secret@host/...`) or query state can surface in OAuth error messages and the refresh warning log (oauth2.py:474/525, auth/utils.py:302, identity_assertion.py:206). Userinfo URLs are never followed (`next_request_within_origin` rejects them), so they are precisely the ones `redirect_note` reports. This contradicts the same delta's `_unfollowed_redirect`, which deliberately strips query and userinfo for exactly this reason. Strip them here too.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| """A suffix naming the location of a redirect response that was not followed, else empty.""" | ||
| if response.next_request is None: | ||
| return "" | ||
| return f" (redirected to {response.next_request.url}; not followed)" |
There was a problem hiding this comment.
P2: redirect_note interpolates the raw next_request.url, so a redirect Location carrying userinfo (https://user:secret@host/...) or query state can surface in OAuth error messages and the refresh warning log (oauth2.py:474/525, auth/utils.py:302, identity_assertion.py:206). Userinfo URLs are never followed (next_request_within_origin rejects them), so they are precisely the ones redirect_note reports. This contradicts the same delta's _unfollowed_redirect, which deliberately strips query and userinfo for exactly this reason. Strip them here too.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/shared/_httpx_utils.py, line 170:
<comment>`redirect_note` interpolates the raw `next_request.url`, so a redirect Location carrying userinfo (`https://user:secret@host/...`) or query state can surface in OAuth error messages and the refresh warning log (oauth2.py:474/525, auth/utils.py:302, identity_assertion.py:206). Userinfo URLs are never followed (`next_request_within_origin` rejects them), so they are precisely the ones `redirect_note` reports. This contradicts the same delta's `_unfollowed_redirect`, which deliberately strips query and userinfo for exactly this reason. Strip them here too.</comment>
<file context>
@@ -130,9 +155,53 @@ async def request_within_origin(
+ """A suffix naming the location of a redirect response that was not followed, else empty."""
+ if response.next_request is None:
+ return ""
+ return f" (redirected to {response.next_request.url}; not followed)"
+
+
</file context>
| return f" (redirected to {response.next_request.url}; not followed)" | |
| return f" (redirected to {response.next_request.url.copy_with(userinfo=b'', query=None, fragment=None)}; not followed)" |
| authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here. | ||
| Whichever client is used, MCP requests follow a redirect only when it stays on the | ||
| endpoint's origin (same scheme, host and port, or http to https on the same host with | ||
| default ports) and keeps the request method (307/308); any other redirect is not |
There was a problem hiding this comment.
P3: The public streamable_http_client docstring incorrectly says only 307/308 redirects are followed. Same-origin 301/302/303 redirects for GET streams are also followed, so update this wording to distinguish method-preserving POST redirects from GET redirects.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/streamable_http.py, line 697:
<comment>The public `streamable_http_client` docstring incorrectly says only 307/308 redirects are followed. Same-origin 301/302/303 redirects for GET streams are also followed, so update this wording to distinguish method-preserving POST redirects from GET redirects.</comment>
<file context>
@@ -671,11 +692,12 @@ async def streamable_http_client(
- and requests its `auth` handler makes during an MCP request do not follow redirects.
+ Whichever client is used, MCP requests follow a redirect only when it stays on the
+ endpoint's origin (same scheme, host and port, or http to https on the same host with
+ default ports) and keeps the request method (307/308); any other redirect is not
+ followed and the message it answered fails with an error naming the location. The
+ client's `follow_redirects` setting is not consulted; the SDK's OAuth providers apply the
</file context>
| default ports) and keeps the request method (307/308); any other redirect is not | |
| default ports) and preserves the request method for POSTs (307/308); same-origin GET redirects are also followed. Other redirects are not |
There was a problem hiding this comment.
This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.
1 other optional suggestion (a nit or a note on pre-existing code) was found and not posted.
Findings marked 🟡 are optional suggestions and need no follow-up push.
| def redirect_note(response: httpx2.Response) -> str: | ||
| """A suffix naming the location of a redirect response that was not followed, else empty.""" | ||
| if response.next_request is None: | ||
| return "" | ||
| return f" (redirected to {response.next_request.url}; not followed)" |
There was a problem hiding this comment.
🔴 redirect_note reproduces the full unfollowed-redirect URL, including query string and userinfo, into OAuthTokenError/OAuthRegistrationError messages and a logger.warning, so after merging a token/registration/refresh redirect can leak session state or embedded credentials into application logs — something this same push deliberately prevents in _unfollowed_redirect (streamable_http.py:76-77, "state that does not belong in logs"). Fix: strip userinfo, query and fragment in redirect_note (e.g. url.copy_with(userinfo=b"", query=None, fragment=None)) so all four call sites (oauth2.py:485,536; utils.py:302; identity_assertion.py:206) are sanitized in one change.
Extended reasoning...
Path: an authorization server (or a proxy in front of it) answers the OAuth flow's token, refresh, or registration POST with a redirect that RedirectAwareAuth/next_request_within_origin refuses (cross-origin, or method-changing 301/302/303 — common for login-wall redirects like 302 Location: https://sso.example/login?return_to=...&session=abc123, and Location URLs can carry userinfo). redirect_note (src/mcp/shared/_httpx_utils.py:166-170) returns f" (redirected to {response.next_request.url}; not followed)" with no sanitization, and that string goes into OAuthTokenError at src/mcp/client/auth/oauth2.py:485 and src/mcp/client/auth/extensions/identity_assertion.py:206, OAuthRegistrationError at src/mcp/client/auth/utils.py:302, and logger.warning at oauth2.py:536. Exceptions and warnings are routinely logged by applications, so query-string state and userinfo credentials from the Location header end up persisted in logs. The PR's own transport-side helper _unfollowed_redirect (src/mcp/client/streamable_http.py:71-86, changed in this same push) strips exactly these components via…
Verification: normal — triggered whenever an authorization server or fronting proxy answers a token/refresh/registration POST with a redirect the SDK refuses to follow (cross-origin, method-changing 301/302/303, or userinfo-bearing). redirect_note (src/mcp/shared/_httpx_utils.py:170) returns f" (redirected to {response.next_request.url}; not followed)" — the raw Location-derived URL with query, fragment…
| if sent.scheme == "https" and location.scheme == "http" and location.host == sent.host: | ||
| return ( | ||
| f"Redirect to {location} not followed: it would downgrade this HTTPS endpoint to plain HTTP.\n" | ||
| "The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,\n" | ||
| f"often combined with a trailing-slash difference. Try {location.copy_with(scheme='https')} instead, " | ||
| "or fix the proxy settings." | ||
| ) | ||
| return f"Redirect to {location} not followed; use that URL as the endpoint if it is the intended server" |
There was a problem hiding this comment.
🟡 nit (optional) (security-adjacent): the https→http downgrade branch in _unfollowed_redirect requires location.host == sent.host, so an HTTPS endpoint redirected to plain HTTP on a different host (e.g. a proxy rewriting to an internal backend name) falls through to the generic message, which tells the operator to "use that URL as the endpoint if it is the intended server" — the SDK recommending a plain-HTTP URL, exactly what the branch was added to avoid. Fix: treat every https→http redirect as a downgrade regardless of host — drop the location.host == sent.host condition (or at minimum never suggest an http:// location in the generic message).
Extended reasoning...
New code path added since the last review: src/mcp/client/streamable_http.py:70-84. Line 77 gates the downgrade warning on sent.scheme == "https" and location.scheme == "http" and location.host == sent.host. Trigger: a client configured with an https endpoint whose server/LB answers the POST or GET with 307/302 to http://other-host/... (misconfigured TLS-terminating proxy forwarding to its backend hostname, or an injected/misrouted Location). stream_within_origin correctly refuses to follow it (cross-origin), _unfollowed_redirect is called at streamable_http.py:236/283/376, and because the host differs the function returns line 84's generic text: "Redirect to http://other-host/... not followed; use that URL as the endpoint if it is the intended server". That message is logged (logger.warning) and delivered to the caller as the JSON-RPC error for the request, so the operator is advised to repoint an MCP endpoint that carries Authorization bearer tokens at a plain-HTTP URL taken from the redirect. The companion test…
Verification: nit — triggered when a server/proxy answers an https MCP endpoint with a redirect to http:// on a different host (untrusted server response, e.g. a TLS-terminating proxy rewriting Location to its internal backend name). Mechanism verified: src/mcp/shared/_httpx_utils.py:74-82 _within_origin rejects any https→http Location, so the 3xx is yielded unfollowed with next_request set;… | nit —…
Redirect handling in the HTTP client transports depended on who built the
httpx2.AsyncClient: the SDK's default client followed every redirect, while a client you pass in (the way to set headers or auth on 2.x) followed none unless you addedfollow_redirects=True, which the docs told you to do. This moves redirect handling into the transports themselves and scopes it to the endpoint's origin, so it behaves the same for every client and a redirect can't quietly move a connection to a different host.Motivation and Context
follow_redirects=Truewas turned on in the client factory for trailing-slash 307s from Starlette-mounted servers (#105, #732) and hosted servers that redirect (#283) — same-origin redirects. Following any redirect also meant the transport would carry on against whatever host aLocationheader named, re-sending what was configured for the original endpoint (headers, auth, request body) there and treating the answer as the MCP server's. httpx2 leaves following off by default and keeps that kind of policy out of the library (encode/httpx#2533), so it belongs in the transport.streamable_http_clientandsse_clientnow send each request with following disabled at the request level and handle a redirect themselves:http→httpson the same host with default ports) and keeps the request method (307/308; any status for a GET) is followed, using httpx2's own next-request construction, up to the client'smax_redirects— a 301/302/303 answering a POST is not, because httpx2 would replay it as a body-less GET, and neither is aLocationcarrying userinfo;max_redirectsbudget, so a loop too) is not followed, and no request is made to its location. The transport gets the redirect response back unfollowed, exactly as httpx2 hands one back with following off, and treats it as the non-success it is: a message POST or a resumption GET resolves that call with anMCPErrornaming the location while the session stays usable (the per-request shape every other non-2xx already has on 2.x), the standalone GET stream logs it and stops rather than retrying, and the SSE connect fails withhttpx2.HTTPStatusErrorfor the redirect response. When anhttpsendpoint redirects to plainhttpon the same host — usually a TLS-terminating proxy the server doesn't trust plus a trailing slash — the error says so and suggests thehttpsform of the location, never thehttpone.This holds whichever client is in use, so a caller-supplied client no longer needs
follow_redirects=Truefor the trailing-slash case, and setting it doesn't widen what the transport follows. httpx2 applies the per-request setting to the requests anauth=flow makes as well, soOAuthClientProviderandIdentityAssertionOAuthProviderapply the same rule to their own requests (discovery, registration, token, refresh) through a small shared base: a within-origin, method-preserving redirect is followed, anything else is treated as that URL not answering (discovery moves on to the next well-known candidate; registration/token errors name the redirect).create_mcp_http_clientdropsfollow_redirects=Trueand keeps only the timeouts.How Has This Been Tested?
httpx2.MockTransport(tests/shared/test_httpx_utils.py): same-origin, relative and https-upgrade redirects followed with method and body intact and the intermediate response closed; other host, other port, subdomain, other scheme, https→http, 301/302/303-on-POST and userinfo locations handed back unfollowed with no request recorded for the location; the hop budget hands back the next redirect; a client built withfollow_redirects=Trueis still scoped; the SSE form sends the headersAsyncClient.sse()sends. OAuth flow tests: a flow request follows a same-origin redirect, hands back a cross-origin one, and stops after a few hops.Client/sse_clientwith in-process transports: Starlette's/mcp→/mcp/redirect is followed with a caller-supplied client left at httpx2's default; a redirect to another origin fails that one call withMCPErrorwhile the next call on the same session succeeds, and that origin is never contacted; the SSE connect equivalents. These fail onmainand pass here.Mount-induced 307 is followed and tool calls work with default, no-follow and follow-enabled clients; an endpoint that 307s to a different port fails the call with the error above and the second server logs no request; an OAuth flow whose first metadata candidate 307s to another port skips it (that port sees nothing) and whose registration endpoint 308s to/register/registers normally; a same-origin redirect loop fails the one call and the session carries on.Mounted, so each one rides a real 307). Full suite with coverage, pyright and ruff pass locally.Breaking Changes
MCPError("Redirect to … not followed; use that URL as the endpoint if it is the intended server") instead of being followed (SDK default client) or ending inUnexpected content type(caller-supplied client). Thehttp→httpsallowance applies only when neither side names a non-default port.httpx2.TooManyRedirectsout of the transport.follow_redirectson a caller-supplied client is no longer consulted for MCP requests; same-origin redirects are followed regardless of it.mcp.shared._httpx_utils.create_mcp_http_clientno longer enablesfollow_redirects.Types of changes
Checklist
help wanted, or I'm a maintainer)Additional context
follow_redirects=Truewhen building a client;docs/client/transports.mdanddocs/client/oauth-clients.mddescribe the redirect behaviour, thedocs/migration.mdpassages that told v1 users to set the flag are corrected, anddocs/run/asgi.mdpoints clients at the mounted path that doesn't redirect. Translated pages aren't regenerated in this PR.examples/servers/simple-toolfetched arbitrary web pages through the private MCP client factory; it now uses a plainhttpx2.AsyncClient(follow_redirects=True)of its own.AI Disclaimer