Apr 30, 2026 · 6 min read

MCP's Streamable HTTP Handshake: The Notification You Can't Skip

Building an HTTP MCP client from scratch and hitting `Unauthorized: Session not found` on every tool call — even with a valid session ID. The fix is one line of spec most SDKs hide from you.

Rust MCP HTTP Axum AI

This week I added a "Chat with me" widget to a demo site I'm building for a prospect. The pattern is simple on paper: visitor types a question, my Rust backend forwards to an LLM with my OpenRouter key, and the LLM grounds its answers by calling tools on the public MCP server at mcsoftsolution.com/mcp. Visible MCP tool calls in the chat UI, no hallucinated services or pricing — same MCP-first wedge I sell.

I rolled my own HTTP MCP client because I only needed two methods (tools/list and tools/call) and didn't want to pull in a full SDK. That's where I lost an hour.

The Bug

The widget loaded. The first message went through. The backend logged this:

WARN mcsoft_chat: mcp tools/list failed
   e=calling tools/list on MC Soft MCP

Caused by:
    0: posting tools/list request
    1: MCP HTTP 401 Unauthorized: Session not found

The model fell back to answering from system-prompt knowledge alone — useful, but not what I was selling. I needed the tool calls visible.

What I'd Built

Stripped down, my client looked like this:

async fn list_tools(&self) -> anyhow::Result<Vec<McpTool>> {
    if self.session_id.read().await.is_none() {
        // Best-effort initialize. Errors are non-fatal — we'll surface
        // any real failure on the tools/list call.
        let _ = self.initialize().await;
    }
    let resp = self.rpc("tools/list", json!({})).await?;
    // ...
}

async fn initialize(&self) -> anyhow::Result<()> {
    let resp = self.send(initialize_request()).await?;
    if let Some(sid) = resp.headers().get("Mcp-Session-Id") {
        *self.session_id.write().await = Some(sid.to_str()?.to_string());
    }
    let _ = decode_response(resp).await?;
    Ok(())
}

That looked right. POST initialize, capture Mcp-Session-Id from the response headers, attach that header to subsequent requests. Simple enough.

The problem: it kept getting 401s.

The Investigation

I dropped down to curl to see what was actually happening on the wire.

Initialize:

curl -isS -X POST https://mcsoftsolution.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",...}'
HTTP/1.1 200 OK
Content-Type: text/event-stream
mcp-session-id: aa5e6572-f969-4041-866d-bdcaec65d3a0

data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":...}}

Fine. Session ID came back as expected (lowercase mcp-session-id, but reqwest's header lookup is case-insensitive, so my code captured it correctly).

Then tools/list with that exact session ID:

curl -sS -X POST https://mcsoftsolution.com/mcp \
  -H 'Mcp-Session-Id: aa5e6572-f969-4041-866d-bdcaec65d3a0' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
Unauthorized: Session not found

Same session ID, freshly minted. Server denies it. So the session ID itself wasn't the problem — the server didn't consider the session active.

The Fix

Re-reading the MCP Streamable HTTP transport spec more carefully:

After the client receives the response to its initialize request, it MUST send an initialized notification to indicate it is ready to begin normal operations.

Note: a notification, not a request. JSON-RPC notifications have no id field; the server returns 202 Accepted with no body. It's easy to miss because:

  • The initialize request returns a useful payload (server info, capabilities).
  • The follow-up notification returns nothing.
  • Most MCP SDKs send it for you automatically and never surface the dance.

If you skip it, the server holds the session in a "started but not confirmed" state and refuses subsequent requests. Some servers (including this one) even tear down those un-confirmed sessions quickly to discourage half-open connections.

The corrected handshake:

async fn initialize_handshake(&self) -> anyhow::Result<()> {
    // Step 1: initialize. Captures Mcp-Session-Id from response headers.
    let resp = self
        .send(&json!({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "initialize",
            "params": {
                "protocolVersion": "2025-06-18",
                "capabilities": {},
                "clientInfo": {
                    "name": "mcsoftsolution-chat",
                    "version": env!("CARGO_PKG_VERSION"),
                }
            }
        }))
        .await?;

    if let Some(sid) = resp
        .headers()
        .get("Mcp-Session-Id")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string())
    {
        *self.session_id.write().await = Some(sid);
    }
    let _ = decode_response(resp).await?;

    // Step 2: notifications/initialized — JSON-RPC notification (no `id`).
    // Without this, the server rejects subsequent requests with 401.
    let resp = self
        .send(&json!({
            "jsonrpc": "2.0",
            "method": "notifications/initialized"
        }))
        .await?;
    let _ = resp.text().await;
    Ok(())
}

Re-ran the chat. The agent loop emitted a tool call event. The MCP returned 6,796 chars of list_services payload. The model streamed an answer grounded in the live data. End-to-end working.

The Handshake, Visualized

STREAMABLE HTTP HANDSHAKE POST /mcp method: initialize Server returns: Mcp-Session-Id: <uuid> + JSON-RPC result body POST /mcp method: notifications/initialized No `id` field. Server returns 202 Accepted. EASY TO SKIP. DO NOT SKIP. POST /mcp method: tools/list, tools/call, … Now succeed. All under one Mcp-Session-Id. 200 OK

One More Robustness Step: Reconnect on 401

Even with the handshake right, MCP sessions don't live forever. The server can evict idle ones, restart, lose them. So I added a one-shot retry: if a request fails with Session not found, drop the cached session, re-handshake, and retry once.

async fn rpc(&self, method: &str, params: Value) -> anyhow::Result<Value> {
    match self.rpc_once(method, params.clone()).await {
        Ok(v) => Ok(v),
        Err(e) => {
            let msg = format!("{e:#}");
            if msg.contains("Session not found") || msg.contains("HTTP 401") {
                tracing::debug!(method, "MCP session lost; re-handshaking");
                *self.session_id.write().await = None;
                self.initialize_handshake().await?;
                self.rpc_once(method, params).await
            } else {
                Err(e)
            }
        }
    }
}

One retry, surface the original error if that fails too. No infinite loops, no silent swallowing.

Why This Matters

If you're using one of the official MCP SDKs — mcp for Python, @modelcontextprotocol/sdk for TypeScript, rmcp for Rust — you'll never see this. The SDK does the handshake for you and only exposes the tools/list / tools/call surface that actually does useful work.

But you'll hit it the moment you do any of these:

  • Roll your own HTTP MCP client (small enough that pulling an SDK feels like overkill).
  • Build a non-MCP service that consumes MCP servers — like a chat backend that bridges OpenAI tool calls to MCP tools.
  • Debug a failing connection from the wire side and try to reproduce with curl.
  • Implement an MCP gateway that proxies between transports.

The whole transcript per session is short:

  1. POSTinitialize (request, gets a result back, capture session id).
  2. POSTnotifications/initialized (notification, no result, but required).
  3. Everything else, with the session id header.

Three POSTs. One of them returns nothing. Skip it and your client looks correct on paper but fails on every real call.

Lifted Out of the Demo

I extracted the chat widget — the HTTP MCP client, the OpenRouter streaming, the SSE handler, the React component — into a small standalone repo so the next demo doesn't have to rebuild any of it. The handshake fix lives in the crate, so anyone using it gets it for free.

Three lines on the backend, one import on the frontend, one env var. The kind of thing that should have been a copy-paste from the start, but had to live inside one project first to learn what was actually reusable.

If you're building MCP integrations and hit this same 401: send the notification.