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
2 changes: 2 additions & 0 deletions docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ The first time `Client` sends a request, the server answers `401`. The provider

After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again.

One transport rule applies to all of these requests: they are made while an MCP request is in flight, and like it they do not follow redirects to other addresses (they follow none at all), so the metadata, registration and token URLs must answer directly.

You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `validate_resource_url`), and this file needs neither. `client_metadata_url` is the one worth knowing about; it gets its own section below.

### Try it
Expand Down
9 changes: 6 additions & 3 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
--8<-- "docs_src/client_transports/tutorial002.py"
```

That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx2.AsyncClient` configured the way MCP needs: a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. Whichever client is underneath, the transport follows 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 the default ports) and keeps the request method, which covers a 307/308 trailing-slash redirect. Any other redirect is not followed, and the call it answered fails with an `MCPError` naming the location; if that address is the server you meant, use it as the URL.

!!! check
A `Client` you have constructed is **not** connected. Construction only picks the transport;
Expand All @@ -45,7 +45,7 @@ That is the whole production client. `Client` wraps the URL in `streamable_http_

The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx2.AsyncClient` yourself and hand it to `streamable_http_client`:

```python title="client.py" hl_lines="8-14"
```python title="client.py" hl_lines="8-13"
--8<-- "docs_src/client_transports/tutorial003.py"
```

Expand Down Expand Up @@ -75,7 +75,10 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A
!!! info
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth,
proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes
nothing away. It is also where OAuth plugs in:
nothing away, with one exception: redirects. MCP requests follow the same-origin rule above rather
than the client's `follow_redirects`, and requests an `auth=` handler makes while one is in flight
(OAuth discovery, registration, token) do not follow redirects, so those URLs must answer directly.
It is also where OAuth plugs in:
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.

## stdio
Expand Down
14 changes: 5 additions & 9 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,15 @@ them:
```python
import httpx

http_client = httpx.AsyncClient(follow_redirects=True)
http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300))
```

**After (v2):**

```python
import httpx2

http_client = httpx2.AsyncClient(follow_redirects=True)
http_client = httpx2.AsyncClient(timeout=httpx2.Timeout(30, read=300))
```

`httpx2` is API-compatible with `httpx`, so usually only the import name
Expand Down Expand Up @@ -2092,7 +2092,6 @@ http_client = httpx2.AsyncClient(
headers={"Authorization": "Bearer token"},
timeout=httpx2.Timeout(30, read=300),
auth=my_auth,
follow_redirects=True,
)

async with http_client:
Expand All @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.


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

- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts and `follow_redirects=True`.
- `headers`, `timeout`, `sse_read_timeout`, `auth`: set them on the `httpx2.AsyncClient` as above. `streamablehttp_client` defaulted to `httpx.Timeout(30, read=300)`; a bare `httpx2.AsyncClient()` falls back to httpx2's flat 5-second timeout, too short for the long-lived GET stream, so set `timeout=httpx2.Timeout(30, read=300)` (as shown) to keep v1's values. Omitting `http_client` still gives you a default client with those timeouts.
- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
- `terminate_on_close`: unchanged (default `True`).

Expand Down Expand Up @@ -2151,10 +2150,7 @@ async def capture_session_id(response: httpx2.Response) -> None:
if session_id:
captured_session_ids.append(session_id)

http_client = httpx2.AsyncClient(
event_hooks={"response": [capture_session_id]},
follow_redirects=True,
)
http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]})

async with http_client:
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
Expand Down
1 change: 0 additions & 1 deletion docs_src/client_transports/tutorial003.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ async def main() -> None:
async with httpx2.AsyncClient(
headers={"Authorization": "Bearer ..."},
timeout=httpx2.Timeout(30.0, read=300.0),
follow_redirects=True,
) as http_client:
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
async with Client(transport) as client:
Expand Down
2 changes: 1 addition & 1 deletion docs_src/identity_assertion/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str:


async def main() -> None:
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
2 changes: 1 addition & 1 deletion docs_src/oauth_clients/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async def wait_for_callback() -> AuthorizationCodeResult:


async def main() -> None:
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
2 changes: 1 addition & 1 deletion docs_src/oauth_clients/tutorial002.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None


async def main() -> None:
async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ async def _default_redirect_handler(authorization_url: str) -> None:
await self._run_session(read_stream, write_stream)
else:
print("📡 Opening StreamableHTTP transport connection with auth...")
async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with httpx2.AsyncClient(auth=oauth_auth) as custom_client:
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
read_stream,
write_stream,
Expand Down
5 changes: 3 additions & 2 deletions examples/servers/simple-tool/mcp_simple_tool/server.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import anyio
import click
import httpx2
import mcp.types as types
from mcp.server import Server, ServerRequestContext
from mcp.shared._httpx_utils import create_mcp_http_client


async def fetch_website(
url: str,
) -> list[types.ContentBlock]:
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
async with create_mcp_http_client(headers=headers) as client:
timeout = httpx2.Timeout(30, read=300)
async with httpx2.AsyncClient(headers=headers, timeout=timeout, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
return [types.TextContent(type="text", text=response.text)]
Expand Down
2 changes: 1 addition & 1 deletion examples/snippets/clients/identity_assertion_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async def main() -> None:
scope="user",
)

async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
async with httpx2.AsyncClient(auth=oauth_auth) as http_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
Expand Down
2 changes: 1 addition & 1 deletion examples/snippets/clients/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def main():
callback_handler=handle_callback,
)

async with httpx2.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
async with httpx2.AsyncClient(auth=oauth_auth) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
Expand Down
6 changes: 3 additions & 3 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,9 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth
return True, asm
except ValidationError: # pragma: no cover
return True, None
elif response.status_code < 400 or response.status_code >= 500:
return False, None # Non-4XX error, stop trying
return True, None
elif 300 <= response.status_code < 500:
return True, None # Not served at this URL (redirects are not followed) - try the next candidate
return False, None # Server error or unexpected status, stop trying


def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None:
Expand Down
27 changes: 18 additions & 9 deletions src/mcp/client/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@

from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import create_context_streams
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
from mcp.shared._httpx_utils import (
McpHttpClientFactory,
create_mcp_http_client,
request_within_origin,
sse_within_origin,
)
from mcp.shared.message import SessionMessage

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -47,15 +52,21 @@ async def sse_client(
headers: Optional headers to include in requests.
timeout: HTTP timeout for regular operations (in seconds).
sse_read_timeout: Timeout for SSE read operations (in seconds).
httpx_client_factory: Factory function for creating the httpx2 client.
httpx_client_factory: Factory function for creating the httpx2 client. Whichever client it
returns, 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; any other redirect is not followed, so connecting fails with
`httpx2.HTTPStatusError` for the redirect response. The client's `follow_redirects`
setting is not consulted, and requests `auth` makes during an MCP request do not follow
redirects.
auth: Optional httpx2 authentication handler.
on_session_created: Optional callback invoked with the session ID when received.
"""
logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}")
async with httpx_client_factory(
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

event_source.response.raise_for_status()
logger.debug("SSE connection established")

Expand Down Expand Up @@ -121,13 +132,11 @@ async def post_writer(endpoint_url: str):

async def _send_message(session_message: SessionMessage) -> None:
logger.debug(f"Sending client message: {session_message}")
response = await client.post(
response = await request_within_origin(
client,
"POST",
endpoint_url,
json=session_message.message.model_dump(
by_alias=True,
mode="json",
exclude_unset=True,
),
json=session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True),
)
response.raise_for_status()
Comment thread
maxisbey marked this conversation as resolved.
logger.debug(f"Client message sent successfully: {response.status_code}")
Expand Down
51 changes: 45 additions & 6 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
from mcp.client._transport import TransportStreams
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared._httpx_utils import (
create_mcp_http_client,
request_within_origin,
sse_within_origin,
stream_within_origin,
)
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
from mcp.shared.message import ClientMessageMetadata, SessionMessage
Expand Down Expand Up @@ -62,6 +67,14 @@ class ResumptionError(StreamableHTTPError):
"""Raised when resumption request is invalid."""


def _unfollowed_redirect(response: httpx2.Response) -> str | None:
"""Describe a redirect `stream_within_origin` left unfollowed, or None if `response` is not one."""
if response.next_request is None:
return None
location = response.next_request.url
return f"Redirect to {location} not followed; use that URL as the endpoint if it is the intended server"


@dataclass
class RequestContext:
"""Context for a request operation."""
Expand Down Expand Up @@ -210,7 +223,11 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer
if last_event_id:
headers[LAST_EVENT_ID] = last_event_id

async with client.sse(self.url, headers=headers) as event_source:
async with sse_within_origin(client, self.url, headers=headers) as event_source:
if (redirect := _unfollowed_redirect(event_source.response)) is not None:
# The same GET would be redirected again, so retrying cannot help.
logger.warning(f"GET stream not opened: {redirect}")
return
event_source.response.raise_for_status()
Comment thread
maxisbey marked this conversation as resolved.
logger.debug("GET SSE connection established")

Expand Down Expand Up @@ -253,7 +270,14 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
original_request_id = ctx.session_message.message.id

async with ctx.client.sse(self.url, headers=headers) as event_source:
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
if (redirect := _unfollowed_redirect(event_source.response)) is not None:
logger.warning(redirect)
assert original_request_id is not None
await self._resolve_abandoned_request(
ctx.read_stream_writer, original_request_id, redirect, code=INVALID_REQUEST
)
return
event_source.response.raise_for_status()
logger.debug("Resumption GET SSE connection established")

Expand Down Expand Up @@ -320,7 +344,8 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
if ctx.metadata is not None and ctx.metadata.headers is not None:
headers.update(ctx.metadata.headers)

async with ctx.client.stream(
async with stream_within_origin(
ctx.client,
"POST",
self.url,
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
Expand All @@ -339,6 +364,14 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
)
return

if (redirect := _unfollowed_redirect(response)) is not None:
logger.warning(redirect)
if isinstance(message, JSONRPCRequest):
await self._resolve_abandoned_request(
ctx.read_stream_writer, message.id, redirect, code=INVALID_REQUEST
)
return

if response.status_code >= 400:
if isinstance(message, JSONRPCRequest):
# A spec-correct server may return the JSON-RPC error in the
Expand Down Expand Up @@ -501,7 +534,7 @@ async def _handle_reconnection(
headers[LAST_EVENT_ID] = last_event_id

try:
async with ctx.client.sse(self.url, headers=headers) as event_source:
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
event_source.response.raise_for_status()
logger.info("Reconnected to SSE stream")

Expand Down Expand Up @@ -626,7 +659,7 @@ async def terminate_session(self, client: httpx2.AsyncClient) -> None:

try:
headers = self._prepare_headers()
response = await client.delete(self.url, headers=headers)
response = await request_within_origin(client, "DELETE", self.url, headers=headers)

if response.status_code == 405:
logger.debug("Server does not allow session termination")
Expand All @@ -650,6 +683,12 @@ async def streamable_http_client(
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
client with recommended MCP timeouts will be created. To configure headers,
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
followed and the message it answered fails with an error naming the location. The
client's `follow_redirects` setting is not consulted, and requests its `auth` handler
makes during an MCP request do not follow redirects.
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.

Yields:
Expand Down
Loading
Loading