Follow redirects only within the MCP endpoint's origin (#3397)
This commit is contained in:
parent
5fd3abc548
commit
c6762e821c
6 changed files with 1029 additions and 205 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue