Hmbown/CodeWhale · error · anyhow

MCP Streamable HTTP session expired; retry with a new…

Error message

MCP Streamable HTTP session expired; retry with a new session required ({detail})

What it means

When a Streamable HTTP send fails with `StreamableSendError::SessionExpired`, the transport clears its cached session ID and returns this error telling the caller the whole message must be retried with a fresh session. The server invalidated the session (idle expiry, restart), so the old `Mcp-Session-Id` header is no longer valid.

Solutions

  1. Reconnect (retry the connection) so a new session is established, then resend the failed message.
  2. Enable keep-alive/heartbeat traffic to avoid idle session expiry.
  3. Configure the MCP server for longer session TTLs or sticky sessions on the load balancer.
  4. Ensure the calling engine treats this error as retry-with-new-session rather than a fatal failure.

Example fix

// before
let resp = transport.send(msg).await?; // fatal on session expiry
// after
match transport.send(msg).await {
    Err(e) if e.to_string().contains("session expired") => {
        transport.reconnect().await?;
        transport.send(msg).await?
    }
    other => other?,
}
Defensive patterns

Strategy: retry

Validate before calling

// on SessionExpired the transport clears session_id; detect and reconnect
let needs_new_session = matches!(err.downcast_ref::<StreamableSendError>(),
    Some(StreamableSendError::SessionExpired(_)));

Type guard

fn is_session_expired(err: &anyhow::Error) -> bool {
    err.to_string().contains("session expired")
}

Try / catch

match send_result {
    Err(e) if e.to_string().contains("session expired") => {
        transport.session_id = None; // already cleared by transport
        reconnect().await?;
        resend(msg).await?
    }
    other => other?,
}

Prevention

When it happens

Trigger: Sending a JSON-RPC message to an MCP Streamable HTTP server after the session expired: server restarted, session timed out server-side, or the server rolled back to a load balancer replica without the session.

Common situations: Long-running TUI sessions left idle past the server's session TTL; server deploys/restarts mid-session; load-balanced MCP fleets without sticky sessions.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/8cd142b444096b68. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/mcp/http.rs:281

            HttpTransportMode::Streamable(transport) => match transport.send(msg.clone()).await {
                Ok(()) => Ok(()),
                Err(StreamableSendError::Incompatible(detail)) => {
                    tracing::debug!(
                        "MCP Streamable HTTP unavailable; falling back to SSE endpoint discovery: {}",
                        detail
                    );
                    self.switch_to_sse_and_send(msg).await
                }
                Err(StreamableSendError::StaleSession(detail)) => {
                    if let HttpTransportMode::Streamable(transport) = &mut self.mode {
                        tracing::debug!(
                            target: "mcp",
                            error = %detail,
                            "MCP Streamable HTTP session expired; clearing cached session ID"
                        );
                        transport.session_id = None;
                    }
                    Err(anyhow::anyhow!(
                        "MCP Streamable HTTP session expired; retry with a new session required ({detail})"
                    ))
                }
                Err(StreamableSendError::Other(err)) => Err(err),
            },
            HttpTransportMode::Sse(transport) => transport.send(msg).await,
        }
    }

    async fn recv(&mut self) -> Result<Vec<u8>> {
        match &mut self.mode {
            HttpTransportMode::Streamable(transport) => transport.recv().await,
            HttpTransportMode::Sse(transport) => transport.recv().await,
        }
    }

    async fn shutdown(&mut self) {
        if let HttpTransportMode::Sse(transport) = &mut self.mode {

View on GitHub (pinned to 73e0f67d83)