Skip to content

Commit c9bf54b

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 616476f commit c9bf54b

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
@@ -81,6 +82,19 @@ def __init__(self, url: str) -> None:
8182
self.url = url
8283
self.session_id: str | None = None
8384
self.protocol_version: str | None = None
85+
self._default_origin = self._derive_origin(url)
86+
87+
@staticmethod
88+
def _derive_origin(url: str) -> str | None:
89+
"""Derive a same-origin ``Origin`` value (scheme://host[:port]) from a URL.
90+
91+
Returns ``None`` when the URL has no scheme or host, in which case no
92+
``Origin`` header is added.
93+
"""
94+
parsed = urlsplit(url)
95+
if not parsed.scheme or not parsed.netloc:
96+
return None
97+
return f"{parsed.scheme}://{parsed.netloc}"
8498

8599
def _prepare_headers(self) -> dict[str, str]:
86100
"""Build MCP-specific request headers.
@@ -92,6 +106,13 @@ def _prepare_headers(self) -> dict[str, str]:
92106
"accept": "application/json, text/event-stream",
93107
"content-type": "application/json",
94108
}
109+
# Send a same-origin Origin header by default so spec-compliant servers
110+
# that enforce anti-DNS-rebinding / CSRF protection (e.g. the Go SDK's
111+
# http.CrossOriginProtection) accept the handshake instead of returning
112+
# 403. Callers needing a different Origin can set one on the underlying
113+
# httpx client's default headers.
114+
if self._default_origin is not None:
115+
headers["origin"] = self._default_origin
95116
# Add session headers if available
96117
if self.session_id:
97118
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
@@ -1776,6 +1776,26 @@ async def bad_client():
17761776
assert tools.tools
17771777

17781778

1779+
def test_prepare_headers_includes_same_origin():
1780+
"""Default Origin header is derived from the target URL (scheme://host[:port]).
1781+
1782+
Regression test for #2727: spec-compliant servers enforcing
1783+
anti-DNS-rebinding / CSRF protection reject requests with no Origin.
1784+
"""
1785+
transport = StreamableHTTPTransport(url="http://my-go-server:8081/mcp")
1786+
headers = transport._prepare_headers()
1787+
assert headers["origin"] == "http://my-go-server:8081"
1788+
1789+
https_transport = StreamableHTTPTransport(url="https://example.com/mcp/path?x=1")
1790+
assert https_transport._prepare_headers()["origin"] == "https://example.com"
1791+
1792+
1793+
def test_prepare_headers_omits_origin_for_invalid_url():
1794+
"""No Origin header is added when the URL lacks a scheme or host."""
1795+
transport = StreamableHTTPTransport(url="not-a-url")
1796+
assert "origin" not in transport._prepare_headers()
1797+
1798+
17791799
@pytest.mark.anyio
17801800
async def test_handle_sse_event_skips_empty_data():
17811801
"""Test that _handle_sse_event skips empty SSE data (keep-alive pings)."""

0 commit comments

Comments
 (0)