Skip to content

Commit 1051429

Browse files
committed
Name redirect locations without query or userinfo everywhere; refuse-message covers any http downgrade
- The OAuth registration/token/refresh messages named an unfollowed redirect with the raw location; they now share one helper with the transport message and print it without userinfo, query or fragment. - The "would downgrade to plain HTTP" explanation applies to any http location an https endpoint redirects to, not only one on the same host, so the error never suggests configuring an http:// URL. - Docstrings and the migration note say which redirects are followed more precisely (307/308 for a POST, any status for a GET).
1 parent 2983290 commit 1051429

6 files changed

Lines changed: 35 additions & 13 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2102,7 +2102,7 @@ async with http_client:
21022102
...
21032103
```
21042104

2105-
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.
2105+
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.
21062106

21072107
`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:
21082108

src/mcp/client/sse.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ async def sse_client(
5555
httpx_client_factory: Factory function for creating the httpx2 client. Whichever client it
5656
returns, MCP requests follow a redirect only when it stays on the endpoint's origin
5757
(same scheme, host and port, or http to https on the same host with default ports) and
58-
keeps the request method; any other redirect is not followed, so connecting fails with
58+
keeps the request method (any status for the SSE GET, 307/308 for a message POST); any
59+
other redirect is not followed, so connecting fails with
5960
`httpx2.HTTPStatusError` for the redirect response. The client's `follow_redirects`
6061
setting is not consulted; the SDK's OAuth providers apply the same rule to the requests
6162
they make.

src/mcp/client/streamable_http.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
3636
from mcp.shared._httpx_utils import (
3737
create_mcp_http_client,
38+
redirect_location,
3839
request_within_origin,
3940
sse_within_origin,
4041
stream_within_origin,
@@ -69,12 +70,10 @@ class ResumptionError(StreamableHTTPError):
6970

7071
def _unfollowed_redirect(response: httpx2.Response) -> str | None:
7172
"""Describe a redirect `stream_within_origin` left unfollowed, or None if `response` is not one."""
72-
if response.next_request is None:
73+
location = redirect_location(response)
74+
if location is None:
7375
return None
74-
sent = response.request.url
75-
# Query and userinfo are left out: they can carry state that does not belong in logs.
76-
location = response.next_request.url.copy_with(userinfo=b"", query=None, fragment=None)
77-
if sent.scheme == "https" and location.scheme == "http" and location.host == sent.host:
76+
if response.request.url.scheme == "https" and location.scheme == "http":
7877
return (
7978
f"Redirect to {location} not followed: it would downgrade this HTTPS endpoint to plain HTTP.\n"
8079
"The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,\n"
@@ -694,8 +693,9 @@ async def streamable_http_client(
694693
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
695694
Whichever client is used, MCP requests follow a redirect only when it stays on the
696695
endpoint's origin (same scheme, host and port, or http to https on the same host with
697-
default ports) and keeps the request method (307/308); any other redirect is not
698-
followed and the message it answered fails with an error naming the location. The
696+
default ports) and keeps the request method (307/308 for a POST; any status for the GET
697+
stream); any other redirect is not followed and the message it answered fails with an
698+
error naming the location. The
699699
client's `follow_redirects` setting is not consulted; the SDK's OAuth providers apply the
700700
same rule to the requests they make.
701701
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.

src/mcp/shared/_httpx_utils.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,20 @@ async def sse_within_origin(
163163
yield httpx2.EventSource(response)
164164

165165

166+
def redirect_location(response: httpx2.Response) -> httpx2.URL | None:
167+
"""Where `response` redirects to, for use in a message: without userinfo, query or fragment,
168+
which can carry state that does not belong in an error or a log line. None if not a redirect."""
169+
if response.next_request is None:
170+
return None
171+
return response.next_request.url.copy_with(userinfo=b"", query=None, fragment=None)
172+
173+
166174
def redirect_note(response: httpx2.Response) -> str:
167175
"""A suffix naming the location of a redirect response that was not followed, else empty."""
168-
if response.next_request is None:
176+
location = redirect_location(response)
177+
if location is None:
169178
return ""
170-
return f" (redirected to {response.next_request.url}; not followed)"
179+
return f" (redirected to {location}; not followed)"
171180

172181

173182
class RedirectAwareAuth(ABC, httpx2.Auth):

tests/client/test_auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,11 +1082,11 @@ async def test_handle_registration_response_reads_before_accessing_text(self):
10821082
@pytest.mark.anyio
10831083
async def test_registration_error_names_an_unfollowed_redirect(self):
10841084
"""SDK-defined: when the registration endpoint answered with a redirect that was not followed,
1085-
the error says where it pointed instead of only the bare status."""
1085+
the error says where it pointed (without userinfo or query) instead of only the bare status."""
10861086
request = httpx2.Request("POST", "https://as.example/register")
10871087
async with httpx2.AsyncClient(
10881088
transport=httpx2.MockTransport(
1089-
lambda r: httpx2.Response(307, headers={"location": "https://elsewhere.example/register"})
1089+
lambda r: httpx2.Response(307, headers={"location": "https://u:p@elsewhere.example/register?state=x"})
10901090
)
10911091
) as client:
10921092
response = await client.send(request)

tests/client/test_streamable_http.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,3 +956,15 @@ async def test_unfollowed_redirect_location_is_named_without_its_query_string()
956956
assert message == snapshot(
957957
"Redirect to https://sso.example/login not followed; use that URL as the endpoint if it is the intended server"
958958
)
959+
960+
961+
@pytest.mark.anyio
962+
async def test_https_endpoint_redirected_to_plain_http_elsewhere_never_suggests_the_http_url() -> None:
963+
"""SDK-authored text: the downgrade explanation applies whatever host the http:// location names,
964+
so the message never offers a plain-HTTP URL as the endpoint to configure."""
965+
message = await _redirected_call_error("https://mcp.example/mcp", "http://backend.lan:8000/mcp/")
966+
assert message == snapshot("""\
967+
Redirect to http://backend.lan:8000/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP.
968+
The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,
969+
often combined with a trailing-slash difference. Try https://backend.lan:8000/mcp/ instead, or fix the proxy settings.\
970+
""")

0 commit comments

Comments
 (0)