If you read one line: Streamable HTTP's Last-Event-ID resumes a dropped MCP stream only if the server buffered the events in an EventStore — and the SDK's default store lives in one process's memory, so a restart or a second replica turns every live session's reconnect into a 404. Wire a Redis-backed EventStore, or run stateless and let clients retry. Those are the two honest answers.

You built an MCP server on Streamable HTTP. A client drops mid-call — a phone changes networks, a laptop sleeps — reconnects, and the streamed result is just gone. Or worse: you ship a deploy, the process restarts, and every connected client starts throwing 404 Not Found while presenting a session ID that looks perfectly valid. Both are the same missing piece, and it isn't a bug in the SDK. It's the part of the transport the quickstart never makes you implement.

Last-Event-ID is a cursor with nothing behind it#

The Streamable HTTP transport is built to be resumable. Each server→client SSE message can carry an id:. When a client's stream drops, it reconnects with an HTTP GET carrying a Last-Event-ID header naming the last id it saw, and the spec says the server MUST resume the stream after that event. It reads like free reconnection.

It is not free, and it is not automatic. "Resume after that event" means replay from a buffer — and the transport has no buffer unless you gave it one. This is the same trap that makes naive SSE resumption a lie for LLM output: Last-Event-ID is a cursor, and a cursor needs a tape to point at. In MCP that tape is the EventStore, and if you didn't pass one, the reconnect succeeds and delivers exactly zero missed messages. No error. Just silence.

The interface is two methods#

The EventStore is smaller than you'd fear — the whole contract is two methods:

interface EventStore {
  // called for every server→client message; the returned id becomes the SSE `id:`
  storeEvent(streamId: string, message: JSONRPCMessage): Promise<string>;

  // called on reconnect: replay everything after lastEventId, in order
  replayEventsAfter(
    lastEventId: string,
    { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
  ): Promise<string>; // returns the streamId that event belonged to
}

You hand it to the transport, and resumability turns on:

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => randomUUID(),
  eventStore, // <-- without this line, Last-Event-ID resolves to nothing
});

The SDK ships a reference InMemoryEventStore of exactly this shape. Read it to learn the interface. Then do not deploy it.

Why the default store fails in production#

InMemoryEventStore keeps every session's events in the Node heap of one process. That single fact is three outages waiting to happen:

The fix is to move the tape out of the process: an EventStore backed by Redis or another shared, durable log, keyed by session and stream, with an eviction cap. storeEvent appends; replayEventsAfter reads the tail. Now any replica can replay, and a restart forgets nothing.

The decision: does a drop resume, or retry?#

Here is the part worth internalizing, because it saves you from building the Redis store you may not need. Statelessness and resumability pull in opposite directions, and the EventStore is the hinge.

Stateless mode survives restarts by having no session to lose. A resumable server survives them by making the session outlive the process. You are choosing which of those two properties you can't live without — you don't get both cheaply.

Run stateless (sessionIdGenerator: undefined) and there is no session, no 404-on-restart, no sticky-routing requirement, no store to run — this is why the ecosystem made stateless the default, and for most servers it is simply the correct answer. The cost: no mid-stream resume. A client that drops re-issues the whole request. If your tool calls are short and idempotent, that is a non-event — the same "make your tool calls idempotent" discipline that already protects you from retries protects you here.

Reach for a stateful server plus an external EventStore only when a single call streams long enough that losing its progress on a blip is genuinely unacceptable: a multi-minute agent run, a large export, a slow generative tool where re-running is expensive or non-deterministic. That's the case that justifies the Redis store — and nothing less is.

The mistake is the middle: a stateful server with the in-memory store, which gives you the illusion of resumption in every demo and the 404 in every deploy. Pick an end. Either a dropped connection resumes — and you paid for that with a store that outlives the process — or it retries, and you kept your server stateless on purpose. Decide it before your first restart decides it for you.