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: like the MCP request they run inside, they follow a redirect only when it stays on the same origin and keeps the method (a trailing-slash 307/308, say), and treat any other redirect as that URL not answering.

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
8 changes: 5 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,9 @@ 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 the requests the SDK's OAuth providers make while one is
in flight (discovery, registration, token) follow that rule too. 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
2 changes: 1 addition & 1 deletion docs/run/asgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr
--8<-- "docs_src/asgi/tutorial004.py"
```

Now clients connect to `/notes`, not `/notes/mcp`.
Now clients connect to `/notes/`, not `/notes/mcp`.

## CORS for browser clients

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 @@ -35,7 +35,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
9 changes: 6 additions & 3 deletions src/mcp/client/auth/extensions/identity_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
union_scopes,
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url

Expand All @@ -56,7 +57,7 @@ def _origin(url: str) -> tuple[str, str, int | None]:
return (parsed.scheme, parsed.hostname or "", port)


class IdentityAssertionOAuthProvider(httpx2.Auth):
class IdentityAssertionOAuthProvider(RedirectAwareAuth):
"""`httpx2.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS.

The authorization server `issuer` is fixed at construction; metadata is fetched from its
Expand Down Expand Up @@ -159,7 +160,7 @@ def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Requ
data["client_secret"] = self._client.client_secret
return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers)

async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
async with self._lock:
if not self._initialized:
self._tokens = await self._storage.get_tokens()
Expand Down Expand Up @@ -201,7 +202,9 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
token_response = yield self._build_token_request(scope_to_request, assertion)
if token_response.status_code != 200:
body = (await token_response.aread()).decode(errors="replace")
raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}")
raise OAuthTokenError(
f"Token exchange failed ({token_response.status_code}){redirect_note(token_response)}: {body}"
)
tokens = await handle_token_response_scopes(token_response)
if tokens.scope is None:
tokens.scope = scope_to_request
Expand Down
13 changes: 8 additions & 5 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
Expand Down Expand Up @@ -287,7 +288,7 @@ def _origin_issuer(server_url: str) -> str:
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))


class OAuthClientProvider(httpx2.Auth):
class OAuthClientProvider(RedirectAwareAuth):
"""OAuth2 authentication for httpx2.

Handles OAuth flow with automatic client registration and token storage.
Expand Down Expand Up @@ -480,7 +481,9 @@ async def _handle_token_response(self, response: httpx2.Response) -> None:
if response.status_code not in {200, 201}:
body = await response.aread()
body_text = body.decode("utf-8")
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}")
raise OAuthTokenError(
f"Token exchange failed ({response.status_code}){redirect_note(response)}: {body_text}"
)

# Parse and validate response with scope validation
token_response = await handle_token_response_scopes(response)
Expand Down Expand Up @@ -530,7 +533,7 @@ async def _refresh_token(self) -> httpx2.Request:
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
"""Handle token refresh response. Returns True if successful."""
if response.status_code != 200:
logger.warning(f"Token refresh failed: {response.status_code}")
logger.warning(f"Token refresh failed: {response.status_code}{redirect_note(response)}")
self.context.clear_tokens()
return False

Expand Down Expand Up @@ -598,8 +601,8 @@ def _expected_issuer(self) -> str:
the 2025-03-26 well-known URL is built from (RFC 8414 §3.3)."""
return self.context.auth_server_url or _origin_issuer(self.context.server_url)

async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""httpx2 auth flow integration."""
async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`)."""
async with self.context.lock:
if not self._initialized:
await self._initialize()
Expand Down
11 changes: 7 additions & 4 deletions src/mcp/client/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pydantic_core import from_json

from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.shared._httpx_utils import redirect_note
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthClientMetadata,
Expand Down Expand Up @@ -230,9 +231,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 Expand Up @@ -297,7 +298,9 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
"""Handle registration response."""
if response.status_code not in (200, 201):
await response.aread()
raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}")
raise OAuthRegistrationError(
f"Registration failed: {response.status_code}{redirect_note(response)} {response.text}"
)

try:
content = await response.aread()
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; the SDK's OAuth providers apply the same rule to the requests
they make.
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
Loading
Loading