Skip to content

Commit a6f301c

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 53117cb commit a6f301c

4 files changed

Lines changed: 43 additions & 2 deletions

File tree

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Invalid Host header
202202
The fix is the same `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` shown under `Server returned an error response`. Two of its edges are worth naming:
203203

204204
* An `allowed_hosts` entry is an exact string. `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any explicit port. List both.
205-
* A `403` with the body `Invalid Origin header` is the sibling check on the `Origin` header. It only fires for browsers (nothing else sends `Origin`), and `allowed_origins=` is its allowlist.
205+
* A `403` with the body `Invalid Origin header` is the sibling check on the `Origin` header, and `allowed_origins=` is its allowlist. Browsers send `Origin`, and so does the python `Client`: it stamps a same-origin value (`scheme://host[:port]`) derived from the URL you connect to, so that spec-compliant servers enforcing CSRF / DNS-rebinding protection accept the handshake. That is why the allowlist above names `http://mcp.example.com` alongside the host — the client's own `Origin` has to be on it.
206206

207207
**[Deploy & scale](run/deploy.md)** has the full treatment, including when switching the check off is the honest configuration.
208208

docs_src/troubleshooting/tutorial004.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ def forecast(city: str) -> str:
1313
app = mcp.streamable_http_app(
1414
transport_security=TransportSecuritySettings(
1515
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
16-
allowed_origins=["https://app.example.com"],
16+
allowed_origins=["http://mcp.example.com", "http://mcp.example.com:*"],
1717
)
1818
)

src/mcp/client/streamable_http.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections.abc import AsyncGenerator, Awaitable, Callable
88
from contextlib import asynccontextmanager
99
from dataclasses import dataclass
10+
from urllib.parse import urlsplit
1011

1112
import anyio
1213
import httpx
@@ -108,6 +109,19 @@ def __init__(self, url: str) -> None:
108109
# `notifications/cancelled` at 2026 can abort it; see
109110
# `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1).
110111
self._in_flight_posts: dict[RequestId, _InFlightPost] = {}
112+
self._default_origin = self._derive_origin(url)
113+
114+
@staticmethod
115+
def _derive_origin(url: str) -> str | None:
116+
"""Derive a same-origin ``Origin`` value (scheme://host[:port]) from a URL.
117+
118+
Returns ``None`` when the URL has no scheme or host, in which case no
119+
``Origin`` header is added.
120+
"""
121+
parsed = urlsplit(url)
122+
if not parsed.scheme or not parsed.netloc:
123+
return None
124+
return f"{parsed.scheme}://{parsed.netloc}"
111125

112126
def _prepare_headers(self) -> dict[str, str]:
113127
"""Build MCP-specific request headers for any outbound HTTP request.
@@ -123,6 +137,13 @@ def _prepare_headers(self) -> dict[str, str]:
123137
"accept": "application/json, text/event-stream",
124138
"content-type": "application/json",
125139
}
140+
# Send a same-origin Origin header by default so spec-compliant servers
141+
# that enforce anti-DNS-rebinding / CSRF protection (e.g. the Go SDK's
142+
# http.CrossOriginProtection) accept the handshake instead of returning
143+
# 403. Callers needing a different Origin can set one on the underlying
144+
# httpx client's default headers.
145+
if self._default_origin is not None:
146+
headers["origin"] = self._default_origin
126147
if self.session_id:
127148
headers[MCP_SESSION_ID] = self.session_id
128149
if self._protocol_version_header:

tests/shared/test_streamable_http.py

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

16101610

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

0 commit comments

Comments
 (0)