Skip to content

fix(transport): follow same-origin 307/308 redirects in the default Streamable HTTP client - #1367

Open
gocamille wants to merge 2 commits into
modelcontextprotocol:mainfrom
gocamille:fix/streamable-http-same-origin-redirects
Open

gocamille wants to merge 2 commits into
modelcontextprotocol:mainfrom
gocamille:fix/streamable-http-same-origin-redirects

Conversation

@gocamille

@gocamille gocamille commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1203. Reported by Mo (@trickyfalcon).

Motivation and Context

The default client built by StreamableHttpClientTransport::default_http_client now uses a custom reqwest redirect policy (same_origin_redirect_policy) instead of Policy::none():

  • Follows 307 and 308 when the target's origin (scheme, host, port) matches the original request's, up to MAX_REDIRECTS (10) hops. reqwest keeps the method, the .json() body, and the session, auth and custom headers on these.
  • Stops on 301, 302 and 303. reqwest would turn the POST into a bodyless GET, which a Streamable HTTP server can accept as an SSE stream request, so the message would be lost without an error. These still come back as UnexpectedServerResponse("HTTP 30x ...").
  • Stops on cross-origin redirects, so the fix: block redirect header leaks #936 guard against replaying custom headers and the session id to another host still holds.
  • Errors after 10 hops (StreamableHttpError::Client, is_redirect()), so a redirect loop can't run forever.

Only the default client changes. A caller-supplied reqwest::Client keeps its own policy. The from_uri and from_config docs now describe this redirect handling. GET and DELETE use the same rule; following 301/302 for a GET would be safe, but one rule for every method seemed simpler. Happy to loosen that.

How Has This Been Tested?

Tests in streamable_http_client.rs:

  • default_http_client_follows_same_origin_body_preserving_redirect (307, 308): the target gets a POST with the same JSON-RPC body, Mcp-Session-Id, Authorization: Bearer and the custom header. Fails on main.
  • default_http_client_does_not_follow_method_rewriting_redirect (301, 302, 303): returns an error and the target is never hit. Passes on main too; it's there so a broader policy can't slip in.
  • default_http_client_stops_redirect_loop: a self-redirecting 307 ends in a redirect error after 11 requests.
  • default_http_client_does_not_leak_custom_headers_to_redirect_target (existing) still passes; it's a cross-origin 307.

Validation:

  • cargo test -p rmcp --all-features: passed.
  • cargo clippy --all-targets --all-features -- -D warnings and cargo +nightly fmt --all -- --check: clean.

Breaking Changes

No API changes. The default client now follows same-origin 307/308 redirects instead of returning UnexpectedServerResponse.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

A 308 is followed on every request; the stored URI doesn't change. Updating it is tracked in #1373.

@github-actions

Copy link
Copy Markdown
Contributor

@bolinfest is on triage rotation this week and is the reviewer for this one.

Labels come from the PR labeler. Please review the change.

Automated triage. Labels are a first pass and the assigned maintainer has the final say.

…treamable HTTP client

The default reqwest client used `Policy::none()`, so any redirect from an
MCP server failed with `UnexpectedServerResponse("HTTP 307: ...")`.

It now follows 307 and 308 when the target has the same origin as the
original request, up to 10 hops. Those keep the method and body, so the
JSON-RPC message and the session, auth and custom headers arrive intact.
301, 302 and 303 are still returned to the caller, because following them
turns the POST into a bodyless GET. Cross-origin redirects are still
returned too, which keeps the header-leak guard.

Refs modelcontextprotocol#936
Reported-by: Mo (@trickyfalcon)
Document on `from_uri` and `from_config` which redirects the default
client follows, that a 308 does not change the stored URI, and that a
client passed to `with_client` keeps its own redirect policy.
@gocamille
gocamille force-pushed the fix/streamable-http-same-origin-redirects branch from c818296 to a821a5e Compare October 16, 2026 21:46
@gocamille

Copy link
Copy Markdown
Contributor Author

Dale signed off on the approach in #1203 (#1203 (comment)). I've added the redirect note he asked for to the from_uri / from_config docs (default_http_client is private), and the 308 stored-URI update is tracked in #1373.

@gocamille
gocamille marked this pull request as ready for review October 16, 2026 21:51
@gocamille
gocamille requested a review from a team as a code owner October 16, 2026 21:51

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-transport Transport layer changes

Projects

None yet

1 participant