Skip to content

Commit 0b9a431

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 4472428 commit 0b9a431

2 files changed

Lines changed: 96 additions & 4 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ def _encode_header_value(value: str) -> str:
6666
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
6767

6868

69+
def _get_default_origin(url: str) -> str | None:
70+
"""Derive a same-origin ``Origin`` value for *url*.
71+
72+
Browsers always send an ``Origin`` on cross-origin-capable requests; a server-to-server
73+
client sends none. Emitting a correct same-origin value matches browser behavior and
74+
satisfies servers that gate state-changing requests on a present, same-origin ``Origin``
75+
(defense-in-depth against DNS-rebinding/CSRF), without weakening any server's posture.
76+
77+
The value is built from ``httpx.URL`` so it uses the exact scheme/host/port normalization
78+
httpx applies to the ``Host`` header (default ports dropped, IPv6 hosts bracketed, userinfo
79+
stripped). That keeps ``Origin`` and ``Host`` byte-for-byte consistent even for inputs like
80+
``https://host:443/mcp``, where naive parsing keeps a redundant ``:443`` that would *not*
81+
match the ``Host`` httpx sends. Returns ``None`` for non-HTTP(S) URLs or URLs without an
82+
authority, where no meaningful web origin exists.
83+
"""
84+
parsed = httpx.URL(url)
85+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
86+
return None
87+
return f"{parsed.scheme}://{parsed.netloc.decode('ascii')}"
88+
89+
6990
class StreamableHTTPError(Exception):
7091
"""Base exception for StreamableHTTP transport errors."""
7192

@@ -88,17 +109,22 @@ class RequestContext:
88109
class StreamableHTTPTransport:
89110
"""StreamableHTTP client transport implementation."""
90111

91-
def __init__(self, url: str, protocol_version: str | None = None) -> None:
112+
def __init__(
113+
self, url: str, default_origin: str | None = None, protocol_version: str | None = None
114+
) -> None:
92115
"""Initialize the StreamableHTTP transport.
93116
94117
Args:
95118
url: The endpoint URL.
119+
default_origin: ``Origin`` header to send when the caller has not configured one
120+
on the HTTP client. See ``_get_default_origin``.
96121
protocol_version: Pin the MCP-Protocol-Version header from the first request.
97122
Only honoured for stateless 2026-07-28+ sessions that never send
98123
initialize; for earlier (stateful) versions the header is populated
99124
from the negotiated InitializeResult, so a pre-2026 value is ignored.
100125
"""
101126
self.url = url
127+
self.default_origin = default_origin
102128
self.session_id: str | None = None
103129
self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None
104130

@@ -134,6 +160,9 @@ def _prepare_headers(self) -> dict[str, str]:
134160
"accept": "application/json, text/event-stream",
135161
"content-type": "application/json",
136162
}
163+
# Same-origin Origin for servers that gate on it; only when the caller set none.
164+
if self.default_origin:
165+
headers["origin"] = self.default_origin
137166
# Add session headers if available
138167
if self.session_id:
139168
headers[MCP_SESSION_ID] = self.session_id
@@ -597,7 +626,10 @@ async def streamable_http_client(
597626
# Create default client with recommended MCP timeouts
598627
client = create_mcp_http_client()
599628

600-
transport = StreamableHTTPTransport(url, protocol_version=protocol_version)
629+
# Only supply a default Origin when the caller hasn't set one, so an explicit Origin
630+
# (e.g. a multi-tenant proxy's) always wins. The client's own headers are left untouched.
631+
default_origin = None if "origin" in client.headers else _get_default_origin(url)
632+
transport = StreamableHTTPTransport(url, default_origin=default_origin, protocol_version=protocol_version)
601633

602634
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")
603635

tests/shared/test_streamable_http.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@
2222
from httpx_sse import ServerSentEvent
2323
from starlette.applications import Starlette
2424
from starlette.requests import Request
25-
from starlette.routing import Mount
25+
from starlette.responses import Response
26+
from starlette.routing import Mount, Route
2627
from starlette.types import Message, Scope
2728

2829
from mcp import MCPError, types
2930
from mcp.client import ClientRequestContext
3031
from mcp.client.session import ClientSession
31-
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
32+
from mcp.client.streamable_http import StreamableHTTPTransport, _get_default_origin, streamable_http_client
3233
from mcp.server import Server, ServerRequestContext
3334
from mcp.server.streamable_http import (
3435
GET_STREAM_KEY,
@@ -364,6 +365,65 @@ def make_client(app: Starlette, headers: dict[str, str] | None = None) -> httpx.
364365
)
365366

366367

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

0 commit comments

Comments
 (0)