Follow redirects only within the MCP endpoint's origin (#3397)
This commit is contained in:
parent
5fd3abc548
commit
c6762e821c
25 changed files with 1029 additions and 205 deletions
|
|
@ -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.
|
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.
|
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
|
### Try it
|
||||||
|
|
|
||||||
|
|
@ -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"
|
--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.
|
||||||
|
|
||||||
!!! check
|
!!! check
|
||||||
A `Client` you have constructed is **not** connected. Construction only picks the transport;
|
A `Client` you have constructed is **not** connected. Construction only picks the transport;
|
||||||
|
|
@ -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`:
|
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"
|
--8<-- "docs_src/client_transports/tutorial003.py"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -75,9 +75,30 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A
|
||||||
!!! info
|
!!! info
|
||||||
`httpx2` keeps the familiar `httpx` API, so if you know `httpx` you already know how to do auth,
|
`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
|
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, except [redirect handling](#redirects). It is also where OAuth plugs in:
|
||||||
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
|
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
|
||||||
|
|
||||||
|
### Redirects
|
||||||
|
|
||||||
|
The transport connects to the URL you gave it, and only that origin.
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
* A redirect anywhere else is **not** followed. The call fails with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
This holds for any `httpx2.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.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
`Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP` means the
|
||||||
|
server sits behind a TLS-terminating proxy it doesn't know about and is issuing `http://` redirects.
|
||||||
|
That is fixed on the server (**[Deploy & scale](../run/deploy.md#behind-a-tls-terminating-proxy)**),
|
||||||
|
or by using the exact `https://…/` URL the message suggests.
|
||||||
|
|
||||||
## stdio
|
## stdio
|
||||||
|
|
||||||
A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.
|
A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.
|
||||||
|
|
@ -115,6 +136,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair
|
||||||
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
|
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
|
||||||
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
|
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
|
||||||
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
|
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
|
||||||
|
* Redirects are followed only within the URL's own origin (a trailing-slash `307`/`308`), plus `http`→`https` on the same host. Anything else fails with `Redirect to … not followed`; configure the final URL.
|
||||||
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
|
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
|
||||||
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
|
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
|
||||||
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object, a URL or `StdioServerParameters` straight to that protocol.
|
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object, a URL or `StdioServerParameters` straight to that protocol.
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ them:
|
||||||
```python
|
```python
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
http_client = httpx.AsyncClient(follow_redirects=True)
|
http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300))
|
||||||
```
|
```
|
||||||
|
|
||||||
**After (v2):**
|
**After (v2):**
|
||||||
|
|
@ -127,7 +127,7 @@ http_client = httpx.AsyncClient(follow_redirects=True)
|
||||||
```python
|
```python
|
||||||
import httpx2
|
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
|
`httpx2` is API-compatible with `httpx`, so usually only the import name
|
||||||
|
|
@ -2092,7 +2092,6 @@ http_client = httpx2.AsyncClient(
|
||||||
headers={"Authorization": "Bearer token"},
|
headers={"Authorization": "Bearer token"},
|
||||||
timeout=httpx2.Timeout(30, read=300),
|
timeout=httpx2.Timeout(30, read=300),
|
||||||
auth=my_auth,
|
auth=my_auth,
|
||||||
follow_redirects=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async with http_client:
|
async with http_client:
|
||||||
|
|
@ -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 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:
|
`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`.
|
- `httpx_client_factory`: gone with no replacement — call your factory yourself and pass the result as `http_client`.
|
||||||
- `terminate_on_close`: unchanged (default `True`).
|
- `terminate_on_close`: unchanged (default `True`).
|
||||||
|
|
||||||
|
|
@ -2151,10 +2150,7 @@ async def capture_session_id(response: httpx2.Response) -> None:
|
||||||
if session_id:
|
if session_id:
|
||||||
captured_session_ids.append(session_id)
|
captured_session_ids.append(session_id)
|
||||||
|
|
||||||
http_client = httpx2.AsyncClient(
|
http_client = httpx2.AsyncClient(event_hooks={"response": [capture_session_id]})
|
||||||
event_hooks={"response": [capture_session_id]},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with http_client:
|
async with http_client:
|
||||||
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
|
async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr
|
||||||
--8<-- "docs_src/asgi/tutorial004.py"
|
--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
|
## CORS for browser clients
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,22 @@ Deployed behind a real hostname, that same default rejects **every request** unt
|
||||||
deployed server that refuses every connection is a Host allowlist until proven otherwise.
|
deployed server that refuses every connection is a Host allowlist until proven otherwise.
|
||||||
**[Troubleshooting](../troubleshooting.md)** starts here too.
|
**[Troubleshooting](../troubleshooting.md)** starts here too.
|
||||||
|
|
||||||
|
## Behind a TLS-terminating proxy
|
||||||
|
|
||||||
|
If TLS ends at a proxy (an ingress, a load balancer, Caddy, nginx) and uvicorn serves plain HTTP behind it, tell uvicorn to trust the proxy's `X-Forwarded-*` headers:
|
||||||
|
|
||||||
|
```console
|
||||||
|
uvicorn server:app --proxy-headers --forwarded-allow-ips='<proxy address>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Without that, the app believes it is being served over `http://`, and any redirect it issues (the usual one is `/mcp` → `/mcp/`) points at `http://…`. The Python client refuses to follow an HTTPS endpoint to plain HTTP and says so:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP.
|
||||||
|
```
|
||||||
|
|
||||||
|
The client-side stopgap is to configure the exact URL the server serves (`https://mcp.example.com/mcp/`, slash included) so no redirect happens. The fix is the flag above. `FORWARDED_ALLOW_IPS` is the environment-variable spelling; `*` trusts every hop, which is only right when nothing but the proxy can reach uvicorn.
|
||||||
|
|
||||||
## Workers, and who has to be sticky
|
## Workers, and who has to be sticky
|
||||||
|
|
||||||
Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork:
|
Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork:
|
||||||
|
|
@ -165,6 +181,7 @@ An `MCPServer` is a protocol implementation, not an application server. The depl
|
||||||
## Recap
|
## Recap
|
||||||
|
|
||||||
* Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log.
|
* Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log.
|
||||||
|
* Behind a TLS-terminating proxy, run uvicorn with `--proxy-headers --forwarded-allow-ips=...`, or its redirects point at `http://` and the client refuses them.
|
||||||
* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read.
|
* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read.
|
||||||
* The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*.
|
* The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*.
|
||||||
* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance. The name is the token's default audience claim. Same keys, same name.
|
* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance. The name is the token's default audience claim. Same keys, same name.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ async def main() -> None:
|
||||||
async with httpx2.AsyncClient(
|
async with httpx2.AsyncClient(
|
||||||
headers={"Authorization": "Bearer ..."},
|
headers={"Authorization": "Bearer ..."},
|
||||||
timeout=httpx2.Timeout(30.0, read=300.0),
|
timeout=httpx2.Timeout(30.0, read=300.0),
|
||||||
follow_redirects=True,
|
|
||||||
) as http_client:
|
) as http_client:
|
||||||
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
|
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
|
||||||
async with Client(transport) as client:
|
async with Client(transport) as client:
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ oauth = IdentityAssertionOAuthProvider(
|
||||||
|
|
||||||
|
|
||||||
async def main() -> 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)
|
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
|
||||||
async with Client(transport) as client:
|
async with Client(transport) as client:
|
||||||
result = await client.list_tools()
|
result = await client.list_tools()
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ oauth = OAuthClientProvider(
|
||||||
|
|
||||||
|
|
||||||
async def main() -> 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)
|
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
|
||||||
async with Client(transport) as client:
|
async with Client(transport) as client:
|
||||||
result = await client.list_tools()
|
result = await client.list_tools()
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ oauth = ClientCredentialsOAuthProvider(
|
||||||
|
|
||||||
|
|
||||||
async def main() -> 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)
|
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
|
||||||
async with Client(transport) as client:
|
async with Client(transport) as client:
|
||||||
result = await client.list_tools()
|
result = await client.list_tools()
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ class SimpleAuthClient:
|
||||||
await self._run_session(read_stream, write_stream)
|
await self._run_session(read_stream, write_stream)
|
||||||
else:
|
else:
|
||||||
print("📡 Opening StreamableHTTP transport connection with auth...")
|
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 (
|
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
|
||||||
read_stream,
|
read_stream,
|
||||||
write_stream,
|
write_stream,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import anyio
|
import anyio
|
||||||
import click
|
import click
|
||||||
|
import httpx2
|
||||||
import mcp.types as types
|
import mcp.types as types
|
||||||
from mcp.server import Server, ServerRequestContext
|
from mcp.server import Server, ServerRequestContext
|
||||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_website(
|
async def fetch_website(
|
||||||
url: str,
|
url: str,
|
||||||
) -> list[types.ContentBlock]:
|
) -> list[types.ContentBlock]:
|
||||||
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
|
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 = await client.get(url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return [types.TextContent(type="text", text=response.text)]
|
return [types.TextContent(type="text", text=response.text)]
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ async def main() -> None:
|
||||||
scope="user",
|
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 streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
|
||||||
async with ClientSession(read, write) as session:
|
async with ClientSession(read, write) as session:
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ async def main():
|
||||||
callback_handler=handle_callback,
|
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 streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
|
||||||
async with ClientSession(read, write) as session:
|
async with ClientSession(read, write) as session:
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from mcp.client.auth.utils import (
|
||||||
union_scopes,
|
union_scopes,
|
||||||
validate_metadata_issuer,
|
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 import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
|
||||||
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url
|
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url
|
||||||
|
|
||||||
|
|
@ -56,7 +57,7 @@ def _origin(url: str) -> tuple[str, str, int | None]:
|
||||||
return (parsed.scheme, parsed.hostname or "", port)
|
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.
|
"""`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
|
The authorization server `issuer` is fixed at construction; metadata is fetched from its
|
||||||
|
|
@ -159,7 +160,7 @@ class IdentityAssertionOAuthProvider(httpx2.Auth):
|
||||||
data["client_secret"] = self._client.client_secret
|
data["client_secret"] = self._client.client_secret
|
||||||
return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers)
|
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:
|
async with self._lock:
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
self._tokens = await self._storage.get_tokens()
|
self._tokens = await self._storage.get_tokens()
|
||||||
|
|
@ -201,7 +202,9 @@ class IdentityAssertionOAuthProvider(httpx2.Auth):
|
||||||
token_response = yield self._build_token_request(scope_to_request, assertion)
|
token_response = yield self._build_token_request(scope_to_request, assertion)
|
||||||
if token_response.status_code != 200:
|
if token_response.status_code != 200:
|
||||||
body = (await token_response.aread()).decode(errors="replace")
|
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)
|
tokens = await handle_token_response_scopes(token_response)
|
||||||
if tokens.scope is None:
|
if tokens.scope is None:
|
||||||
tokens.scope = scope_to_request
|
tokens.scope = scope_to_request
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from mcp.client.auth.utils import (
|
||||||
validate_authorization_response_iss,
|
validate_authorization_response_iss,
|
||||||
validate_metadata_issuer,
|
validate_metadata_issuer,
|
||||||
)
|
)
|
||||||
|
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
|
||||||
from mcp.shared.auth import (
|
from mcp.shared.auth import (
|
||||||
AuthorizationCodeResult,
|
AuthorizationCodeResult,
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
|
|
@ -287,7 +288,7 @@ def _origin_issuer(server_url: str) -> str:
|
||||||
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))
|
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))
|
||||||
|
|
||||||
|
|
||||||
class OAuthClientProvider(httpx2.Auth):
|
class OAuthClientProvider(RedirectAwareAuth):
|
||||||
"""OAuth2 authentication for httpx2.
|
"""OAuth2 authentication for httpx2.
|
||||||
|
|
||||||
Handles OAuth flow with automatic client registration and token storage.
|
Handles OAuth flow with automatic client registration and token storage.
|
||||||
|
|
@ -480,7 +481,9 @@ class OAuthClientProvider(httpx2.Auth):
|
||||||
if response.status_code not in {200, 201}:
|
if response.status_code not in {200, 201}:
|
||||||
body = await response.aread()
|
body = await response.aread()
|
||||||
body_text = body.decode("utf-8")
|
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
|
# Parse and validate response with scope validation
|
||||||
token_response = await handle_token_response_scopes(response)
|
token_response = await handle_token_response_scopes(response)
|
||||||
|
|
@ -530,7 +533,7 @@ class OAuthClientProvider(httpx2.Auth):
|
||||||
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
|
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
|
||||||
"""Handle token refresh response. Returns True if successful."""
|
"""Handle token refresh response. Returns True if successful."""
|
||||||
if response.status_code != 200:
|
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()
|
self.context.clear_tokens()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -598,8 +601,8 @@ class OAuthClientProvider(httpx2.Auth):
|
||||||
the 2025-03-26 well-known URL is built from (RFC 8414 §3.3)."""
|
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)
|
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]:
|
async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
|
||||||
"""httpx2 auth flow integration."""
|
"""The OAuth flow proper; `async_auth_flow` drives it (see `RedirectAwareAuth`)."""
|
||||||
async with self.context.lock:
|
async with self.context.lock:
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
await self._initialize()
|
await self._initialize()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from pydantic import AnyUrl, ValidationError
|
||||||
from pydantic_core import from_json
|
from pydantic_core import from_json
|
||||||
|
|
||||||
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
||||||
|
from mcp.shared._httpx_utils import redirect_note
|
||||||
from mcp.shared.auth import (
|
from mcp.shared.auth import (
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
OAuthClientMetadata,
|
OAuthClientMetadata,
|
||||||
|
|
@ -230,9 +231,9 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth
|
||||||
return True, asm
|
return True, asm
|
||||||
except ValidationError: # pragma: no cover
|
except ValidationError: # pragma: no cover
|
||||||
return True, None
|
return True, None
|
||||||
elif response.status_code < 400 or response.status_code >= 500:
|
elif 300 <= response.status_code < 500:
|
||||||
return False, None # Non-4XX error, stop trying
|
return True, None # Not served at this URL (redirects are not followed) - try the next candidate
|
||||||
return True, None
|
return False, None # Server error or unexpected status, stop trying
|
||||||
|
|
||||||
|
|
||||||
def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None:
|
def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None:
|
||||||
|
|
@ -297,7 +298,9 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
|
||||||
"""Handle registration response."""
|
"""Handle registration response."""
|
||||||
if response.status_code not in (200, 201):
|
if response.status_code not in (200, 201):
|
||||||
await response.aread()
|
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:
|
try:
|
||||||
content = await response.aread()
|
content = await response.aread()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ from httpx2 import SSEError
|
||||||
|
|
||||||
from mcp.shared._compat import resync_tracer
|
from mcp.shared._compat import resync_tracer
|
||||||
from mcp.shared._context_streams import create_context_streams
|
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
|
from mcp.shared.message import SessionMessage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -47,7 +52,14 @@ async def sse_client(
|
||||||
headers: Optional headers to include in requests.
|
headers: Optional headers to include in requests.
|
||||||
timeout: HTTP timeout for regular operations (in seconds).
|
timeout: HTTP timeout for regular operations (in seconds).
|
||||||
sse_read_timeout: Timeout for SSE read 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 status for the SSE GET, 307/308 for a message POST); 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.
|
auth: Optional httpx2 authentication handler.
|
||||||
on_session_created: Optional callback invoked with the session ID when received.
|
on_session_created: Optional callback invoked with the session ID when received.
|
||||||
"""
|
"""
|
||||||
|
|
@ -55,7 +67,7 @@ async def sse_client(
|
||||||
async with httpx_client_factory(
|
async with httpx_client_factory(
|
||||||
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
|
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
|
||||||
) as client:
|
) 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()
|
event_source.response.raise_for_status()
|
||||||
logger.debug("SSE connection established")
|
logger.debug("SSE connection established")
|
||||||
|
|
||||||
|
|
@ -121,13 +133,11 @@ async def sse_client(
|
||||||
|
|
||||||
async def _send_message(session_message: SessionMessage) -> None:
|
async def _send_message(session_message: SessionMessage) -> None:
|
||||||
logger.debug(f"Sending client message: {session_message}")
|
logger.debug(f"Sending client message: {session_message}")
|
||||||
response = await client.post(
|
response = await request_within_origin(
|
||||||
|
client,
|
||||||
|
"POST",
|
||||||
endpoint_url,
|
endpoint_url,
|
||||||
json=session_message.message.model_dump(
|
json=session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True),
|
||||||
by_alias=True,
|
|
||||||
mode="json",
|
|
||||||
exclude_unset=True,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.debug(f"Client message sent successfully: {response.status_code}")
|
logger.debug(f"Client message sent successfully: {response.status_code}")
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,13 @@ from pydantic import ValidationError
|
||||||
from mcp.client._transport import TransportStreams
|
from mcp.client._transport import TransportStreams
|
||||||
from mcp.shared._compat import resync_tracer
|
from mcp.shared._compat import resync_tracer
|
||||||
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
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,
|
||||||
|
redirect_location,
|
||||||
|
request_within_origin,
|
||||||
|
sse_within_origin,
|
||||||
|
stream_within_origin,
|
||||||
|
)
|
||||||
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
||||||
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
|
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
|
||||||
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
||||||
|
|
@ -62,6 +68,21 @@ class ResumptionError(StreamableHTTPError):
|
||||||
"""Raised when resumption request is invalid."""
|
"""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."""
|
||||||
|
location = redirect_location(response)
|
||||||
|
if location is None:
|
||||||
|
return None
|
||||||
|
if response.request.url.scheme == "https" and location.scheme == "http":
|
||||||
|
return (
|
||||||
|
f"Redirect to {location} not followed: it would downgrade this HTTPS endpoint to plain HTTP.\n"
|
||||||
|
"The server is likely behind a TLS-terminating proxy whose forwarded headers it does not trust,\n"
|
||||||
|
f"often combined with a trailing-slash difference. Try {location.copy_with(scheme='https')} instead, "
|
||||||
|
"or fix the proxy settings."
|
||||||
|
)
|
||||||
|
return f"Redirect to {location} not followed; use that URL as the endpoint if it is the intended server"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RequestContext:
|
class RequestContext:
|
||||||
"""Context for a request operation."""
|
"""Context for a request operation."""
|
||||||
|
|
@ -210,7 +231,11 @@ class StreamableHTTPTransport:
|
||||||
if last_event_id:
|
if last_event_id:
|
||||||
headers[LAST_EVENT_ID] = 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()
|
event_source.response.raise_for_status()
|
||||||
logger.debug("GET SSE connection established")
|
logger.debug("GET SSE connection established")
|
||||||
|
|
||||||
|
|
@ -253,7 +278,14 @@ class StreamableHTTPTransport:
|
||||||
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
|
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
|
||||||
original_request_id = ctx.session_message.message.id
|
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()
|
event_source.response.raise_for_status()
|
||||||
logger.debug("Resumption GET SSE connection established")
|
logger.debug("Resumption GET SSE connection established")
|
||||||
|
|
||||||
|
|
@ -320,7 +352,8 @@ class StreamableHTTPTransport:
|
||||||
if ctx.metadata is not None and ctx.metadata.headers is not None:
|
if ctx.metadata is not None and ctx.metadata.headers is not None:
|
||||||
headers.update(ctx.metadata.headers)
|
headers.update(ctx.metadata.headers)
|
||||||
|
|
||||||
async with ctx.client.stream(
|
async with stream_within_origin(
|
||||||
|
ctx.client,
|
||||||
"POST",
|
"POST",
|
||||||
self.url,
|
self.url,
|
||||||
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
|
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
|
||||||
|
|
@ -339,6 +372,14 @@ class StreamableHTTPTransport:
|
||||||
)
|
)
|
||||||
return
|
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 response.status_code >= 400:
|
||||||
if isinstance(message, JSONRPCRequest):
|
if isinstance(message, JSONRPCRequest):
|
||||||
# A spec-correct server may return the JSON-RPC error in the
|
# A spec-correct server may return the JSON-RPC error in the
|
||||||
|
|
@ -501,7 +542,7 @@ class StreamableHTTPTransport:
|
||||||
headers[LAST_EVENT_ID] = last_event_id
|
headers[LAST_EVENT_ID] = last_event_id
|
||||||
|
|
||||||
try:
|
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()
|
event_source.response.raise_for_status()
|
||||||
logger.info("Reconnected to SSE stream")
|
logger.info("Reconnected to SSE stream")
|
||||||
|
|
||||||
|
|
@ -626,7 +667,7 @@ class StreamableHTTPTransport:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
headers = self._prepare_headers()
|
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:
|
if response.status_code == 405:
|
||||||
logger.debug("Server does not allow session termination")
|
logger.debug("Server does not allow session termination")
|
||||||
|
|
@ -650,6 +691,13 @@ async def streamable_http_client(
|
||||||
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
|
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
|
||||||
client with recommended MCP timeouts will be created. To configure headers,
|
client with recommended MCP timeouts will be created. To configure headers,
|
||||||
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
|
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 for a POST; any status for the GET
|
||||||
|
stream); 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; the SDK's OAuth providers apply the
|
||||||
|
same rule to the requests they make.
|
||||||
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
|
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
"""Utilities for creating standardized httpx2 AsyncClient instances."""
|
"""Utilities for creating and using httpx2 AsyncClient instances in the MCP transports."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import httpx2
|
import httpx2
|
||||||
|
|
@ -10,6 +13,12 @@ __all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_REA
|
||||||
MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds)
|
MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds)
|
||||||
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds)
|
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds)
|
||||||
|
|
||||||
|
# The headers httpx2.AsyncClient.sse() adds to an event-stream request.
|
||||||
|
_SSE_HEADERS = {"Accept": "text/event-stream", "Cache-Control": "no-store"}
|
||||||
|
|
||||||
|
# How many redirects one auth-flow request may follow within its origin (see RedirectAwareAuth).
|
||||||
|
_AUTH_REDIRECT_LIMIT = 5
|
||||||
|
|
||||||
|
|
||||||
class McpHttpClientFactory(Protocol): # pragma: no branch
|
class McpHttpClientFactory(Protocol): # pragma: no branch
|
||||||
def __call__( # pragma: no branch
|
def __call__( # pragma: no branch
|
||||||
|
|
@ -25,9 +34,12 @@ def create_mcp_http_client(
|
||||||
timeout: httpx2.Timeout | None = None,
|
timeout: httpx2.Timeout | None = None,
|
||||||
auth: httpx2.Auth | None = None,
|
auth: httpx2.Auth | None = None,
|
||||||
) -> httpx2.AsyncClient:
|
) -> httpx2.AsyncClient:
|
||||||
"""Create a standardized httpx2 AsyncClient with MCP defaults.
|
"""Create an httpx2 AsyncClient with the MCP transports' default timeouts.
|
||||||
|
|
||||||
Always enables follow_redirects and applies an SSE-friendly default timeout.
|
The client uses a 30-second timeout for connect/write/pool and a 300-second
|
||||||
|
read timeout, because a server may hold a response stream open. Redirect
|
||||||
|
following is left at the httpx2 default (off): the MCP transports follow
|
||||||
|
redirects within the endpoint's origin themselves, see `stream_within_origin`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
headers: Optional headers to include with all requests.
|
headers: Optional headers to include with all requests.
|
||||||
|
|
@ -36,60 +48,169 @@ def create_mcp_http_client(
|
||||||
auth: Optional authentication handler.
|
auth: Optional authentication handler.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured httpx2.AsyncClient instance with MCP defaults.
|
Configured httpx2.AsyncClient instance.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
The returned AsyncClient must be used as a context manager to ensure
|
The returned AsyncClient must be used as a context manager to ensure
|
||||||
proper cleanup of connections.
|
proper cleanup of connections.
|
||||||
|
|
||||||
Example:
|
|
||||||
Basic usage with MCP defaults:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async with create_mcp_http_client() as client:
|
|
||||||
response = await client.get("https://api.example.com")
|
|
||||||
```
|
|
||||||
|
|
||||||
With custom headers:
|
|
||||||
|
|
||||||
```python
|
|
||||||
headers = {"Authorization": "Bearer token"}
|
|
||||||
async with create_mcp_http_client(headers) as client:
|
|
||||||
response = await client.get("/endpoint")
|
|
||||||
```
|
|
||||||
|
|
||||||
With both custom headers and timeout:
|
|
||||||
|
|
||||||
```python
|
|
||||||
timeout = httpx2.Timeout(60.0, read=300.0)
|
|
||||||
async with create_mcp_http_client(headers, timeout) as client:
|
|
||||||
response = await client.get("/long-request")
|
|
||||||
```
|
|
||||||
|
|
||||||
With authentication:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from httpx2 import BasicAuth
|
|
||||||
auth = BasicAuth(username="user", password="pass")
|
|
||||||
async with create_mcp_http_client(headers, timeout, auth) as client:
|
|
||||||
response = await client.get("/protected-endpoint")
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
# Set MCP defaults
|
|
||||||
kwargs: dict[str, Any] = {"follow_redirects": True}
|
|
||||||
|
|
||||||
# Handle timeout
|
|
||||||
if timeout is None:
|
if timeout is None:
|
||||||
kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
|
timeout = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
|
||||||
else:
|
kwargs: dict[str, Any] = {"timeout": timeout}
|
||||||
kwargs["timeout"] = timeout
|
|
||||||
|
|
||||||
# Handle headers
|
|
||||||
if headers is not None:
|
if headers is not None:
|
||||||
kwargs["headers"] = headers
|
kwargs["headers"] = headers
|
||||||
|
|
||||||
# Handle authentication
|
|
||||||
if auth is not None: # pragma: no cover
|
if auth is not None: # pragma: no cover
|
||||||
kwargs["auth"] = auth
|
kwargs["auth"] = auth
|
||||||
|
|
||||||
return httpx2.AsyncClient(**kwargs)
|
return httpx2.AsyncClient(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _within_origin(url: httpx2.URL, location: httpx2.URL) -> bool:
|
||||||
|
"""Whether `location` is on `url`'s origin, or is its https upgrade on the default ports.
|
||||||
|
|
||||||
|
httpx2 normalises a scheme's default port to None and lower-cases hosts, so
|
||||||
|
plain tuple comparison is exact. The upgrade rule is the one httpx2 itself
|
||||||
|
uses to decide a redirect has not left the origin (`_is_https_redirect`).
|
||||||
|
"""
|
||||||
|
if (url.scheme, url.host, url.port) == (location.scheme, location.host, location.port):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
url.host == location.host
|
||||||
|
and url.scheme == "http"
|
||||||
|
and url.port is None
|
||||||
|
and location.scheme == "https"
|
||||||
|
and location.port is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def next_request_within_origin(response: httpx2.Response) -> httpx2.Request | None:
|
||||||
|
"""The request that follows `response`'s redirect, if it is one the MCP transports follow.
|
||||||
|
|
||||||
|
That is when httpx2 built a next request for it (a redirect status with a
|
||||||
|
Location), the next request keeps the method (307/308, or any redirect of a
|
||||||
|
GET: httpx2 turns a POST into a body-less GET for 301/302/303, which would
|
||||||
|
drop the message), its URL stays within the origin of the request just sent
|
||||||
|
(same scheme, host and port, or http to https on the same host with default
|
||||||
|
ports), and the Location carries no userinfo (which httpx2 would otherwise
|
||||||
|
send as Basic auth). None for anything else, including a non-redirect.
|
||||||
|
"""
|
||||||
|
next_request = response.next_request
|
||||||
|
if next_request is None:
|
||||||
|
return None
|
||||||
|
sent = response.request
|
||||||
|
if (
|
||||||
|
next_request.method != sent.method
|
||||||
|
or next_request.url.userinfo
|
||||||
|
or not _within_origin(sent.url, next_request.url)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return next_request
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def stream_within_origin(
|
||||||
|
client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any
|
||||||
|
) -> AsyncGenerator[httpx2.Response]:
|
||||||
|
"""`client.stream(...)`, following redirects only while they stay within the request's origin.
|
||||||
|
|
||||||
|
An MCP transport talks to one configured endpoint, and everything on a request
|
||||||
|
(headers, auth, body) was configured for that endpoint. A redirect that
|
||||||
|
`next_request_within_origin` accepts, such as a 307/308 trailing-slash
|
||||||
|
normalisation, is followed, at most `client.max_redirects` times. Any other
|
||||||
|
redirect (or one past that budget) is not followed: the redirect response
|
||||||
|
itself is yielded, the way httpx2 hands one back when `follow_redirects` is
|
||||||
|
off, and the caller treats it as the non-success it is. The client's own
|
||||||
|
`follow_redirects` setting is not consulted. Requests an `httpx2.Auth` flow
|
||||||
|
makes during the call are sent without following either; the SDK's OAuth
|
||||||
|
providers apply the same rule to their own requests.
|
||||||
|
"""
|
||||||
|
request = client.build_request(method, url, **kwargs)
|
||||||
|
followed = 0
|
||||||
|
while True:
|
||||||
|
response = await client.send(request, stream=True, follow_redirects=False)
|
||||||
|
next_request = next_request_within_origin(response)
|
||||||
|
if next_request is None or followed == client.max_redirects:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
# Drain the redirect body so the connection returns to the pool, as httpx2 does when it follows.
|
||||||
|
await response.aread()
|
||||||
|
finally:
|
||||||
|
await response.aclose()
|
||||||
|
request = next_request
|
||||||
|
followed += 1
|
||||||
|
try:
|
||||||
|
yield response
|
||||||
|
finally:
|
||||||
|
await response.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def request_within_origin(
|
||||||
|
client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, **kwargs: Any
|
||||||
|
) -> httpx2.Response:
|
||||||
|
"""`client.request(...)` with the redirect handling of `stream_within_origin`."""
|
||||||
|
async with stream_within_origin(client, method, url, **kwargs) as response:
|
||||||
|
await response.aread()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def sse_within_origin(
|
||||||
|
client: httpx2.AsyncClient, url: httpx2.URL | str, *, headers: dict[str, str] | None = None
|
||||||
|
) -> AsyncGenerator[httpx2.EventSource]:
|
||||||
|
"""`client.sse(url)` with the redirect handling of `stream_within_origin`."""
|
||||||
|
merged = httpx2.Headers(_SSE_HEADERS)
|
||||||
|
merged.update(headers or {})
|
||||||
|
async with stream_within_origin(client, "GET", url, headers=merged) as response:
|
||||||
|
yield httpx2.EventSource(response)
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_location(response: httpx2.Response) -> httpx2.URL | None:
|
||||||
|
"""Where `response` redirects to, for use in a message: without userinfo, query or fragment,
|
||||||
|
which can carry state that does not belong in an error or a log line. None if not a redirect."""
|
||||||
|
if response.next_request is None:
|
||||||
|
return None
|
||||||
|
return response.next_request.url.copy_with(userinfo=b"", query=None, fragment=None)
|
||||||
|
|
||||||
|
|
||||||
|
def redirect_note(response: httpx2.Response) -> str:
|
||||||
|
"""A suffix naming the location of a redirect response that was not followed, else empty."""
|
||||||
|
location = redirect_location(response)
|
||||||
|
if location is None:
|
||||||
|
return ""
|
||||||
|
return f" (redirected to {location}; not followed)"
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectAwareAuth(ABC, httpx2.Auth):
|
||||||
|
"""An `httpx2.Auth` whose own requests follow redirects the way MCP transport requests do.
|
||||||
|
|
||||||
|
The transports send every request with redirect following off and follow a
|
||||||
|
redirect themselves only within the endpoint's origin (`stream_within_origin`).
|
||||||
|
httpx2 applies that per-request setting to the requests an auth flow makes
|
||||||
|
too (metadata discovery, registration, token), so on their own those would
|
||||||
|
follow nothing. Subclasses write their flow as `_auth_flow`; this class
|
||||||
|
drives it and, for each request the flow makes other than the one being
|
||||||
|
authenticated, follows a redirect that `next_request_within_origin` accepts,
|
||||||
|
up to `_AUTH_REDIRECT_LIMIT` times. Any other redirect response is handed
|
||||||
|
to the flow as it is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
|
||||||
|
"""The subclass's flow, written as `httpx2.Auth.async_auth_flow` otherwise would be."""
|
||||||
|
|
||||||
|
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
|
||||||
|
flow = self._auth_flow(request)
|
||||||
|
try:
|
||||||
|
outgoing = await flow.__anext__()
|
||||||
|
while True:
|
||||||
|
response = yield outgoing
|
||||||
|
if outgoing is not request:
|
||||||
|
for _ in range(_AUTH_REDIRECT_LIMIT):
|
||||||
|
follow = next_request_within_origin(response)
|
||||||
|
if follow is None:
|
||||||
|
break
|
||||||
|
response = yield follow
|
||||||
|
outgoing = await flow.asend(response)
|
||||||
|
except StopAsyncIteration:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
await flow.aclose()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ from mcp.client.auth.utils import (
|
||||||
extract_resource_metadata_from_www_auth,
|
extract_resource_metadata_from_www_auth,
|
||||||
extract_scope_from_www_auth,
|
extract_scope_from_www_auth,
|
||||||
get_client_metadata_scopes,
|
get_client_metadata_scopes,
|
||||||
|
handle_auth_metadata_response,
|
||||||
handle_registration_response,
|
handle_registration_response,
|
||||||
is_valid_client_metadata_url,
|
is_valid_client_metadata_url,
|
||||||
should_use_client_metadata_url,
|
should_use_client_metadata_url,
|
||||||
|
|
@ -824,6 +826,97 @@ class TestProtectedResourceMetadata:
|
||||||
assert "resource=" in content
|
assert "resource=" in content
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_discovery(
|
||||||
|
provider: OAuthClientProvider,
|
||||||
|
) -> tuple[AsyncGenerator[httpx2.Request, httpx2.Response], httpx2.Request]:
|
||||||
|
"""Drive `provider`'s auth flow to the point where it has sent the MCP request, seen a 401 and
|
||||||
|
issued its first protected-resource-metadata request; returns (flow, that request)."""
|
||||||
|
provider.context.current_tokens = None
|
||||||
|
provider.context.token_expiry_time = None
|
||||||
|
provider._initialized = True
|
||||||
|
mcp_request = httpx2.Request("POST", "https://api.example.com/v1/mcp")
|
||||||
|
flow = provider.async_auth_flow(mcp_request)
|
||||||
|
sent = await flow.__anext__()
|
||||||
|
assert sent is mcp_request
|
||||||
|
# No resource_metadata hint, so discovery tries the path-based well-known URL, then the root one.
|
||||||
|
unauthorized = httpx2.Response(401, request=mcp_request)
|
||||||
|
prm_request = await flow.asend(unauthorized)
|
||||||
|
assert (prm_request.method, str(prm_request.url)) == (
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
|
||||||
|
)
|
||||||
|
return flow, prm_request
|
||||||
|
|
||||||
|
|
||||||
|
async def _redirect(request: httpx2.Request, status: int, location: str) -> httpx2.Response:
|
||||||
|
"""A redirect answer to `request`, as httpx2 hands it back when it does not follow it."""
|
||||||
|
transport = httpx2.MockTransport(lambda r: httpx2.Response(status, headers={"location": location}))
|
||||||
|
async with httpx2.AsyncClient(transport=transport) as client:
|
||||||
|
return await client.send(request)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_auth_flow_follows_a_same_origin_redirect_of_its_own_request(oauth_provider: OAuthClientProvider):
|
||||||
|
"""SDK-defined: a request the OAuth flow makes (here protected-resource metadata discovery)
|
||||||
|
follows a redirect that stays within its origin and keeps its method, like an MCP request."""
|
||||||
|
flow, prm_request = await _start_discovery(oauth_provider)
|
||||||
|
|
||||||
|
follow_up = await flow.asend(await _redirect(prm_request, 307, "/.well-known/oauth-protected-resource/v1/mcp/"))
|
||||||
|
|
||||||
|
assert (follow_up.method, str(follow_up.url)) == (
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/.well-known/oauth-protected-resource/v1/mcp/",
|
||||||
|
)
|
||||||
|
await flow.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_auth_flow_does_not_follow_a_redirect_of_its_own_request_to_another_origin(
|
||||||
|
oauth_provider: OAuthClientProvider,
|
||||||
|
):
|
||||||
|
"""SDK-defined: a redirect of a flow request to another origin is handed to the flow unfollowed,
|
||||||
|
which treats it as "not served here" and moves to its next discovery URL."""
|
||||||
|
flow, prm_request = await _start_discovery(oauth_provider)
|
||||||
|
|
||||||
|
next_request = await flow.asend(await _redirect(prm_request, 307, "https://elsewhere.example/prm"))
|
||||||
|
|
||||||
|
assert (next_request.method, str(next_request.url)) == (
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/.well-known/oauth-protected-resource",
|
||||||
|
)
|
||||||
|
await flow.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_auth_flow_stops_following_a_redirecting_request_after_a_few_hops(
|
||||||
|
oauth_provider: OAuthClientProvider,
|
||||||
|
):
|
||||||
|
"""SDK-defined: a flow request that keeps redirecting within its origin is followed a bounded
|
||||||
|
number of times; the redirect after that is handed to the flow unfollowed."""
|
||||||
|
flow, request = await _start_discovery(oauth_provider)
|
||||||
|
|
||||||
|
hops = 0
|
||||||
|
while str(request.url) != "https://api.example.com/.well-known/oauth-protected-resource":
|
||||||
|
request = await flow.asend(
|
||||||
|
await _redirect(request, 307, f"/.well-known/oauth-protected-resource/v1/mcp/{hops}")
|
||||||
|
)
|
||||||
|
hops += 1
|
||||||
|
|
||||||
|
assert hops == 6 # five followed, the sixth handed back and taken as "try the next URL"
|
||||||
|
await flow.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize(("status", "keep_trying"), [(404, True), (307, True), (500, False)])
|
||||||
|
async def test_auth_metadata_response_says_whether_to_try_the_next_discovery_url(
|
||||||
|
status: int, keep_trying: bool
|
||||||
|
) -> None:
|
||||||
|
"""SDK-defined: a 4xx or a 3xx (redirects are not followed on these requests) from a discovery
|
||||||
|
candidate means the metadata is not served there and the next well-known URL is tried; a 5xx
|
||||||
|
stops discovery."""
|
||||||
|
assert await handle_auth_metadata_response(httpx2.Response(status)) == (keep_trying, None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("protocol_version", "expected"),
|
("protocol_version", "expected"),
|
||||||
[
|
[
|
||||||
|
|
@ -977,35 +1070,33 @@ class TestRegistrationResponse:
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_handle_registration_response_reads_before_accessing_text(self):
|
async def test_handle_registration_response_reads_before_accessing_text(self):
|
||||||
"""Test that response.aread() is called before accessing response.text."""
|
"""The registration error carries the response text, which for a streamed response means
|
||||||
|
reading it first (a streamed httpx2 response raises ResponseNotRead otherwise)."""
|
||||||
|
response = httpx2.Response(400, stream=httpx2.ByteStream(b"Registration failed with error"))
|
||||||
|
|
||||||
# Track if aread() was called
|
with pytest.raises(OAuthRegistrationError) as exc_info:
|
||||||
class MockResponse(httpx2.Response):
|
await handle_registration_response(response)
|
||||||
def __init__(self):
|
|
||||||
self.status_code = 400
|
|
||||||
self._aread_called = False
|
|
||||||
self._text = "Registration failed with error"
|
|
||||||
|
|
||||||
async def aread(self):
|
assert str(exc_info.value) == snapshot("Registration failed: 400 Registration failed with error")
|
||||||
self._aread_called = True
|
|
||||||
return b"test content"
|
|
||||||
|
|
||||||
@property
|
@pytest.mark.anyio
|
||||||
def text(self):
|
async def test_registration_error_names_an_unfollowed_redirect(self):
|
||||||
if not self._aread_called:
|
"""SDK-defined: when the registration endpoint answered with a redirect that was not followed,
|
||||||
raise RuntimeError("Response.text accessed before response.aread()") # pragma: no cover
|
the error says where it pointed (without userinfo or query) instead of only the bare status."""
|
||||||
return self._text
|
request = httpx2.Request("POST", "https://as.example/register")
|
||||||
|
async with httpx2.AsyncClient(
|
||||||
|
transport=httpx2.MockTransport(
|
||||||
|
lambda r: httpx2.Response(307, headers={"location": "https://u:p@elsewhere.example/register?state=x"})
|
||||||
|
)
|
||||||
|
) as client:
|
||||||
|
response = await client.send(request)
|
||||||
|
|
||||||
mock_response = MockResponse()
|
with pytest.raises(OAuthRegistrationError) as exc_info:
|
||||||
|
await handle_registration_response(response)
|
||||||
|
|
||||||
# This should call aread() before accessing text
|
assert str(exc_info.value) == snapshot(
|
||||||
with pytest.raises(Exception) as exc_info:
|
"Registration failed: 307 (redirected to https://elsewhere.example/register; not followed) "
|
||||||
await handle_registration_response(mock_response)
|
)
|
||||||
|
|
||||||
# Verify aread() was called
|
|
||||||
assert mock_response._aread_called
|
|
||||||
# Verify the error message includes the response text
|
|
||||||
assert "Registration failed: 400" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue