Skip to content

Commit 92a1e3c

Browse files
committed
feat(client): send a same-origin Origin header by default (streamable HTTP)
The streamable HTTP client sends no Origin header. Browsers always send one on cross-origin-capable requests; emitting a correct same-origin value matches that behavior and satisfies servers that gate state-changing requests on a present, same-origin Origin (defense-in-depth against DNS-rebinding / CSRF), without weakening any server's posture. The value is derived from httpx.URL so it uses the exact scheme/host/port normalization httpx applies to the Host header (default ports dropped, IPv6 hosts bracketed, userinfo stripped). Origin and Host therefore stay byte-for-byte consistent even for inputs like https://host:443/mcp, where naive string parsing keeps a redundant :443 that would not match the Host httpx sends. A caller-provided Origin always wins, and the caller's httpx client headers are never mutated. Refs #2727
1 parent 19fe9fa commit 92a1e3c

2 files changed

Lines changed: 94 additions & 4 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,27 @@
5050
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
5151

5252

53+
def _get_default_origin(url: str) -> str | None:
54+
"""Derive a same-origin ``Origin`` value for *url*.
55+
56+
Browsers always send an ``Origin`` on cross-origin-capable requests; a server-to-server
57+
client sends none. Emitting a correct same-origin value matches browser behavior and
58+
satisfies servers that gate state-changing requests on a present, same-origin ``Origin``
59+
(defense-in-depth against DNS-rebinding/CSRF), without weakening any server's posture.
60+
61+
The value is built from ``httpx.URL`` so it uses the exact scheme/host/port normalization
62+
httpx applies to the ``Host`` header (default ports dropped, IPv6 hosts bracketed, userinfo
63+
stripped). That keeps ``Origin`` and ``Host`` byte-for-byte consistent even for inputs like
64+
``https://host:443/mcp``, where naive parsing keeps a redundant ``:443`` that would *not*
65+
match the ``Host`` httpx sends. Returns ``None`` for non-HTTP(S) URLs or URLs without an
66+
authority, where no meaningful web origin exists.
67+
"""
68+
parsed = httpx.URL(url)
69+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
70+
return None
71+
return f"{parsed.scheme}://{parsed.netloc.decode('ascii')}"
72+
73+
5374
class StreamableHTTPError(Exception):
5475
"""Base exception for StreamableHTTP transport errors."""
5576

@@ -72,13 +93,16 @@ class RequestContext:
7293
class StreamableHTTPTransport:
7394
"""StreamableHTTP client transport implementation."""
7495

75-
def __init__(self, url: str) -> None:
96+
def __init__(self, url: str, default_origin: str | None = None) -> None:
7697
"""Initialize the StreamableHTTP transport.
7798
7899
Args:
79100
url: The endpoint URL.
101+
default_origin: ``Origin`` header to send when the caller has not configured one
102+
on the HTTP client. See ``_get_default_origin``.
80103
"""
81104
self.url = url
105+
self.default_origin = default_origin
82106
self.session_id: str | None = None
83107
self.protocol_version: str | None = None
84108

@@ -92,6 +116,9 @@ def _prepare_headers(self) -> dict[str, str]:
92116
"accept": "application/json, text/event-stream",
93117
"content-type": "application/json",
94118
}
119+
# Same-origin Origin for servers that gate on it; only when the caller set none.
120+
if self.default_origin:
121+
headers["origin"] = self.default_origin
95122
# Add session headers if available
96123
if self.session_id:
97124
headers[MCP_SESSION_ID] = self.session_id
@@ -547,7 +574,10 @@ async def streamable_http_client(
547574
# Create default client with recommended MCP timeouts
548575
client = create_mcp_http_client()
549576

550-
transport = StreamableHTTPTransport(url)
577+
# Only supply a default Origin when the caller hasn't set one, so an explicit Origin
578+
# (e.g. a multi-tenant proxy's) always wins. The client's own headers are left untouched.
579+
default_origin = None if "origin" in client.headers else _get_default_origin(url)
580+
transport = StreamableHTTPTransport(url, default_origin=default_origin)
551581

552582
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")
553583

tests/shared/test_streamable_http.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@
2121
from httpx_sse import ServerSentEvent
2222
from starlette.applications import Starlette
2323
from starlette.requests import Request
24-
from starlette.routing import Mount
24+
from starlette.responses import Response
25+
from starlette.routing import Mount, Route
2526

2627
from mcp import MCPError, types
2728
from mcp.client.session import ClientSession
28-
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
29+
from mcp.client.streamable_http import StreamableHTTPTransport, _get_default_origin, streamable_http_client
2930
from mcp.server import Server, ServerRequestContext
3031
from mcp.server.streamable_http import (
3132
MCP_PROTOCOL_VERSION_HEADER,
@@ -355,6 +356,65 @@ def make_client(app: Starlette, headers: dict[str, str] | None = None) -> httpx.
355356
)
356357

357358

359+
def test_get_default_origin_normalizes_authority() -> None:
360+
"""The default Origin matches the Host header httpx emits for the same URL."""
361+
# Default ports are dropped, so Origin "https://h:443" can't mismatch the Host "h".
362+
assert _get_default_origin("https://example.com:443/mcp?token=abc") == "https://example.com"
363+
assert _get_default_origin("http://example.com:80/mcp") == "http://example.com"
364+
# Non-default ports kept; IPv6 hosts bracketed; userinfo stripped.
365+
assert _get_default_origin("https://example.com:8443/mcp") == "https://example.com:8443"
366+
assert _get_default_origin("http://user:pass@[::1]:8080/mcp") == "http://[::1]:8080"
367+
368+
369+
def test_get_default_origin_returns_none_without_web_origin() -> None:
370+
"""URLs with no meaningful web origin yield no Origin header."""
371+
assert _get_default_origin("ws://example.com/mcp") is None # non-HTTP scheme
372+
assert _get_default_origin("http:///mcp") is None # no authority
373+
374+
375+
def _make_origin_recording_app(seen: anyio.Event, recorded: dict[str, str | None]) -> Starlette:
376+
async def mcp_endpoint(request: Request) -> Response:
377+
recorded["origin"] = request.headers.get("origin")
378+
recorded["host"] = request.headers.get("host")
379+
seen.set()
380+
return Response(status_code=202)
381+
382+
return Starlette(routes=[Route("/mcp", endpoint=mcp_endpoint, methods=["POST"])])
383+
384+
385+
@pytest.mark.anyio
386+
async def test_streamable_http_client_sends_same_origin_by_default() -> None:
387+
"""The client sends a same-origin Origin derived from the URL, matching the Host it emits."""
388+
seen = anyio.Event()
389+
recorded: dict[str, str | None] = {}
390+
async with make_client(_make_origin_recording_app(seen, recorded)) as client:
391+
async with streamable_http_client(f"{BASE_URL}/mcp", http_client=client) as (_read_stream, write_stream):
392+
await write_stream.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
393+
with anyio.fail_after(5):
394+
await seen.wait()
395+
396+
assert recorded["origin"] == BASE_URL
397+
assert recorded["origin"] is not None
398+
assert recorded["origin"].split("://", 1)[1] == recorded["host"] # Origin host == Host header
399+
assert "origin" not in client.headers # caller's client is left untouched
400+
401+
402+
@pytest.mark.anyio
403+
async def test_streamable_http_client_preserves_custom_origin() -> None:
404+
"""A caller-configured Origin always wins over the derived default."""
405+
seen = anyio.Event()
406+
recorded: dict[str, str | None] = {}
407+
app = _make_origin_recording_app(seen, recorded)
408+
async with make_client(app, headers={"Origin": "https://proxy.example"}) as client:
409+
async with streamable_http_client(f"{BASE_URL}/mcp", http_client=client) as (_read_stream, write_stream):
410+
await write_stream.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
411+
with anyio.fail_after(5):
412+
await seen.wait()
413+
414+
assert recorded["origin"] == "https://proxy.example"
415+
assert client.headers["origin"] == "https://proxy.example"
416+
417+
358418
# Test fixtures
359419
@pytest.fixture
360420
async def basic_app() -> AsyncIterator[Starlette]:

0 commit comments

Comments
 (0)