Skip to content

rmcp (official Rust MCP SDK): default Streamable HTTP client never follows spec-permitted redirects - redirecting MCP servers hard-fail with Err(UnexpectedServerResponse) #1203

Open

Description

@trickyfalcon

rmcp (official Rust MCP SDK): default Streamable HTTP client never follows spec-permitted redirects

Type: functional / MCP conformance
Component: crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs — StreamableHttpClientTransport::default_http_client()

Summary

The official Rust SDK's default Streamable HTTP client is built with:

reqwest::Client::builder()
    .pool_max_idle_per_host(0)
    .redirect(reqwest::redirect::Policy::none())
    .build()

so it never follows HTTP redirects. The MCP spec explicitly permits servers to respond to transport requests with 301/302/307/308 (load balancing, canonical URL / trailing-slash normalization — see e.g. https://github.com/IBM/mcp-context-forge#4441 for a real 307 on /mcp/). Against any redirecting server, every request degrades to:

Err(StreamableHttpError::UnexpectedServerResponse("HTTP 307: ..."))

and the client cannot talk to that server at all.

The truncation is deliberate (there is a unit test asserting no custom-header leak to a redirect target — sensible given reqwest's default policy would replay custom headers), but the consequence is that a spec-compliant redirecting MCP server is unsupported out of the box.

Repro (no build needed)

Point any rmcp StreamableHttpClientTransport (default client) at a server that answers POST /mcp with 307 Location: <same-origin>/v2/mcp. Every post_message returns UnexpectedServerResponse("HTTP 307: ...") instead of issuing a follow-up request to the new Location.

A self-contained crate reproducing this (and the goose-side contrast) is at https://github.com/trickyfalcon/cve-hunt/blob/main/poc-goose-rust/ — scenario B prints the Err(UnexpectedServerResponse("HTTP 307: ...")) directly.

Suggested direction

Follow redirects safely rather than never: only follow cross-host/cross-lo*...* leftovers to be trimmed — or, at minimum, make redirect policy configurable on StreamableHttpClientTransportConfig while keeping the default strict (no-follow) for backwards compatibility. An explicit redirect policy override (as an associated constructor option) would let security-conscious users opt in to same-origin redirect following.

Environment

  • rmcp via modelcontextprotocol/rust-sdk (checked out 2026-08-22, rmcp crate 3.x)
  • Reproducible with the PoC referenced above

Happy to be credited in the advisory/release notes as: Mo (@trickyfalcon, https://trickyfalcon.com)

Activity

  1. dawNotPoi commented on Aug 23, 2026

    @dawNotPoi

    I'd like to work on this. I plan to add a focused local same-origin 307 regression test and implement a safe redirect policy that preserves the existing cross-host and custom-header protections. Please let me know if this direction matches the project's expectations.

  2. added
    P2Medium: important but non-blocking improvement
    T-enhancementNew features and enhancements
    T-transportTransport layer changes
    on Aug 28, 2026
  3. trickyfalcon commented on Sep 7, 2026

    @trickyfalcon
    Author

    Friendly bump — checking in on this conformance report. Quick recap: the default StreamableHttpClientTransport never follows spec-permitted 301/302/307/308 redirects (redirect::Policy::none() at streamable_http_client.rs:381-387), so redirecting MCP servers hard-fail with Err(UnexpectedServerResponse("HTTP 307: ...")) — a spec-compliant server is unusable out of the box. @dawNotPoi has kindly offered to implement a safe redirect policy (same-origin + host guard); is that direction acceptable to the maintainers, or would you prefer a configurable policy option? Happy to help review/verify. PoC (scenario B prints the 307 error): https://github.com/trickyfalcon/cve-hunt/blob/main/poc-goose-rust/

    Happy to be credited in the advisory/release notes as: Mo (@trickyfalcon, https://trickyfalcon.com)

  4. trickyfalcon commented on Sep 12, 2026

    @trickyfalcon
    Author

    Friendly bump (final scheduled check-in, day 21) — still no maintainer direction on @dawNotPoi's offer to implement a safe same-origin redirect policy. Recap: the default StreamableHttpClientTransport uses redirect::Policy::none() (streamable_http_client.rs:381-387), so spec-permitted 301/302/307/308 redirects hard-fail with Err(UnexpectedServerResponse(\"HTTP 307: ...\")) — a redirecting spec-compliant MCP server is unusable out of the box. Happy to help review/verify the regression test and policy once a PR lands. Is a transport-level fix planned? PoC (scenario B prints the 307 error): https://github.com/trickyfalcon/cve-hunt/blob/main/poc-goose-rust/

    Happy to be credited in the advisory/release notes as: Mo (@trickyfalcon, https://trickyfalcon.com)

  5. gocamille commented on Oct 14, 2026

    @gocamille
    Contributor

    Hi @dawNotPoi, are you still planning to work on this? I'd be glad to review a PR, even a draft. If you've moved on to other things, would you mind if I picked it up?

    One thing that might be worth covering: on 301/302/303, reqwest turns a POST into a GET with no body, so following those would drop the JSON-RPC message. 307/308 keep the body.

  6. trickyfalcon commented on Oct 15, 2026

    @trickyfalcon
    Author

    Either path is fine from my side — @dawNotPoi if you're still on it, otherwise @gocamille please take it; even a draft PR is enough to get review moving.

    On the semantics: agreed, and it's exactly why the repro uses 307 (scenario B in https://github.com/trickyfalcon/cve-hunt/blob/main/poc-goose-rust/) — 307/308 preserve method+body so the JSON-RPC call survives the hop, while a naive follow of 301/302/303 rewrites POST→GET and silently drops the message. So the shape isn't just 'follow the spec codes': same-origin auto-follow for body-preserving 307/308, with 301/302/303 either surfaced to the caller or handled with a deliberate, documented rewrite — otherwise the hard failure just becomes silent message loss. Happy to verify whatever lands against the PoC (scenario B prints the 307 error).

  7. gocamille commented on Oct 15, 2026

    @gocamille
    Contributor

    Opened a draft in #1367 so there's something concrete to review. Thanks @trickyfalcon for the report and the 301/302/303 point; you're credited in the PR and commit.

    @dawNotPoi, if you have work in progress, glad to fold it in or close mine.

    @trickyfalcon, a run against your PoC (scenario B) would be welcome.

    @DaleSeo, this relaxes the Policy::none() from #936, so it needs your sign-off on the approach: the default client follows only same-origin 307/308 (up to 10 hops), and 301/302/303 and cross-origin redirects still come back as errors. Does that direction work for you?

  8. trickyfalcon commented on Oct 16, 2026

    @trickyfalcon
    Author

    Ran the verification @gocamille asked for — PoC (scenario B) and the PR's own tests, both against current main (5d9935d) with the #1367 patch applied.

    PoC run (self-contained harness: "public" endpoint on the LAN IP 307s every POST to a loopback victim that logs what it receives):

    • [B] rust-sdk default (Policy::none()): status=307, Location surfaced to the caller, victim untouched → the UnexpectedServerResponse("HTTP 307 …") hard-failure this issue reports, reproduced on latest main.
    • [A] goose-style client (no policy override), for contrast: redirect followed into the loopback service with user-agent: goose and the custom auth header forwarded — the other half of the same story (#11500).
    • [C] PR-equivalent guard: public→loopback hop stops, victim delta 0; [D] internal→internal hop still follows.

    Cross-check vs the PR's policy (same_origin_redirect_policy): origin equality on the first previous() entry, 307/308-only, hop cap — all match the behavior I proposed in the thread. One deliberate divergence, flagged for completeness: at the cap the PR returns attempt.error(...) (reqwest Error::Redirect, surfacing via StreamableHttpError::Client) where my sketch used stop(); the loop test asserting MAX_REDIRECTS + 1 hits is consistent with the error path, so it's coherent — just note the cap is a hard error, not a silent stop.

    PR test suite (cargo test -p rmcp --lib --features client,transport-streamable-http-client-reqwest):

    • patch applied: the 6 new tests pass — same-origin 307/308 followed with method/body/session/Authorization/x-api-key intact, 301/302/303 surfaced as UnexpectedServerResponse with no follow, cross-origin not followed, loop capped — and the full lib run is 293 passed / 0 failed.
    • same tree with the patch stashed: 1 preexisting redirect test, so the +6 delta is exactly this PR's coverage, including the rewritten L593 same-host expectation.

    Happy with the approach; the @DaleSeo sign-off on relaxing #936 is the remaining gate. For the eventual release notes / advisory:

    Happy to be credited in the advisory/release notes as: Mo (@trickyfalcon, https://trickyfalcon.com)

  9. DaleSeo commented on Oct 16, 2026

    @DaleSeo
    Member

    I'm good with this direction. #936 was about not sending custom headers or the session ID to a different host, and a same-origin-only policy preserves that guarantee. Limiting redirects to 307 and 308 also avoids the 301, 302, and 303 POST-to-GET rewrite, which would silently drop the JSON-RPC message. So that seems like the right call. Leaving caller-supplied clients alone sounds right to me too.

    @gocamille, feel free to mark it ready for review. It would be nice to add a short note to the default_http_client docs about the redirect behavior. Updating the stored URI for 308 can be a follow-up.

    @trickyfalcon, about credit, this is a conformance fix, not a security vulnerability, so there won't be an advisory.

  10. gocamille commented on Oct 16, 2026

    @gocamille
    Contributor

    Thanks @DaleSeo! #1367 is now ready for review, with the doc note added. The 308 stored-URI update is tracked in #1373.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium: important but non-blocking improvementT-enhancementNew features and enhancementsT-transportTransport layer changes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions