From a6f301cf74982075214c5a9044214b01ef19980e Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Sat, 30 May 2026 02:03:38 -0400 Subject: [PATCH] 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. --- docs/troubleshooting.md | 2 +- docs_src/troubleshooting/tutorial004.py | 2 +- src/mcp/client/streamable_http.py | 21 +++++++++++++++++++++ tests/shared/test_streamable_http.py | 20 ++++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 621b32c6c..c635744a0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -202,7 +202,7 @@ Invalid Host header 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: * 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. -* 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. +* 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. **[Deploy & scale](run/deploy.md)** has the full treatment, including when switching the check off is the honest configuration. diff --git a/docs_src/troubleshooting/tutorial004.py b/docs_src/troubleshooting/tutorial004.py index b78fa1d94..66038fc53 100644 --- a/docs_src/troubleshooting/tutorial004.py +++ b/docs_src/troubleshooting/tutorial004.py @@ -13,6 +13,6 @@ def forecast(city: str) -> str: app = mcp.streamable_http_app( transport_security=TransportSecuritySettings( allowed_hosts=["mcp.example.com", "mcp.example.com:*"], - allowed_origins=["https://app.example.com"], + allowed_origins=["http://mcp.example.com", "http://mcp.example.com:*"], ) ) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 09e5048cc..062545eef 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass +from urllib.parse import urlsplit import anyio import httpx @@ -108,6 +109,19 @@ def __init__(self, url: str) -> None: # `notifications/cancelled` at 2026 can abort it; see # `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1). self._in_flight_posts: dict[RequestId, _InFlightPost] = {} + self._default_origin = self._derive_origin(url) + + @staticmethod + def _derive_origin(url: str) -> str | None: + """Derive a same-origin ``Origin`` value (scheme://host[:port]) from a URL. + + Returns ``None`` when the URL has no scheme or host, in which case no + ``Origin`` header is added. + """ + parsed = urlsplit(url) + if not parsed.scheme or not parsed.netloc: + return None + return f"{parsed.scheme}://{parsed.netloc}" def _prepare_headers(self) -> dict[str, str]: """Build MCP-specific request headers for any outbound HTTP request. @@ -123,6 +137,13 @@ def _prepare_headers(self) -> dict[str, str]: "accept": "application/json, text/event-stream", "content-type": "application/json", } + # Send a same-origin Origin header by default so spec-compliant servers + # that enforce anti-DNS-rebinding / CSRF protection (e.g. the Go SDK's + # http.CrossOriginProtection) accept the handshake instead of returning + # 403. Callers needing a different Origin can set one on the underlying + # httpx client's default headers. + if self._default_origin is not None: + headers["origin"] = self._default_origin if self.session_id: headers[MCP_SESSION_ID] = self.session_id if self._protocol_version_header: diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index cbce222ec..d336df3e7 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1608,6 +1608,26 @@ async def bad_client(): assert tools.tools +def test_prepare_headers_includes_same_origin(): + """Default Origin header is derived from the target URL (scheme://host[:port]). + + Regression test for #2727: spec-compliant servers enforcing + anti-DNS-rebinding / CSRF protection reject requests with no Origin. + """ + transport = StreamableHTTPTransport(url="http://my-go-server:8081/mcp") + headers = transport._prepare_headers() + assert headers["origin"] == "http://my-go-server:8081" + + https_transport = StreamableHTTPTransport(url="https://example.com/mcp/path?x=1") + assert https_transport._prepare_headers()["origin"] == "https://example.com" + + +def test_prepare_headers_omits_origin_for_invalid_url(): + """No Origin header is added when the URL lacks a scheme or host.""" + transport = StreamableHTTPTransport(url="not-a-url") + assert "origin" not in transport._prepare_headers() + + @pytest.mark.anyio async def test_handle_sse_event_skips_empty_data() -> None: """_handle_sse_event skips empty SSE data (keep-alive pings) without writing to the stream."""