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

Closed

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. Ben-Home commented on Aug 23, 2026

    @Ben-Home

    The existing unit test that asserts no custom-header leak to a redirect target is the right instinct, and the fix should extend it rather than remove it. reqwest's Policy::custom is the clean place: follow redirects only when the target authority matches the original request's authority, and reject anything cross-origin when custom headers are attached. For 307 and 308 the method and body are preserved, so a same-origin 307 from /mcp to /v2/mcp can be followed safely and transparently. For 301 and 302, reqwest would normally convert POST to GET, which breaks the MCP message framing, so treating those as UnexpectedServerResponse until the server publishes the canonical URL is also defensible. If you want to support cross-origin redirects for load-balanced deployments, the safe variant is to strip Authorization and custom headers on the cross-origin hop and require the target to issue its own challenge, which matches the OAuth resource-server model. That keeps spec conformance without reintroducing the leak the test was guarding against.

    We proxy many MCP servers through a connector gateway at corpusiq.io, and same-origin follow plus cross-origin strip is the policy that has held up in production there.

  2. 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.

  3. added
    P2Medium: important but non-blocking improvement
    T-enhancementNew features and enhancements
    T-transportTransport layer changes
    on Aug 28, 2026
  4. 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)

  5. 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)

  6. 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.

  7. 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).

  8. 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?

  9. 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)

  10. 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.

  11. 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