Repository navigation
Streamable HTTP client: update the stored endpoint URI after a 308 #1373
Description
Follow-up from #1203 and #1367.
Problem
After #1367, the default Streamable HTTP client follows same-origin 307 and 308 redirects. But it never saves the new location. Every request still goes to the configured URI first and gets redirected again.
A 308 means the move is permanent, so the client could send later requests straight to the new URI.
Current behavior
At a156be8, the URI lives in StreamableHttpClientTransportConfig::uri. The worker clones the config once and never changes it. Each request copies the URI from there:
- POST:
post_request, and the initialize POSTs at startup and in the fallback path. - Standalone GET stream: resumption with
Last-Event-IDuses its own copy inStreamableHttpClientReconnect. - SSE on a POST response: resumption gets a copy too.
- Re-initialization after a 404:
perform_reinitialization. - DELETE on close:
SessionCleanupInfoholds its own copy, used at exit.
All of these requests use the same reqwest::Client, so the redirect policy applies to GET and DELETE too, not only POST.
reqwest gives us the final URL through Response::url(), but not the status of each hop. The Policy::custom closure sees each hop's status. However, one policy is shared by every request on the client, and it can't tell which request a hop belongs to.
Proposal
- Only save the new URI when every hop in the chain is a 308. If any hop is a 307, keep the configured URI.
- Have the
runloop keep the current URI, since it already owns the session state. Use that URI for new POSTs, new GET streams, re-initialization and the DELETE. - Return the final URL from the client to the worker.
StreamableHttpPostResponsedoesn't carry a URL today.
To find out whether every hop was a 308, we could:
- Stop on 308 in the policy and follow it by hand in the reqwest
StreamableHttpClientimpl, with the same origin and hop-limit checks. - Record hops from the policy closure into shared state. This is unreliable when concurrent requests start from the same URI.
Open questions
- Same-origin is already enforced, so only the path and query can change. Do we need any other check?
- Should the session ID carry over to the new URI, or should the client re-initialize?
- OAuth:
AuthorizationManagerkeeps its ownbase_url. It's the fallbackresource, and the protected resource metadataresourceis checked against it. If the transport URI moves andbase_urldoesn't, they no longer match, and there's no way to updatebase_urltoday. Should we skip the update whenAuthClientwraps the transport, or move both? - Requests and streams already in flight keep the old URI. Is that fine, or should resumption pick up the new one?
- Default client only, or also clients passed to
with_client?
Related
- rmcp (official Rust MCP SDK): default Streamable HTTP client never follows spec-permitted redirects - redirecting MCP servers hard-fail with Err(UnexpectedServerResponse) #1203: the original redirect report
- fix(transport): follow same-origin 307/308 redirects in the default Streamable HTTP client #1367: follows same-origin 307/308 in the default client
- The TypeScript and Python SDKs don't update the stored URL either. Both follow same-origin redirects and keep the configured URL (TypeScript, Python).
Activity
github-actions commentedon Oct 16, 2026
@connor4312 is on triage rotation this week and is the reviewer for this one.
Codex labeled this enhancement / P3 (T-transport). Please confirm or correct those labels.
Automated triage. Labels are a first pass and the assigned maintainer has the final say.
connor4312 commentedon Oct 17, 2026
I'm slightly dubious about this just because I've had occasional situations where a server incorrectly gave a 308 at some point and my client code broke because it wasn't meant to be permanent-permanent. Notably, the Python and TypeScript MCP SDK's do not remember 308s--nor do most generic API clients out of the box, from my experience.
Providing a way for clients to opt-in to 308 caching would make sense but I would tend to follow the lead of other implementations and not make it default behavior.
gocamille commentedon Oct 19, 2026
Thanks @connor4312! I think you're right that this shouldn't be the default. TypeScript and Python both follow redirects per request without updating the URL and I read RFC 9110 which says the "update your references" hint is usually ignored anyway, so #1367 already lines up with that.
I was curious whether the extra hop costs us more than it costs them so I measured it locally. Our default client sets
pool_max_idle_per_host(0)which means every request through a same-origin 308 opens a second connection (40 connections for 20 POSTs vs 20 going direct). That still doesn't feel like enough to make remembering the default though, and most of the cost comes from the pool setting rather than the redirect. That's probably worth its own issue.What do you think about skipping opt-in for now and just logging a warning with the final URL when we get redirected? Folks could fix their config and we can come back to this if someone actually asks for it.
connor4312 commentedon Oct 19, 2026
I think a warning is too loud, but a debug level would make sense.