Skip to content

Commit e45220d

Browse files
committed
fix(client): send same-origin Origin header from streamable HTTP client
Closes #2727 The streamable HTTP client opened its POST handshake without an Origin header, so spec-compliant servers that enforce anti-DNS-rebinding / CSRF protection (e.g. the Go SDK's http.CrossOriginProtection) reject the very first request with 403 Forbidden, and the client then hangs on the read stream. _prepare_headers now derives a same-origin value (scheme://host[:port]) from the target URL and sends it as the Origin header. URLs without a scheme or host add no header. Callers needing a different Origin can set one on the underlying httpx client's default headers.
1 parent 4472428 commit e45220d

2 files changed

Lines changed: 41 additions & 0 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from collections.abc import AsyncGenerator, Awaitable, Callable
1010
from contextlib import asynccontextmanager
1111
from dataclasses import dataclass
12+
from urllib.parse import urlsplit
1213

1314
import anyio
1415
import httpx
@@ -101,6 +102,19 @@ def __init__(self, url: str, protocol_version: str | None = None) -> None:
101102
self.url = url
102103
self.session_id: str | None = None
103104
self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None
105+
self._default_origin = self._derive_origin(url)
106+
107+
@staticmethod
108+
def _derive_origin(url: str) -> str | None:
109+
"""Derive a same-origin ``Origin`` value (scheme://host[:port]) from a URL.
110+
111+
Returns ``None`` when the URL has no scheme or host, in which case no
112+
``Origin`` header is added.
113+
"""
114+
parsed = urlsplit(url)
115+
if not parsed.scheme or not parsed.netloc:
116+
return None
117+
return f"{parsed.scheme}://{parsed.netloc}"
104118

105119
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
106120
"""Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports.
@@ -134,6 +148,13 @@ def _prepare_headers(self) -> dict[str, str]:
134148
"accept": "application/json, text/event-stream",
135149
"content-type": "application/json",
136150
}
151+
# Send a same-origin Origin header by default so spec-compliant servers
152+
# that enforce anti-DNS-rebinding / CSRF protection (e.g. the Go SDK's
153+
# http.CrossOriginProtection) accept the handshake instead of returning
154+
# 403. Callers needing a different Origin can set one on the underlying
155+
# httpx client's default headers.
156+
if self._default_origin is not None:
157+
headers["origin"] = self._default_origin
137158
# Add session headers if available
138159
if self.session_id:
139160
headers[MCP_SESSION_ID] = self.session_id

tests/shared/test_streamable_http.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,26 @@ async def bad_client():
16141614
assert tools.tools
16151615

16161616

1617+
def test_prepare_headers_includes_same_origin():
1618+
"""Default Origin header is derived from the target URL (scheme://host[:port]).
1619+
1620+
Regression test for #2727: spec-compliant servers enforcing
1621+
anti-DNS-rebinding / CSRF protection reject requests with no Origin.
1622+
"""
1623+
transport = StreamableHTTPTransport(url="http://my-go-server:8081/mcp")
1624+
headers = transport._prepare_headers()
1625+
assert headers["origin"] == "http://my-go-server:8081"
1626+
1627+
https_transport = StreamableHTTPTransport(url="https://example.com/mcp/path?x=1")
1628+
assert https_transport._prepare_headers()["origin"] == "https://example.com"
1629+
1630+
1631+
def test_prepare_headers_omits_origin_for_invalid_url():
1632+
"""No Origin header is added when the URL lacks a scheme or host."""
1633+
transport = StreamableHTTPTransport(url="not-a-url")
1634+
assert "origin" not in transport._prepare_headers()
1635+
1636+
16171637
@pytest.mark.anyio
16181638
async def test_handle_sse_event_skips_empty_data() -> None:
16191639
"""_handle_sse_event skips empty SSE data (keep-alive pings) without writing to the stream."""

0 commit comments

Comments
 (0)