Skip to content

Commit 2106335

Browse files
authored
[v1.x] Follow redirects only within the MCP endpoint's origin (#3448)
1 parent 3eed7ce commit 2106335

14 files changed

Lines changed: 948 additions & 192 deletions

File tree

docs/authorization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ async def main():
151151
callback_handler=handle_callback,
152152
)
153153

154-
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
154+
async with httpx.AsyncClient(auth=oauth_auth) as custom_client:
155155
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _):
156156
async with ClientSession(read, write) as session:
157157
await session.initialize()

docs/client.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,29 @@ if __name__ == "__main__":
130130
_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/streamable_basic.py)_
131131
<!-- /snippet-source -->
132132

133+
To configure headers, authentication or timeouts, create an `httpx.AsyncClient` and pass it as `http_client=`.
134+
135+
## HTTP redirects
136+
137+
The transport connects to the URL you gave it, and only that origin.
138+
139+
* A `307`/`308` redirect that stays on the same scheme, host and port is followed, and so is `http://``https://` on the same host. That covers the usual `/mcp``/mcp/` trailing-slash redirect.
140+
* A redirect anywhere else is **not** followed. Connecting fails with:
141+
142+
```text
143+
httpx.HTTPStatusError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server
144+
```
145+
146+
If that URL is the server you meant, put it in your config. If it isn't, the server or a proxy in front of it is misconfigured.
147+
148+
This holds for any `httpx.AsyncClient` you pass in: its `follow_redirects` setting is not consulted for MCP requests, in either direction. The SDK's OAuth providers apply the same rule to their own requests, and so does `sse_client()`.
149+
150+
!!! tip
151+
`Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` means the
152+
server sits behind a TLS-terminating proxy it doesn't know about and is issuing `http://` redirects.
153+
That is fixed on the server (for uvicorn: `--proxy-headers` and `--forwarded-allow-ips`), or by
154+
using the exact `https://…/` URL the message suggests.
155+
133156
## Client Display Utilities
134157
135158
When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts:

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ async def _default_redirect_handler(authorization_url: str) -> None:
212212
await self._run_session(read_stream, write_stream, None)
213213
else:
214214
print("📡 Opening StreamableHTTP transport connection with auth...")
215-
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
215+
async with httpx.AsyncClient(auth=oauth_auth) as custom_client:
216216
async with streamable_http_client(
217217
url=self.server_url,
218218
http_client=custom_client,

examples/servers/simple-tool/mcp_simple_tool/server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22

33
import anyio
44
import click
5+
import httpx
56
import mcp.types as types
67
from mcp.server.lowlevel import Server
7-
from mcp.shared._httpx_utils import create_mcp_http_client
88
from starlette.requests import Request
99

1010

1111
async def fetch_website(
1212
url: str,
1313
) -> list[types.ContentBlock]:
1414
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
15-
async with create_mcp_http_client(headers=headers) as client:
15+
timeout = httpx.Timeout(30, read=300)
16+
async with httpx.AsyncClient(headers=headers, timeout=timeout, follow_redirects=True) as client:
1617
response = await client.get(url)
1718
response.raise_for_status()
1819
return [types.TextContent(type="text", text=response.text)]

examples/snippets/clients/oauth_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ async def main():
6969
callback_handler=handle_callback,
7070
)
7171

72-
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
72+
async with httpx.AsyncClient(auth=oauth_auth) as custom_client:
7373
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _):
7474
async with ClientSession(read, write) as session:
7575
await session.initialize()

src/mcp/client/auth/oauth2.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
validate_metadata_issuer,
4141
)
4242
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
43+
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
4344
from mcp.shared.auth import (
4445
OAuthClientInformationFull,
4546
OAuthClientMetadata,
@@ -224,7 +225,7 @@ def _origin_issuer(server_url: str) -> str:
224225
return str(AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}"))
225226

226227

227-
class OAuthClientProvider(httpx.Auth):
228+
class OAuthClientProvider(RedirectAwareAuth):
228229
"""
229230
OAuth2 authentication for httpx.
230231
Handles OAuth flow with automatic client registration and token storage.
@@ -421,7 +422,9 @@ async def _handle_token_response(self, response: httpx.Response) -> None:
421422
if response.status_code != 200:
422423
body = await response.aread() # pragma: no cover
423424
body_text = body.decode("utf-8") # pragma: no cover
424-
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # pragma: no cover
425+
raise OAuthTokenError( # pragma: no cover
426+
f"Token exchange failed ({response.status_code}){redirect_note(response)}: {body_text}"
427+
)
425428

426429
# Parse and validate response with scope validation
427430
token_response = await handle_token_response_scopes(response)
@@ -464,7 +467,7 @@ async def _refresh_token(self) -> httpx.Request:
464467
async def _handle_refresh_response(self, response: httpx.Response) -> bool: # pragma: no cover
465468
"""Handle token refresh response. Returns True if successful."""
466469
if response.status_code != 200:
467-
logger.warning(f"Token refresh failed: {response.status_code}")
470+
logger.warning(f"Token refresh failed: {response.status_code}{redirect_note(response)}")
468471
self.context.clear_tokens()
469472
return False
470473

@@ -508,8 +511,8 @@ def _expected_issuer(self) -> str:
508511
the 2025-03-26 well-known URL is built from (RFC 8414 §3.3)."""
509512
return self.context.auth_server_url or _origin_issuer(self.context.server_url)
510513

511-
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
512-
"""HTTPX auth flow integration."""
514+
async def _auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
515+
"""The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`)."""
513516
async with self.context.lock:
514517
if not self._initialized:
515518
await self._initialize() # pragma: no cover

src/mcp/client/auth/utils.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
1111
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
12+
from mcp.shared._httpx_utils import redirect_note
1213
from mcp.shared.auth import (
1314
OAuthClientInformationFull,
1415
OAuthClientMetadata,
@@ -205,9 +206,9 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth
205206
return True, asm
206207
except ValidationError: # pragma: no cover
207208
return True, None
208-
elif response.status_code < 400 or response.status_code >= 500:
209-
return False, None # Non-4XX error, stop trying
210-
return True, None
209+
elif 300 <= response.status_code < 500:
210+
return True, None # Not served at this URL (redirects are not followed) - try the next candidate
211+
return False, None # Server error or unexpected status, stop trying
211212

212213

213214
def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str) -> None:
@@ -262,7 +263,9 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
262263
"""Handle registration response."""
263264
if response.status_code not in (200, 201):
264265
await response.aread()
265-
raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}")
266+
raise OAuthRegistrationError(
267+
f"Registration failed: {response.status_code}{redirect_note(response)} {response.text}"
268+
)
266269

267270
try:
268271
content = await response.aread()

src/mcp/client/sse.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
import httpx
99
from anyio.abc import TaskStatus
1010
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
11-
from httpx_sse import SSEError, aconnect_sse
11+
from httpx_sse import SSEError
1212

1313
import mcp.types as types
14-
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
14+
from mcp.shared._httpx_utils import (
15+
McpHttpClientFactory,
16+
create_mcp_http_client,
17+
request_within_origin,
18+
sse_within_origin,
19+
)
1520
from mcp.shared.message import SessionMessage
1621

1722
logger = logging.getLogger(__name__)
@@ -47,6 +52,13 @@ async def sse_client(
4752
headers: Optional headers to include in requests.
4853
timeout: HTTP timeout for regular operations.
4954
sse_read_timeout: Timeout for SSE read operations.
55+
httpx_client_factory: Factory function for creating the httpx client. Whichever client it
56+
returns, MCP requests follow a redirect only when it stays on the endpoint's origin
57+
(same scheme, host and port, or http to https on the same host with default ports) and
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 `httpx.HTTPStatusError` for
60+
the redirect response. The client's `follow_redirects` setting is not consulted; the
61+
SDK's OAuth providers apply the same rule to the requests they make.
5062
auth: Optional HTTPX authentication handler.
5163
on_session_created: Optional callback invoked with the session ID when received.
5264
"""
@@ -65,11 +77,7 @@ async def sse_client(
6577
async with httpx_client_factory(
6678
headers=headers, auth=auth, timeout=httpx.Timeout(timeout, read=sse_read_timeout)
6779
) as client:
68-
async with aconnect_sse(
69-
client,
70-
"GET",
71-
url,
72-
) as event_source:
80+
async with sse_within_origin(client, url) as event_source:
7381
event_source.response.raise_for_status()
7482
logger.debug("SSE connection established")
7583

@@ -135,7 +143,9 @@ async def post_writer(endpoint_url: str):
135143
async with write_stream_reader:
136144
async for session_message in write_stream_reader:
137145
logger.debug(f"Sending client message: {session_message}")
138-
response = await client.post(
146+
response = await request_within_origin(
147+
client,
148+
"POST",
139149
endpoint_url,
140150
json=session_message.message.model_dump(
141151
by_alias=True,
@@ -161,3 +171,7 @@ async def post_writer(endpoint_url: str):
161171
finally:
162172
await read_stream_writer.aclose()
163173
await write_stream.aclose()
174+
# The receive sides too, so that failing to connect (which raises before the
175+
# streams are handed to the caller) does not leave them to the garbage collector.
176+
await read_stream.aclose()
177+
await write_stream_reader.aclose()

src/mcp/client/streamable_http.py

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@
1919
import httpx
2020
from anyio.abc import TaskGroup
2121
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
22-
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
22+
from httpx_sse import EventSource, ServerSentEvent
2323
from typing_extensions import deprecated
2424

2525
from mcp.shared._httpx_utils import (
2626
McpHttpClientFactory,
2727
create_mcp_http_client,
28+
redirect_location,
29+
request_within_origin,
30+
sse_within_origin,
31+
stream_within_origin,
2832
)
2933
from mcp.shared.message import ClientMessageMetadata, SessionMessage
3034
from mcp.types import (
@@ -72,6 +76,28 @@ class ResumptionError(StreamableHTTPError):
7276
"""Raised when resumption request is invalid."""
7377

7478

79+
def _unfollowed_redirect(response: httpx.Response) -> str | None:
80+
"""Describe a redirect `stream_within_origin` left unfollowed, or None if `response` is not one."""
81+
location = redirect_location(response)
82+
if location is None:
83+
return None
84+
if response.request.url.scheme == "https" and location.scheme == "http":
85+
return (
86+
f"Redirect to {location} not followed: it would downgrade this HTTPS endpoint to plain HTTP.\n"
87+
"The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,\n"
88+
f"often combined with a trailing-slash difference. Try {location.copy_with(scheme='https')} instead, "
89+
"or fix the proxy settings."
90+
)
91+
return f"Redirect to {location} not followed; use that URL as the endpoint if it is the intended server"
92+
93+
94+
def _raise_for_unfollowed_redirect(response: httpx.Response) -> None:
95+
"""Raise `httpx.HTTPStatusError`, as `raise_for_status()` does for a redirect response, saying why
96+
this one was not followed."""
97+
if (redirect := _unfollowed_redirect(response)) is not None:
98+
raise httpx.HTTPStatusError(redirect, request=response.request, response=response)
99+
100+
75101
@dataclass
76102
class RequestContext:
77103
"""Context for a request operation."""
@@ -263,12 +289,11 @@ async def handle_get_stream(
263289
if last_event_id:
264290
headers[LAST_EVENT_ID] = last_event_id # pragma: no cover
265291

266-
async with aconnect_sse(
267-
client,
268-
"GET",
269-
self.url,
270-
headers=headers,
271-
) as event_source:
292+
async with sse_within_origin(client, self.url, headers=headers) as event_source:
293+
if (redirect := _unfollowed_redirect(event_source.response)) is not None:
294+
# The same GET would be redirected again, so retrying cannot help.
295+
logger.warning(f"GET stream not opened: {redirect}")
296+
return
272297
event_source.response.raise_for_status()
273298
logger.debug("GET SSE connection established")
274299

@@ -311,12 +336,8 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
311336
if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch
312337
original_request_id = ctx.session_message.message.root.id
313338

314-
async with aconnect_sse(
315-
ctx.client,
316-
"GET",
317-
self.url,
318-
headers=headers,
319-
) as event_source:
339+
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
340+
_raise_for_unfollowed_redirect(event_source.response)
320341
event_source.response.raise_for_status()
321342
logger.debug("Resumption GET SSE connection established")
322343

@@ -337,7 +358,8 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
337358
message = ctx.session_message.message
338359
is_initialization = self._is_initialization_request(message)
339360

340-
async with ctx.client.stream(
361+
async with stream_within_origin(
362+
ctx.client,
341363
"POST",
342364
self.url,
343365
json=message.model_dump(by_alias=True, mode="json", exclude_none=True),
@@ -355,6 +377,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
355377
) # pragma: no cover
356378
return # pragma: no cover
357379

380+
_raise_for_unfollowed_redirect(response)
358381
response.raise_for_status()
359382
if is_initialization:
360383
self._maybe_extract_session_id_from_response(response)
@@ -460,12 +483,7 @@ async def _handle_reconnection(
460483
original_request_id = ctx.session_message.message.root.id
461484

462485
try:
463-
async with aconnect_sse(
464-
ctx.client,
465-
"GET",
466-
self.url,
467-
headers=headers,
468-
) as event_source:
486+
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
469487
event_source.response.raise_for_status()
470488
logger.info("Reconnected to SSE stream")
471489

@@ -583,7 +601,7 @@ async def terminate_session(self, client: httpx.AsyncClient) -> None: # pragma:
583601

584602
try:
585603
headers = self._prepare_headers()
586-
response = await client.delete(self.url, headers=headers)
604+
response = await request_within_origin(client, "DELETE", self.url, headers=headers)
587605

588606
if response.status_code == 405:
589607
logger.debug("Server does not allow session termination")
@@ -619,6 +637,13 @@ async def streamable_http_client(
619637
http_client: Optional pre-configured httpx.AsyncClient. If None, a default
620638
client with recommended MCP timeouts will be created. To configure headers,
621639
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
640+
Whichever client is used, MCP requests follow a redirect only when it stays on the
641+
endpoint's origin (same scheme, host and port, or http to https on the same host with
642+
default ports) and keeps the request method (307/308 for a POST; any status for the GET
643+
stream); any other redirect is not followed and, like any non-2xx response, raises
644+
`httpx.HTTPStatusError`, here naming the location. The client's `follow_redirects`
645+
setting is not consulted; the SDK's OAuth providers apply the same rule to the
646+
requests they make.
622647
terminate_on_close: If True, send a DELETE request to terminate the session
623648
when the context exits.
624649

0 commit comments

Comments
 (0)