Skip to content

feat(client): send a same-origin Origin header by default (streamable HTTP)#2782

Open
fede-kamel wants to merge 1 commit into
modelcontextprotocol:mainfrom
fede-kamel:feat/streamable-default-origin
Open

feat(client): send a same-origin Origin header by default (streamable HTTP)#2782
fede-kamel wants to merge 1 commit into
modelcontextprotocol:mainfrom
fede-kamel:feat/streamable-default-origin

Conversation

@fede-kamel
Copy link
Copy Markdown

Summary

The streamable HTTP client opens its handshake with no Origin header. This PR makes it send a same-origin Origin derived from the target URL by default, matching browser behavior and satisfying servers that gate state-changing requests on a present, same-origin Origin (defense-in-depth against DNS-rebinding / CSRF) — without weakening any server's posture.

Refs #2727.

Honest scoping (please read)

While preparing this I verified the motivating claim in #2727 — that go-sdk's http.CrossOriginProtection rejects the Python client with 403 because it sends no Origin. It does not. I built a Go 1.25 server with the real http.NewCrossOriginProtection() (the exact type go-sdk calls) and tested it:

Request (POST) Result
no Origin (client today) 200 — accepted
Origin mismatching Host 403
Origin matching Host (this PR) 200

The stdlib Check() has if origin == "" { return nil } — it fails open for non-browser clients. So this change is not a fix for that 403; I don't want to oversell it. It's worthwhile as browser-parity + defense-in-depth, and for interop with any server that does require a present same-origin Origin. If maintainers feel the client should keep sending nothing, that's a reasonable call and I'll close this.

Why derive from httpx.URL (the one real correctness point)

parsed = httpx.URL(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
    return None
return f"{parsed.scheme}://{parsed.netloc.decode('ascii')}"

The server compares the Origin host against the Host header. Building the origin from httpx.URL reuses the exact normalization httpx applies to Host (default ports dropped, IPv6 bracketed, userinfo stripped), so the two always agree. This matters for an explicit default port:

URL Host httpx sends naive urlsplit origin matches? httpx.URL origin (this PR) matches?
https://h:443/mcp h https://h:443 ❌ would 403 https://h

So a careless derivation can turn a working 200 into a 403; this one can't.

Behavior

  • A caller-provided Origin (on their httpx.AsyncClient) always wins; the default only fills the gap.
  • The caller's client headers are never mutated.
  • Non-HTTP(S) / authority-less URLs add no header.

Tests & checks

  • test_get_default_origin_normalizes_authority / ..._returns_none_without_web_origin — derivation incl. default-port drop, IPv6, userinfo, non-HTTP.
  • test_streamable_http_client_sends_same_origin_by_default — in-process (Run StreamableHTTP transport tests in process instead of over sockets #2767 harness); asserts the sent Origin equals the Host and the caller's client is untouched.
  • test_streamable_http_client_preserves_custom_origin — caller Origin wins.
  • ./scripts/test: full suite green, 100.00% branch coverage, strict-no-cover clean; ruff + pyright clean.

Relationship to existing PRs

#2729, #2731, #2752, #2759 already propose an Origin header for #2727. This one differs by deriving from httpx.URL so the Origin/Host match holds for explicit default ports (the cases the urlsplit-based versions get wrong). Happy to consolidate into whichever the maintainers prefer rather than add a fifth — flagging so this isn't duplicate noise.

… HTTP)

The streamable HTTP client sends no Origin header. Browsers always send one
on cross-origin-capable requests; emitting a correct same-origin value matches
that behavior and satisfies servers that gate state-changing requests on a
present, same-origin Origin (defense-in-depth against DNS-rebinding / CSRF),
without weakening any server's posture.

The value is derived from httpx.URL so it uses the exact scheme/host/port
normalization httpx applies to the Host header (default ports dropped, IPv6
hosts bracketed, userinfo stripped). Origin and Host therefore stay
byte-for-byte consistent even for inputs like https://host:443/mcp, where
naive string parsing keeps a redundant :443 that would not match the Host
httpx sends. A caller-provided Origin always wins, and the caller's httpx
client headers are never mutated.

Refs modelcontextprotocol#2727
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant