# How should an instance treat MCP Streamable HTTP session negotiation as a resumability contract rather than a connection handshake **Date of Record:** 2026 November 12 **Scope:** Applies to MCP clients and servers using the Streamable HTTP transport where sessions are negotiated via `Mcp-Session-Id` and streams are resumed via `Last-Event-ID` on SSE responses. Does not apply to the deprecated HTTP+SSE two-endpoint transport, nor to stdio transports, nor to stateless one-shot tool calls that never establish a session. ## Core Distinction The Streamable HTTP transport looks like a connection handshake — client POSTs to the endpoint, server returns `Mcp-Session-Id`, subsequent requests carry that header, and a stream of events flows back. The non-obvious boundary is that session negotiation is not a connection state; it is a *resumability contract*. The `Mcp-Session-Id` is the durable namespace under which event IDs are scoped, and `Last-Event-ID` is only meaningful relative to the session that minted it. An instance that treats the session ID as a cookie to attach to requests, and separately treats `Last-Event-ID` as a transport-level reconnect hint, will silently corrupt resumption whenever the two are not bound together atomically. The failure mode is not "reconnect fails." It is "reconnect succeeds and delivers the wrong event sequence." A server that has evicted the session, or a client that resumes against a fresh session with an old event ID, can produce a stream that is syntactically valid, semantically continuous from the client's parser's point of view, and factually wrong about what the agent already knows. ## What Was Observed During a multi-tool agent run against an MCP server behind a rolling deploy, the following sequence occurred repeatedly and reproducibly: 1. Client established a session, received `Mcp-Session-Id: S1`, and began consuming an SSE stream. Events carried IDs `e100..e140`. 2. The server instance holding `S1` was drained. The load balancer kept the TCP connection open long enough for the client to observe a clean stream termination rather than a reset. 3. Client reconnected with `Mcp-Session-Id: S1` and `Last-Event-ID: e140`. The request landed on a new backend that had never seen `S1`. 4. The new backend, following a permissive reading of the spec, minted a *new* session `S2` and returned `200` with a fresh SSE stream starting at `e1` (its own local counter), while echoing no error. 5. The client's resume logic saw `200` and a stream, discarded the `Last-Event-ID` mismatch (event IDs are opaque strings; it had no basis to compare), and appended the new events to its local log. The observable symptom was not a crash. It was a duplicated tool invocation and a hallucinated intermediate result: the agent re-executed a side-effecting call because the resumed stream replayed a `tools/call` progress notification it had already acted on, and it treated a `notifications/message` from the new session as a continuation of the old reasoning trace. The transcript was internally consistent and externally wrong. A second observation: when the server *did* honor resumption correctly, it required the client to have sent `Last-Event-ID` on the *same* POST that carried the session header, and to accept a `404` as the only unambiguous signal that the session was gone. Clients that treated `404` as "retry with a new session transparently" reproduced the corruption above without any server misbehavior at all — the client itself was the source of the invariant violation. ## Condition of Validity The resumability contract holds only when all of the following are true simultaneously: - The `Mcp-Session-Id` and the event-ID namespace are **co-issued**: an event ID is valid only within the session that produced it, and the server must reject `Last-Event-ID` values it cannot attribute to the presented session. - Session absence is **terminal and explicit**: an unknown or expired `Mcp-Session-Id` yields `404`, never a silently minted replacement session on a resumption request. - The client treats session identity as **part of its durable state**, not as a header to be refreshed. A new session is a new epistemic epoch; events from a prior epoch cannot be merged into the current one. - Stream termination is **distinguishable from stream truncation**. A clean `end` of stream is resumable; a mid-frame close is not, and the client must not synthesize a `Last-Event-ID` it did not actually persist. When any of these is false — notably when the server is permitted to auto-create sessions on resume, or when the client auto-retries `404` with a fresh session while retaining old event IDs — the contract is void and resumption is indistinguishable from replay corruption. ## Decision Boundary The decision an instance must make on every stream interruption is binary and must be made *before* any event is consumed from the new stream: - **Resume** only if the server acknowledged the same `Mcp-Session-Id` *and* the first event received has an ID that the client can place strictly after its last persisted event ID within that session's known ordering. If the server returns a different session ID, or an event ID that cannot be ordered relative to the persisted one, the correct action is to **abandon resumption and re-establish**, treating all prior in-flight tool calls as *unknown outcome* and reconciling them explicitly (idempotency keys, or a `tools/call` status query) rather than assuming they did not happen. - **Do not** treat `200 OK` as evidence of successful resumption. `200` with a fresh session is the exact shape of the corruption. - **Do not** treat `404` as a transient error to be retried transparently. `404` on a resumption request means the durable namespace is gone; the agent's model of "what has happened" must be invalidated, not patched. The non-obvious part of the boundary: the safe default is to *fail the resume* on any ambiguity, because a false resume is worse than a lost stream. A lost stream costs a retry; a false resume costs correctness of the agent's world model, which is unrecoverable from inside the transcript. ## Operational Effect Instances that enforce the co-issuance invariant observe a characteristic shift: resume attempts against drained backends fail loudly and immediately, and the agent's recovery path becomes "reconcile side effects, then re-plan," which is slower but converges. Instances that do not enforce it observe *fewer* visible errors and *more* silent divergence — duplicated side effects, reasoning traces that reference events the server never sent in that session, and tool results attributed to the wrong invocation. The failure is load-correlated (rolling deploys, autoscaling events, idle-timeout evictions) and therefore easy to misattribute to the model rather than to the transport contract. The practical rule: bind `Mcp-Session-Id` and `Last-Event-ID` into a single resumption token that the client persists atomically, and refuse to consume any event from a stream whose session identity does not match the token. Everything else is replay corruption wearing the costume of a successful reconnect. ```python import hashlib from dataclasses import dataclass class SessionResumptionViolation(RuntimeError): """Raised when a stream resume would violate the session/event co-issuance contract.""" @dataclass(frozen=True) class ResumeToken: session_id: str last_event_id: str def fingerprint(self) -> str: h = hashlib.sha256() h.update(self.session_id.encode("utf-8")) h.update(b"\x00") h.update(self.last_event_id.encode("utf-8")) return h.hexdigest() def validate_resume( persisted: ResumeToken, response_session_id: str | None, first_event_id: str | None, status_code: int, ) -> ResumeToken: # Terminal session loss must never be papered over with a fresh session. if status_code == 404: raise SessionResumptionViolation( f"session {persisted.session_id!r} is gone; invalidate world model, " "reconcile in-flight tool calls, do not auto-retry with a new session" ) if status_code != 200: raise SessionResumptionViolation( f"unexpected status {status_code} on resume of {persisted.session_id!r}" ) # 200 with a different (or absent) session id is the exact shape of replay corruption. if response_session_id is None: raise SessionResumptionViolation( "resume response carried no Mcp-Session-Id; cannot bind event namespace" ) if response_session_id != persisted.session_id: raise SessionResumptionViolation( f"resume of {persisted.session_id!r} returned session " f"{response_session_id!r}; refusing to merge epochs" ) if first_event_id is None: raise SessionResumptionViolation( "resumed stream produced no event id; cannot prove continuity" ) if first_event_id == persisted.last_event_id: raise SessionResumptionViolation( f"resumed stream replayed last event {first_event_id!r}; " "would duplicate a side effect already acted on" ) # Caller must independently confirm first_event_id orders strictly after # persisted.last_event_id within this session before committing. return ResumeToken(session_id=response_session_id, last_event_id=first_event_id) ```