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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
!!! check
|
||||
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`:
|
||||
|
||||
```python title="client.py" hl_lines="8-14"
|
||||
```python title="client.py" hl_lines="8-13"
|
||||
--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
|
||||
`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, except [redirect handling](#redirects). It is also where OAuth plugs in:
|
||||
`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
|
||||
|
||||
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("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.
|
||||
* 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.
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ them:
|
|||
```python
|
||||
import httpx
|
||||
|
||||
http_client = httpx.AsyncClient(follow_redirects=True)
|
||||
http_client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=300))
|
||||
```
|
||||
|
||||
**After (v2):**
|
||||
|
|
@ -127,7 +127,7 @@ http_client = httpx.AsyncClient(follow_redirects=True)
|
|||
```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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
||||
- `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`).
|
||||
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
**[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
|
||||
|
||||
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
|
||||
|
||||
* 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.
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ oauth = IdentityAssertionOAuthProvider(
|
|||
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ oauth = OAuthClientProvider(
|
|||
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ oauth = ClientCredentialsOAuthProvider(
|
|||
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ class SimpleAuthClient:
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from mcp.client.auth.utils import (
|
|||
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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -159,7 +160,7 @@ class IdentityAssertionOAuthProvider(httpx2.Auth):
|
|||
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()
|
||||
|
|
@ -201,7 +202,9 @@ class IdentityAssertionOAuthProvider(httpx2.Auth):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from mcp.client.auth.utils import (
|
|||
validate_authorization_response_iss,
|
||||
validate_metadata_issuer,
|
||||
)
|
||||
from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note
|
||||
from mcp.shared.auth import (
|
||||
AuthorizationCodeResult,
|
||||
OAuthClientInformationFull,
|
||||
|
|
@ -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.
|
||||
|
|
@ -480,7 +481,9 @@ class OAuthClientProvider(httpx2.Auth):
|
|||
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)
|
||||
|
|
@ -530,7 +533,7 @@ class OAuthClientProvider(httpx2.Auth):
|
|||
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
|
||||
|
||||
|
|
@ -598,8 +601,8 @@ class OAuthClientProvider(httpx2.Auth):
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from pydantic import AnyUrl, ValidationError
|
|||
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,
|
||||
|
|
@ -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:
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ from httpx2 import SSEError
|
|||
|
||||
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__)
|
||||
|
|
@ -47,7 +52,14 @@ 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 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.
|
||||
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(
|
||||
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")
|
||||
|
||||
|
|
@ -121,13 +133,11 @@ async def sse_client(
|
|||
|
||||
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()
|
||||
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.shared._compat import resync_tracer
|
||||
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
from mcp.shared._httpx_utils import (
|
||||
create_mcp_http_client,
|
||||
redirect_location,
|
||||
request_within_origin,
|
||||
sse_within_origin,
|
||||
stream_within_origin,
|
||||
)
|
||||
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
||||
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
|
||||
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
||||
|
|
@ -62,6 +68,21 @@ class ResumptionError(StreamableHTTPError):
|
|||
"""Raised when resumption request is invalid."""
|
||||
|
||||
|
||||
def _unfollowed_redirect(response: httpx2.Response) -> str | None:
|
||||
"""Describe a redirect `stream_within_origin` left unfollowed, or None if `response` is not one."""
|
||||
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
|
||||
class RequestContext:
|
||||
"""Context for a request operation."""
|
||||
|
|
@ -210,7 +231,11 @@ class StreamableHTTPTransport:
|
|||
if last_event_id:
|
||||
headers[LAST_EVENT_ID] = last_event_id
|
||||
|
||||
async with client.sse(self.url, headers=headers) as event_source:
|
||||
async with sse_within_origin(client, self.url, headers=headers) as event_source:
|
||||
if (redirect := _unfollowed_redirect(event_source.response)) is not None:
|
||||
# The same GET would be redirected again, so retrying cannot help.
|
||||
logger.warning(f"GET stream not opened: {redirect}")
|
||||
return
|
||||
event_source.response.raise_for_status()
|
||||
logger.debug("GET SSE connection established")
|
||||
|
||||
|
|
@ -253,7 +278,14 @@ class StreamableHTTPTransport:
|
|||
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
|
||||
original_request_id = ctx.session_message.message.id
|
||||
|
||||
async with ctx.client.sse(self.url, headers=headers) as event_source:
|
||||
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
|
||||
if (redirect := _unfollowed_redirect(event_source.response)) is not None:
|
||||
logger.warning(redirect)
|
||||
assert original_request_id is not None
|
||||
await self._resolve_abandoned_request(
|
||||
ctx.read_stream_writer, original_request_id, redirect, code=INVALID_REQUEST
|
||||
)
|
||||
return
|
||||
event_source.response.raise_for_status()
|
||||
logger.debug("Resumption GET SSE connection established")
|
||||
|
||||
|
|
@ -320,7 +352,8 @@ class StreamableHTTPTransport:
|
|||
if ctx.metadata is not None and ctx.metadata.headers is not None:
|
||||
headers.update(ctx.metadata.headers)
|
||||
|
||||
async with ctx.client.stream(
|
||||
async with stream_within_origin(
|
||||
ctx.client,
|
||||
"POST",
|
||||
self.url,
|
||||
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
|
||||
|
|
@ -339,6 +372,14 @@ class StreamableHTTPTransport:
|
|||
)
|
||||
return
|
||||
|
||||
if (redirect := _unfollowed_redirect(response)) is not None:
|
||||
logger.warning(redirect)
|
||||
if isinstance(message, JSONRPCRequest):
|
||||
await self._resolve_abandoned_request(
|
||||
ctx.read_stream_writer, message.id, redirect, code=INVALID_REQUEST
|
||||
)
|
||||
return
|
||||
|
||||
if response.status_code >= 400:
|
||||
if isinstance(message, JSONRPCRequest):
|
||||
# A spec-correct server may return the JSON-RPC error in the
|
||||
|
|
@ -501,7 +542,7 @@ class StreamableHTTPTransport:
|
|||
headers[LAST_EVENT_ID] = last_event_id
|
||||
|
||||
try:
|
||||
async with ctx.client.sse(self.url, headers=headers) as event_source:
|
||||
async with sse_within_origin(ctx.client, self.url, headers=headers) as event_source:
|
||||
event_source.response.raise_for_status()
|
||||
logger.info("Reconnected to SSE stream")
|
||||
|
||||
|
|
@ -626,7 +667,7 @@ class StreamableHTTPTransport:
|
|||
|
||||
try:
|
||||
headers = self._prepare_headers()
|
||||
response = await client.delete(self.url, headers=headers)
|
||||
response = await request_within_origin(client, "DELETE", self.url, headers=headers)
|
||||
|
||||
if response.status_code == 405:
|
||||
logger.debug("Server does not allow session termination")
|
||||
|
|
@ -650,6 +691,13 @@ async def streamable_http_client(
|
|||
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
|
||||
client with recommended MCP timeouts will be created. To configure headers,
|
||||
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
|
||||
Whichever client is used, MCP requests follow a redirect only when it stays on the
|
||||
endpoint's origin (same scheme, host and port, or http to https on the same host with
|
||||
default ports) and keeps the request method (307/308 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.
|
||||
|
||||
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
|
||||
|
||||
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_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
|
||||
def __call__( # pragma: no branch
|
||||
|
|
@ -25,9 +34,12 @@ def create_mcp_http_client(
|
|||
timeout: httpx2.Timeout | None = None,
|
||||
auth: httpx2.Auth | None = None,
|
||||
) -> 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:
|
||||
headers: Optional headers to include with all requests.
|
||||
|
|
@ -36,60 +48,169 @@ def create_mcp_http_client(
|
|||
auth: Optional authentication handler.
|
||||
|
||||
Returns:
|
||||
Configured httpx2.AsyncClient instance with MCP defaults.
|
||||
Configured httpx2.AsyncClient instance.
|
||||
|
||||
Note:
|
||||
The returned AsyncClient must be used as a context manager to ensure
|
||||
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:
|
||||
kwargs["timeout"] = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
|
||||
else:
|
||||
kwargs["timeout"] = timeout
|
||||
|
||||
# Handle headers
|
||||
timeout = httpx2.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
|
||||
kwargs: dict[str, Any] = {"timeout": timeout}
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
|
||||
# Handle authentication
|
||||
if auth is not None: # pragma: no cover
|
||||
kwargs["auth"] = auth
|
||||
|
||||
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 json
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest import mock
|
||||
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_scope_from_www_auth,
|
||||
get_client_metadata_scopes,
|
||||
handle_auth_metadata_response,
|
||||
handle_registration_response,
|
||||
is_valid_client_metadata_url,
|
||||
should_use_client_metadata_url,
|
||||
|
|
@ -824,6 +826,97 @@ class TestProtectedResourceMetadata:
|
|||
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(
|
||||
("protocol_version", "expected"),
|
||||
[
|
||||
|
|
@ -977,35 +1070,33 @@ class TestRegistrationResponse:
|
|||
|
||||
@pytest.mark.anyio
|
||||
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
|
||||
class MockResponse(httpx2.Response):
|
||||
def __init__(self):
|
||||
self.status_code = 400
|
||||
self._aread_called = False
|
||||
self._text = "Registration failed with error"
|
||||
with pytest.raises(OAuthRegistrationError) as exc_info:
|
||||
await handle_registration_response(response)
|
||||
|
||||
async def aread(self):
|
||||
self._aread_called = True
|
||||
return b"test content"
|
||||
assert str(exc_info.value) == snapshot("Registration failed: 400 Registration failed with error")
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
if not self._aread_called:
|
||||
raise RuntimeError("Response.text accessed before response.aread()") # pragma: no cover
|
||||
return self._text
|
||||
@pytest.mark.anyio
|
||||
async def test_registration_error_names_an_unfollowed_redirect(self):
|
||||
"""SDK-defined: when the registration endpoint answered with a redirect that was not followed,
|
||||
the error says where it pointed (without userinfo or query) instead of only the bare status."""
|
||||
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
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
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)
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"Registration failed: 307 (redirected to https://elsewhere.example/register; not followed) "
|
||||
)
|
||||
|
||||
|
||||
@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