Skip to content

Commit b8a107d

Browse files
committed
Scope HTTP client redirect following to the request's origin
create_mcp_http_client followed every redirect, so everything configured on a client (headers, auth, request bodies) was re-sent to whatever host a Location header named. Clients built by the factory now follow redirects within the same origin (scheme, host, and port), plus http-to-https upgrades of the same host on default ports, and raise the new RedirectError for anything else - before the next request is sent. - transports resolve a refused redirect in-band: requests get a JSON-RPC error naming the target and the remedy, notifications are delivered to the session's message handler; the standalone GET stream stops retrying an endpoint that keeps redirecting - caller-supplied clients that follow no redirects get the same clear error on POST, GET stream, and SSE connect instead of an opaque content-type error - OAuth discovery, registration, token, refresh, and the identity-assertion token exchange fail loudly on redirect responses instead of silently trying the next URL or abandoning the discovery chain - RedirectError and create_mcp_http_client are exported from the top-level mcp package; migration.md documents the behavior change; docs and examples configure clients through the factory, and the general-purpose fetch example uses a browser-like client of its own
1 parent 9bdc03d commit b8a107d

24 files changed

Lines changed: 878 additions & 57 deletions

File tree

docs/client/transports.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Pass a URL string and you get **Streamable HTTP**, the transport you deploy behi
2929
--8<-- "docs_src/client_transports/tutorial002.py"
3030
```
3131

32-
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.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.
32+
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: same-origin redirect handling, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
3333

3434
!!! check
3535
A `Client` you have constructed is **not** connected. Construction only picks the transport;

docs/migration.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,14 +1595,15 @@ async with streamablehttp_client(
15951595

15961596
```python
15971597
import httpx
1598+
from mcp import create_mcp_http_client
15981599
from mcp.client.streamable_http import streamable_http_client
15991600

1600-
# Configure headers, timeout, and auth on the httpx.AsyncClient
1601-
http_client = httpx.AsyncClient(
1601+
# Configure headers, timeout, and auth on the client. create_mcp_http_client
1602+
# applies the SDK defaults (timeouts, same-origin redirect handling).
1603+
http_client = create_mcp_http_client(
16021604
headers={"Authorization": "Bearer token"},
16031605
timeout=httpx.Timeout(30, read=300),
16041606
auth=my_auth,
1605-
follow_redirects=True,
16061607
)
16071608

16081609
async with http_client:
@@ -1613,7 +1614,26 @@ async with http_client:
16131614
...
16141615
```
16151616

1616-
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
1617+
Prefer `create_mcp_http_client` when supplying your own client: a plain `httpx.AsyncClient` does not follow redirects at all, and configuring `follow_redirects=True` yourself sends everything on the client (headers, auth, bodies) to whichever server a redirect names.
1618+
1619+
### HTTP clients only follow same-origin redirects
1620+
1621+
Clients created by the SDK (including `Client("http://...")`, `sse_client`, and the
1622+
default client inside `streamable_http_client`) now vet redirect targets before
1623+
following. Redirects that stay on the same origin (scheme, host, and port) and
1624+
http-to-https upgrades of the same host still work; any other redirect raises
1625+
`mcp.RedirectError` naming the target instead of being followed. Everything
1626+
configured on a client - headers, auth, request bodies - is sent to whichever
1627+
server it talks to, so requests now stay with the server you configured, and a
1628+
misconfigured URL fails with a clear error instead of silently bouncing.
1629+
1630+
To migrate: connect to the final URL the error names. If your deployment
1631+
genuinely requires following a redirect to a different origin, supply your own
1632+
`httpx.AsyncClient(follow_redirects=True)` - after verifying you trust every
1633+
destination in the chain.
1634+
1635+
`create_mcp_http_client` (the SDK's client factory) and `RedirectError` are now
1636+
exported from the top-level `mcp` package.
16171637

16181638
### `get_session_id` callback removed from `streamable_http_client`
16191639

@@ -1638,6 +1658,7 @@ async with streamable_http_client(url) as (read_stream, write_stream, get_sessio
16381658

16391659
```python
16401660
import httpx
1661+
from mcp import create_mcp_http_client
16411662
from mcp.client.streamable_http import streamable_http_client
16421663

16431664
# Option 1: Simply ignore if you don't need the session ID
@@ -1653,10 +1674,8 @@ async def capture_session_id(response: httpx.Response) -> None:
16531674
if session_id:
16541675
captured_session_ids.append(session_id)
16551676

1656-
http_client = httpx.AsyncClient(
1657-
event_hooks={"response": [capture_session_id]},
1658-
follow_redirects=True,
1659-
)
1677+
http_client = create_mcp_http_client()
1678+
http_client.event_hooks["response"].append(capture_session_id)
16601679

16611680
async with http_client:
16621681
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):

docs/run/asgi.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr
9494
--8<-- "docs_src/asgi/tutorial004.py"
9595
```
9696

97-
Now clients connect to `/notes`, not `/notes/mcp`.
97+
Now clients connect to `/notes/`, not `/notes/mcp`. (The trailing slash is the canonical path for this mount shape; the bare `/notes` answers with a same-origin redirect to it.)
9898

9999
## CORS for browser clients
100100

docs_src/client_transports/tutorial003.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import httpx
22

3-
from mcp import Client
3+
from mcp import Client, create_mcp_http_client
44
from mcp.client.streamable_http import streamable_http_client
55

66

77
async def main() -> None:
8-
async with httpx.AsyncClient(
8+
async with create_mcp_http_client(
99
headers={"Authorization": "Bearer ..."},
1010
timeout=httpx.Timeout(30.0, read=300.0),
11-
follow_redirects=True,
1211
) as http_client:
1312
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
1413
async with Client(transport) as client:

docs_src/identity_assertion/tutorial001.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import time
22
import uuid
33

4-
import httpx
54
import jwt
65

7-
from mcp import Client
6+
from mcp import Client, create_mcp_http_client
87
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
98
from mcp.client.streamable_http import streamable_http_client
109
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
@@ -62,7 +61,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str:
6261

6362

6463
async def main() -> None:
65-
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
64+
async with create_mcp_http_client(auth=oauth) as http_client:
6665
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
6766
async with Client(transport) as client:
6867
result = await client.list_tools()

docs_src/oauth_clients/tutorial001.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
from urllib.parse import parse_qs, urlparse
22

3-
import httpx
43
from pydantic import AnyUrl
54

6-
from mcp import Client
5+
from mcp import Client, create_mcp_http_client
76
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider
87
from mcp.client.streamable_http import streamable_http_client
98
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
@@ -55,7 +54,7 @@ async def wait_for_callback() -> AuthorizationCodeResult:
5554

5655

5756
async def main() -> None:
58-
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
57+
async with create_mcp_http_client(auth=oauth) as http_client:
5958
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
6059
async with Client(transport) as client:
6160
result = await client.list_tools()

docs_src/oauth_clients/tutorial002.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import httpx
2-
3-
from mcp import Client
1+
from mcp import Client, create_mcp_http_client
42
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
53
from mcp.client.streamable_http import streamable_http_client
64
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
@@ -34,7 +32,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None
3432

3533

3634
async def main() -> None:
37-
async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
35+
async with create_mcp_http_client(auth=oauth) as http_client:
3836
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
3937
async with Client(transport) as client:
4038
result = await client.list_tools()

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from typing import Any
1818
from urllib.parse import parse_qs, urlparse
1919

20-
import httpx
20+
from mcp import create_mcp_http_client
2121
from mcp.client._transport import ReadStream, WriteStream
2222
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
2323
from mcp.client.session import ClientSession
@@ -233,7 +233,7 @@ async def _default_redirect_handler(authorization_url: str) -> None:
233233
await self._run_session(read_stream, write_stream)
234234
else:
235235
print("📡 Opening StreamableHTTP transport connection with auth...")
236-
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
236+
async with create_mcp_http_client(auth=oauth_auth) as custom_client:
237237
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
238238
read_stream,
239239
write_stream,

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import anyio
22
import click
3+
import httpx
34
import mcp_types as types
45
from mcp.server import Server, ServerRequestContext
5-
from mcp.shared._httpx_utils import create_mcp_http_client
66

77

88
async def fetch_website(
99
url: str,
1010
) -> list[types.ContentBlock]:
1111
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
12-
async with create_mcp_http_client(headers=headers) as client:
12+
# A general-purpose web fetch wants ordinary browser-like redirect
13+
# behavior; create_mcp_http_client is for talking to a configured MCP
14+
# endpoint and only follows same-origin redirects.
15+
async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=30.0) as client:
1316
response = await client.get(url)
1417
response.raise_for_status()
1518
return [types.TextContent(type="text", text=response.text)]

examples/snippets/clients/identity_assertion_client.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@
1616

1717
import asyncio
1818

19-
import httpx
20-
21-
from mcp import ClientSession
19+
from mcp import ClientSession, create_mcp_http_client
2220
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
2321
from mcp.client.streamable_http import streamable_http_client
2422
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
@@ -66,7 +64,7 @@ async def main() -> None:
6664
scope="user",
6765
)
6866

69-
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
67+
async with create_mcp_http_client(auth=oauth_auth) as http_client:
7068
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
7169
async with ClientSession(read, write) as session:
7270
await session.initialize()

0 commit comments

Comments
 (0)