Skip to content

Commit 5a585eb

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 603342f commit 5a585eb

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
@@ -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
@@ -86,6 +87,19 @@ def __init__(self, url: str) -> None:
8687
# response/error/cancel POSTs that bypass the session's stamp). Cleared when an
8788
# `initialize` POST goes out so a probe-stamped value cannot leak onto the handshake.
8889
self._protocol_version_header: str | None = None
90+
self._default_origin = self._derive_origin(url)
91+
92+
@staticmethod
93+
def _derive_origin(url: str) -> str | None:
94+
"""Derive a same-origin ``Origin`` value (scheme://host[:port]) from a URL.
95+
96+
Returns ``None`` when the URL has no scheme or host, in which case no
97+
``Origin`` header is added.
98+
"""
99+
parsed = urlsplit(url)
100+
if not parsed.scheme or not parsed.netloc:
101+
return None
102+
return f"{parsed.scheme}://{parsed.netloc}"
89103

90104
def _prepare_headers(self) -> dict[str, str]:
91105
"""Build MCP-specific request headers for any outbound HTTP request.
@@ -101,6 +115,13 @@ def _prepare_headers(self) -> dict[str, str]:
101115
"accept": "application/json, text/event-stream",
102116
"content-type": "application/json",
103117
}
118+
# Send a same-origin Origin header by default so spec-compliant servers
119+
# that enforce anti-DNS-rebinding / CSRF protection (e.g. the Go SDK's
120+
# http.CrossOriginProtection) accept the handshake instead of returning
121+
# 403. Callers needing a different Origin can set one on the underlying
122+
# httpx client's default headers.
123+
if self._default_origin is not None:
124+
headers["origin"] = self._default_origin
104125
if self.session_id:
105126
headers[MCP_SESSION_ID] = self.session_id
106127
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)