Files
modelcontextprotocol--pytho…/tests/shared/test_httpx_utils.py
Max Isbey b8a107d16c Scope HTTP client redirect following to the request's origin
create_mcp_http_client followed every redirect, so everything configured
on a client (headers, auth, request bodies) was re-sent to whatever host
a Location header named. Clients built by the factory now follow
redirects within the same origin (scheme, host, and port), plus
http-to-https upgrades of the same host on default ports, and raise the
new RedirectError for anything else - before the next request is sent.

- transports resolve a refused redirect in-band: requests get a JSON-RPC
  error naming the target and the remedy, notifications are delivered to
  the session's message handler; the standalone GET stream stops
  retrying an endpoint that keeps redirecting
- caller-supplied clients that follow no redirects get the same clear
  error on POST, GET stream, and SSE connect instead of an opaque
  content-type error
- OAuth discovery, registration, token, refresh, and the
  identity-assertion token exchange fail loudly on redirect responses
  instead of silently trying the next URL or abandoning the discovery
  chain
- RedirectError and create_mcp_http_client are exported from the
  top-level mcp package; migration.md documents the behavior change;
  docs and examples configure clients through the factory, and the
  general-purpose fetch example uses a browser-like client of its own
2026-07-07 19:41:45 +00:00

232 lines
8.0 KiB
Python

"""Tests for httpx utility functions."""
import httpx
import pytest
from mcp.shared._httpx_utils import RedirectError, _resolve_redirect_target, create_mcp_http_client, redirect_error
@pytest.fixture(autouse=True)
def _no_proxy_env(no_proxy_env: None) -> None:
"""Every test here swaps a mock transport into a factory-built client."""
def test_default_settings():
"""Test that default settings are applied correctly."""
client = create_mcp_http_client()
assert client.follow_redirects is True
assert client.timeout.connect == 30.0
def test_custom_parameters():
"""Test custom headers and timeout are set correctly."""
headers = {"Authorization": "Bearer token"}
timeout = httpx.Timeout(60.0)
client = create_mcp_http_client(headers, timeout)
assert client.headers["Authorization"] == "Bearer token"
assert client.timeout.connect == 60.0
def _redirecting_transport(location: str, requests: list[httpx.Request]) -> httpx.MockTransport:
"""First request gets a 307 to `location`; every request is recorded."""
def handle(request: httpx.Request) -> httpx.Response:
requests.append(request)
if len(requests) == 1:
return httpx.Response(307, headers={"location": location})
return httpx.Response(200, json={"ok": True})
return httpx.MockTransport(handle)
@pytest.mark.anyio
async def test_follows_same_origin_redirect():
requests: list[httpx.Request] = []
transport = _redirecting_transport("/canonical", requests)
client = create_mcp_http_client(headers={"X-Custom": "value"})
client._transport = transport # swap in the mock transport
response = await client.post("http://example.com/endpoint", content=b"payload")
assert response.status_code == 200
assert len(requests) == 2
assert requests[1].url == httpx.URL("http://example.com/canonical")
assert requests[1].method == "POST"
assert requests[1].headers["X-Custom"] == "value"
assert requests[1].content == b"payload"
await client.aclose()
@pytest.mark.anyio
async def test_follows_https_upgrade_redirect():
requests: list[httpx.Request] = []
transport = _redirecting_transport("https://example.com/endpoint", requests)
client = create_mcp_http_client()
client._transport = transport
response = await client.get("http://example.com/endpoint")
assert response.status_code == 200
assert len(requests) == 2
assert requests[1].url == httpx.URL("https://example.com/endpoint")
await client.aclose()
@pytest.mark.anyio
async def test_rejects_redirect_to_other_host():
requests: list[httpx.Request] = []
transport = _redirecting_transport("https://other.example.com/collect", requests)
client = create_mcp_http_client(headers={"X-Custom": "value"})
client._transport = transport
with pytest.raises(RedirectError) as excinfo:
await client.post("https://example.com/endpoint", content=b"payload")
# The redirect was not followed: exactly one request went out.
assert len(requests) == 1
assert "https://other.example.com/collect" in str(excinfo.value)
assert "different origin" in str(excinfo.value)
await client.aclose()
@pytest.mark.anyio
async def test_rejects_redirect_to_other_port():
requests: list[httpx.Request] = []
transport = _redirecting_transport("http://example.com:9000/endpoint", requests)
client = create_mcp_http_client()
client._transport = transport
with pytest.raises(RedirectError):
await client.get("http://example.com:8000/endpoint")
assert len(requests) == 1
await client.aclose()
@pytest.mark.anyio
async def test_rejects_https_to_http_redirect():
requests: list[httpx.Request] = []
transport = _redirecting_transport("http://example.com/endpoint", requests)
client = create_mcp_http_client()
client._transport = transport
with pytest.raises(RedirectError):
await client.get("https://example.com/endpoint")
assert len(requests) == 1
await client.aclose()
@pytest.mark.anyio
async def test_rejects_absolute_form_location_without_host():
# An "absolute" Location with no host keeps the request's host but not its
# port (matching httpx's own resolution), so from a non-default port this
# resolves to a different origin and is refused.
requests: list[httpx.Request] = []
transport = _redirecting_transport("http:///moved", requests)
client = create_mcp_http_client()
client._transport = transport
with pytest.raises(RedirectError) as excinfo:
await client.get("http://example.com:8000/endpoint")
assert len(requests) == 1
assert "http://example.com/moved" in str(excinfo.value)
await client.aclose()
@pytest.mark.anyio
async def test_unparsable_location_defers_to_httpx():
requests: list[httpx.Request] = []
transport = _redirecting_transport("http://\x00bad/", requests)
client = create_mcp_http_client()
client._transport = transport
with pytest.raises(httpx.RemoteProtocolError):
await client.get("https://example.com/endpoint")
await client.aclose()
@pytest.mark.anyio
async def test_user_supplied_client_still_follows_everything():
"""A caller's own follow_redirects=True client is not policed by the SDK."""
requests: list[httpx.Request] = []
transport = _redirecting_transport("https://other.example.com/moved", requests)
async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client:
response = await client.get("https://example.com/endpoint")
assert response.status_code == 200
assert len(requests) == 2
assert requests[1].url == httpx.URL("https://other.example.com/moved")
def test_redirect_error_builder_same_origin_names_target():
request = httpx.Request("POST", "http://example.com/endpoint")
response = httpx.Response(307, headers={"location": "/canonical"}, request=request)
error = redirect_error(response)
assert "http://example.com/canonical" in str(error)
assert "Connect to that URL directly" in str(error)
def test_redirect_error_builder_includes_context():
request = httpx.Request("POST", "https://auth.example.com/token")
response = httpx.Response(307, headers={"location": "https://other.example.com/token"}, request=request)
error = redirect_error(response, context="OAuth token request")
assert str(error).startswith("OAuth token request: ")
assert "different origin" in str(error)
def test_redirect_error_builder_unparsable_location():
request = httpx.Request("GET", "https://example.com/endpoint")
response = httpx.Response(307, headers={"location": "http://\x00bad/"}, request=request)
error = redirect_error(response)
assert "unparsable Location" in str(error)
def test_redirect_error_is_httpx_status_error():
"""Existing handlers that catch httpx.HTTPStatusError keep working."""
request = httpx.Request("GET", "https://example.com/endpoint")
response = httpx.Response(307, headers={"location": "https://other.example.com/"}, request=request)
assert isinstance(redirect_error(response), httpx.HTTPStatusError)
@pytest.mark.anyio
@pytest.mark.parametrize(
"location",
[
"/relative/path?q=1",
"relative-no-slash",
"http:///absolute-form-no-host",
"https://other.example.com/absolute",
"//protocol-relative.example.com/x",
"http://example.com:8000/same-origin",
],
)
async def test_redirect_target_resolution_matches_httpx(location: str) -> None:
"""The policy must judge exactly the URL httpx itself would follow.
Guards against drift between _resolve_redirect_target and httpx's own
redirect URL construction across httpx upgrades.
"""
def handle(request: httpx.Request) -> httpx.Response:
return httpx.Response(307, headers={"location": location})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
response = await client.get("http://example.com:8000/base/path")
assert response.next_request is not None
assert _resolve_redirect_target(response.request.url, location) == response.next_request.url