Repository navigation
Description
The MCP spec (2025-03-26), Session Management point 4 says:
When a client receives HTTP 404 in response to a request containing an
Mcp-Session-Id, it MUST start a new session by sending a newInitializeRequestwithout a session ID attached.
Neither send() (POST) nor _startOrAuthSse() (GET) handle 404. Both paths just throw StreamableHTTPError. There's no session ID clearing and no re-initialization.
send() has retry logic for 401 (auth) and 403 (insufficient_scope), but 404 falls through to:
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);Since _sessionId isn't cleared before the throw, subsequent send() calls keep sending the stale session ID and keep getting 404. It's not a transient failure - the client is stuck until it's destroyed and recreated.
This means servers that correctly return 404 for expired/unknown session IDs (as #389 asks them to) permanently break SDK-based clients instead of triggering a graceful reconnect.
Expected behavior: On 404, clear _sessionId and either re-initialize the session automatically or surface an event that lets the Client layer handle re-initialization (since InitializeRequest involves capability negotiation that the transport doesn't own).
Ref: #389 tracks the server-side of this (examples returning 400 instead of 404). This is the client-side counterpart. Affects v1.26.0 and v1.27.1.
Activity
mbochneak commentedon Mar 19, 2026
Related: #1678 fixes the server-side examples (returning 404 instead of 400 for invalid sessions). This issue is the client-side counterpart - once servers correctly return 404,
StreamableHTTPClientTransportneeds to handle it.Also worth noting:
_sessionIdpersists after theStreamableHTTPErrorthrow since there's no cleanup before the throw. This means subsequentsend()calls will also fail with the same stale session ID - it's a stuck state, not just a one-shot failure.Maverick-666 commentedon Mar 20, 2026
I’d like to work on this.
Plan:
Mcp-Session-Idis present.I’ll open a PR shortly.
Maverick-666 commentedon Mar 20, 2026
Implemented and opened a PR for this:
sessionExpired: truefor recoverable handling@modelcontextprotocol/clientPR: #1718
fix(client/streamableHttp): handle 404 by clearing session ID per MCP…
lanxevo3 commentedon Mar 28, 2026
Fixed in PR #1705 (actually #1805) — added 404 handling that clears the stale session ID before throwing, per MCP spec §Session Management. Added SdkErrorCode.ClientHttpNotFound for client-level detection.
methran1304 commentedon Apr 28, 2026
What's the ETA on this?
ericmckinney commentedon Apr 28, 2026
Adding production field-evidence to support this issue, in case it's useful for prioritization or for the maintainers landing #1718.
7-day production observation
Operating a Python-SDK-based MCP server (Streamable HTTP transport, public via Cloudflare tunnel, bearer-auth at edge), we see this exact spec-violation pattern from at least one connector class consistently. Across a 7-day window:
404 Not Foundlog lines on/mcprequests carrying anMcp-Session-Idthat the server had already evicted (typically after a server restart releases in-memory session state).InitializeRequest(or otherwise rotates session). Distribution is not consistent with a constant retry-budget; appears time- or stream-bounded, but eventually terminates in every observed burst.Sample log entries (server side, redacted to non-secret fields):
Spec reference (matches the issue): MCP 2025-06-18 §Session Management point 4 — "When a client receives HTTP 404 in response to a request containing an
Mcp-Session-Id, it MUST start a new session by sending a newInitializeRequestwithout a session ID attached." First 404 should be terminal-for-this-session-id; the observed retry-then-reinit pattern is non-conformant.Connector-class signal
The 404 retry-bursts originate consistently from one Cloudflare egress class. A second connector class (a different MCP client wrapping localhost-direct, also Anthropic-built) hitting the same server does not trigger the bug — on session loss it issues
InitializeRequestcleanly. So at least empirically, the spec-violating behavior is implementation-specific to the connector that wrapsStreamableHTTPClientTransport(or an equivalent code path), not universal to all clients.This is consistent with the synthetic repro by @mcp-claude in the comment thread above: the SDK transport retains
_sessionIdpast the throw and any wrapping client that doesn't manually destroy + recreate the transport stays stuck for as long as it tries.Possibly-related body-shape divergence (hypothesis, not validated)
Cross-referencing python-sdk#1821: the JSON-RPC error body that servers emit on stale-session 404 differs across SDK implementations.
error.code-32001-32600(INVALID_REQUEST)idnull"server-error"If a TypeScript-based client classifies "stale session" by pattern-matching on the TypeScript-shape body (rather than HTTP 404 alone), a Python-server-emitted body might fall through to a generic-retry path rather than the stale-session reinit path. This is GUESSED, not observed — would need connector source-side or a controlled body-shape A/B to confirm — but worth flagging here in case it informs whether the SDK fix should also key on HTTP status alone (per spec) regardless of body shape.
Server-side workaround (FYI)
While #1708/#1718 lands, we've deployed a server-side auto-reinit middleware that synthesizes an
InitializeRequestfrom the pre-authenticated bearer on stale-session non-initialize requests — feature-flagged and staged-rollout. It works around connector behavior at the cost of ~50 LOC of server middleware that we'd retire once the client-side fix is in. Mentioning only so the empirical motivation is visible: the bug is impacting real production traffic and folks are routing around it server-side.Happy to share more log samples, the retry-distribution methodology, or test against a candidate fix if useful for #1718's regression suite. Thanks for the work tracking this down.
Reconnect and retry once when the server forgets the MCP session
Azazi commentedon Aug 13, 2026
Opened #2660, which rebases #2125's fix (thank you @dsp-ant for that work) onto current
mainto resolve its conflict with #2469.546148471 commentedon Oct 15, 2026
Confirming this is still present in the latest published release, and adding one point the fixes proposed so far (#1718, #2125, #2660) do not cover.
Still unfixed in the published v2 line (
@modelcontextprotocol/[email protected])Verified against the published tarball (
dist/index.cjs), not just v1.x/mainsources:dist/index.cjs:5792— the non-2xx branch of_send()handles401(:5743),403/insufficient_scope(:5770) and400(:5783), then falls through tothrow new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: ...'). There is no404arm andthis._sessionIdis not cleared._startOrAuthSse()(:5488) throwsClientHttpFailedToOpenStreamat :5551 with no 404 handling.404matches, none of them in the transport — two are OAuth well-known-endpoint probes (:966, :1064)._sessionIdis written in exactly 3 places: constructor (:5387), handshake success (:5741, guarded byisHandshake && response.ok),terminateSession()(:5855). There is no expiry path.sessionExpiredoccurs 0 times in the bundle, so thesessionExpired: truetagging proposed in this thread is in no published release.Net effect on 2.3.1: every subsequent
tools/callre-sends the deadMcp-Session-Id, gets 404 again, and never recovers, while the tool list stays registered.Clearing the session ID is not sufficient for hosts that reconnect on
oncloseThis is the part I'd suggest folding into whichever PR lands. In
StreamableHTTPClientTransport(class starts at :5342)oncloseis invoked from exactly one place —close()(:5693,this.onclose?.()at :5699). A request failure, including this 404, never reaches it:_send()catches and re-throws at :5820-5822,so session expiry surfaces only on
onerror. A host whose reconnect supervisor is wired toonclose— the natural "the transport/session is dead" signal — therefore still never learns the session died, even once #2660/#2125 land; it has to special-caseClientHttpSessionExpiredinside its error handler and synthesize the reconnect itself.Neither #1718, #2125 nor #2660 touches
onclose/onerror. It would be worth either callingthis.onclose?.()on the session-expiry path in addition to clearing_sessionId, or documenting explicitly that hosts MUST drive reconnection fromonerror+ClientHttpSessionExpired. Same class as #2682 and #2964, where the v2 transport also fails to notify the application when a stream dies.Real-world trigger: an idle timeout in the server SDK's default config
Field-observed against a local stateful Streamable HTTP MCP server built on
rmcp1.6.0. The session worker's idlekeep_alivedefaults to 300 s (SessionConfig::DEFAULT_KEEP_ALIVE = Duration::from_secs(300),session/local.rs:1146); on expiry the server logsand answers any later request carrying that session id with
404/Session not found. Client side: ~5 minutes idle, then everytools/callfails withError POSTing to endpoint: Not Found: Session not foundand never recovers until the process is restarted. This is the server SDK's default behaviour, so "server silently expires sessions after an idle period" is not an exotic misconfiguration — it is what you get out of the box.Two smaller notes:
Mcp-Session-Id" (as fix(client): treat HTTP 404 with session ID as session expiry #2660 does). The handshake is already exempt — :5718-5719 strips the header whenisHandshake— which correctly keeps a genuinely wrong URL on first connect surfacing asClientHttpNotImplementedinstead of triggering a reconnect loop.ClientHttpNotImplementedfor an expired session is what makes this hard to diagnose: the semantics read as "server does not implement this operation" and the message never mentions the session. The distinctClientHttpSessionExpiredcode is the right call.Suggested regression test: transport with a session ID set → mocked
fetchreturns 404 → asserttransport.sessionId === undefined, that the thrown error carries the session-expiry code, and (if adopted) thatonclosefired.