Follow redirects only within the MCP endpoint's origin (#3397)

This commit is contained in:
Max 2026-09-04 23:43:38 +01:00 • committed by GitHub
commit c6762e821c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1029 additions and 205 deletions

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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}")

View file

@ -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:

View file

@ -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()