Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs_src/troubleshooting/tutorial004.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"],
)
)
21 changes: 21 additions & 0 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading