Title: feat(client): send a same-origin Origin header by default (streamable HTTP) by fede-kamel · Pull Request #2782 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: feat(client): send a same-origin Origin header by default (streamable HTTP) by fede-kamel · Pull Request #2782 · modelcontextprotocol/python-sdk
X Title: feat(client): send a same-origin Origin header by default (streamable HTTP) by fede-kamel · Pull Request #2782 · modelcontextprotocol/python-sdk
Description: 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 (#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.
Open Graph Description: 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 satisf...
X Description: 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 satisf...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/pull/2782
X: @github
Domain: github.com
| route-pattern | /:user_id/:repository/pull/:id/files(.:format) |
| route-controller | pull_requests |
| route-action | files |
| fetch-nonce | v2:5cf7d2f1-2c69-2b6d-492f-c3c25ff4a65b |
| current-catalog-service-hash | ae870bc5e265a340912cde392f23dad3671a0a881730ffdadd82f2f57d81641b |
| request-id | D86A:D8745:28BB68:370463:6A5A6D99 |
| html-safe-nonce | 0bff6be09e80801691733b3a2c07434a9e2ed1d8df191b8087d73f8566cfb9bc |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJEODZBOkQ4NzQ1OjI4QkI2ODozNzA0NjM6NkE1QTZEOTkiLCJ2aXNpdG9yX2lkIjoiMjY2MTM3NTAzNTA1MTc2NTE0NSIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9 |
| visitor-hmac | 1a37ed14d5b3403e34db27c3e857e82fbe0f6c736ae23e003eb765930582eed8 |
| hovercard-subject-tag | pull_request:3807178668 |
| github-keyboard-shortcuts | repository,pull-request-list,pull-request-conversation,pull-request-files-changed,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/modelcontextprotocol/python-sdk/pull/2782/files |
| twitter:image | https://avatars.githubusercontent.com/u/209537060?s=400&v=4 |
| twitter:card | summary_large_image |
| og:image | https://avatars.githubusercontent.com/u/209537060?s=400&v=4 |
| og:image:alt | 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 satisf... |
| og:site_name | GitHub |
| og:type | object |
| hostname | github.com |
| expected-hostname | github.com |
| None | 922bdba685ea48400234d557977f0e32d9adbae6f5125725b4f48cacc6801106 |
| turbo-cache-control | no-preview |
| diff-view | unified |
| go-import | github.com/modelcontextprotocol/python-sdk git https://github.com/modelcontextprotocol/python-sdk.git |
| octolytics-dimension-user_id | 182288589 |
| octolytics-dimension-user_login | modelcontextprotocol |
| octolytics-dimension-repository_id | 862584018 |
| octolytics-dimension-repository_nwo | modelcontextprotocol/python-sdk |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 862584018 |
| octolytics-dimension-repository_network_root_nwo | modelcontextprotocol/python-sdk |
| turbo-body-classes | logged-out env-production page-responsive full-width |
| disable-turbo | true |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 03575fc589c0e2692de839d7ca2b3aa5b5179f52 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width