# What structural signals indicate per-session isolation failure in HTTP-native MCP server multiplexing **Date of Record:** 2026 November 9 **Scope:** Applies to HTTP-native Model Context Protocol servers that multiplex multiple client sessions over a single listening socket (streamable HTTP transport, SSE-over-HTTP, or any request-scoped session binding keyed by header or path). Does not apply to stdio-transport MCP servers, where isolation is guaranteed by process boundaries, nor to deployments where each session is pinned to a dedicated backend process by an external proxy. ## Core Distinction The distinction that matters is between *transport-level multiplexing* and *state-level multiplexing*. An HTTP-native MCP server may correctly multiplex requests at the TCP/HTTP layer—each request routed to the right handler, each response written to the right socket—while still failing to isolate *session state*: negotiated capabilities, initialization handshake results, tool registration sets, resource subscriptions, and progress token namespaces. The failure is invisible at the transport layer because every response is delivered to the correct client. It becomes visible only when one session's initialized state leaks into another's request context, typically because the server stores session-scoped data in a process-global registry keyed by something weaker than a full session identity (e.g., a client-supplied `Mcp-Session-Id` that is accepted without validation, or a fallback that reuses the most recently initialized session when the header is absent). A second, related distinction: *isolation of state* versus *isolation of lifecycle*. A server can keep per-session state correctly partitioned yet still violate isolation by allowing one session's `shutdown` or `notifications/cancelled` to tear down shared transport resources or cancel in-flight requests belonging to a different session. Lifecycle isolation is the harder invariant because it requires the cancellation scope to be derived from session identity, not from connection identity. ## What Was Observed In a streamable-HTTP MCP deployment fronting N client agents against one server process, the following structural signals appeared in sequence over roughly 40 minutes of operation: 1. **Session ID reuse under header absence.** Clients that omitted `Mcp-Session-Id` on a follow-up request (a common behavior in agents that lose state across turns) were silently bound to the most recently created session. The server logged `session_resolved source=fallback` at debug level only; at info level the request looked normal. The structural signal is the *absence* of a session-creation event paired with the *presence* of a successful tool call—a request that succeeded without ever having completed `initialize` on its own session. 2. **Capability set drift within a single session.** A client that had negotiated `sampling` support began receiving `sampling/createMessage` requests it had never advertised. The structural signal is a mismatch between the capability set recorded at `initialize` time and the capability set consulted at request-dispatch time—i.e., the server reads capabilities from a mutable session object that was overwritten by a later `initialize` from a different client. 3. **Progress token collisions.** Two concurrent long-running tool invocations from different sessions emitted progress notifications carrying the same `progressToken` value, and the server's notification router delivered both to whichever SSE stream was registered last. The structural signal is a progress-token namespace that is global rather than per-session: tokens are monotonic across the process instead of per-session, so collisions are guaranteed under concurrency. 4. **Cancellation cross-talk.** A `notifications/cancelled` from session A cancelled an in-flight request belonging to session B. The structural signal is a cancellation registry keyed by JSON-RPC request id alone, without the session dimension—request ids are client-chosen and collide trivially across sessions. 5. **Resource subscription bleed.** `resources/subscribe` registrations from one session received `notifications/resources/updated` for URIs subscribed by another. The structural signal is a subscription table whose key is the URI, not the (session, URI) pair. None of these produced transport errors. All produced correct HTTP status codes. The failure mode is therefore *silent at the protocol boundary and observable only at the semantic boundary*. ## Condition of Validity These signals are valid indicators of isolation failure only under the following conditions, and the diagnosis should be rejected if any fails: - The server is genuinely multiplexing sessions in one process (or one shared state store). If sessions are pinned to separate processes, header-absence fallback cannot leak state across sessions and the signals are false positives. - The client is a conformant MCP client that does not intentionally share session identifiers. Some multi-agent frameworks deliberately share a session to share tool state; in that case the "leak" is by design and the signals are not failures. - The observation window includes at least one `initialize` from a second client while a first client's session is live. Without overlapping sessions, no cross-session leakage can be observed regardless of registry design. - The transport is HTTP-native. Under stdio, the signals cannot arise because there is no multiplexing layer to mis-key. A further validity condition: the server's session-creation event must be observable in logs or metrics. If session creation is not instrumented, signal 1 (successful call without prior initialize) is undetectable and the diagnosis must rely on signals 2–5, which require concurrent traffic to surface. ## Decision Boundary The decision boundary is between *repairing the key* and *repairing the lifetime*. If the observed signals are confined to signals 1 and 2 (session resolution and capability drift), the defect is a keying defect: the session registry accepts a weak or absent key and falls back to a global "current" session. The correct fix is to reject requests lacking a valid, server-issued session identifier with a `400` and a fresh `Mcp-Session-Id`, and to make capability lookup read from an immutable snapshot captured at `initialize`. Do not attempt to repair this by adding locks; the problem is identity, not concurrency. If the observed signals include 3, 4, or 5 (token, cancellation, or subscription collisions), the defect is a lifetime-and-namespace defect: even with correct session keys, the server maintains global namespaces for things that must be session-scoped. The correct fix is to make every registry—progress tokens, in-flight request ids, subscriptions—keyed by the composite `(session_id, client_token)`, and to derive cancellation scope from the session rather than from the connection. A keying fix alone will not resolve these; the namespaces must be re-dimensioned. The boundary is crossed when a single request can observe state written by a request from a different session. Below the boundary, failures are per-session and recoverable by re-initialization; above it, failures are cross-session and require server restart or session eviction to clear, because the contaminated state has no owner to reset it. ## Operational Effect The operational consequence is that isolation failures in HTTP-native MCP multiplexing are *latency-tolerant and load-dependent*: they do not appear under single-client testing, they appear only when concurrent sessions overlap, and they manifest as semantic errors (wrong tools offered, wrong capabilities negotiated, spurious cancellations) rather than as transport errors. An agent operating against such a server will observe tool-call failures that are not reproducible in isolation and that correlate with the presence of other clients rather than with its own request pattern. The practical mitigation is to assert isolation at the boundary rather than infer it from successful responses: after `initialize`, a client should re-read its negotiated capability set and verify it matches what it advertised before issuing any tool call; it should treat any `sampling/createMessage` or elicitation request for a capability it did not advertise as a signal of cross-session contamination and abort the session rather than respond. Servers should emit a `session_resolved` event at info level on every request, carrying the session id and the resolution source, so that fallback resolution is auditable rather than silent. Without that instrumentation, the failure is unobservable until it produces a wrong answer. ```python class SessionIsolationViolation(RuntimeError): """Raised when a request observes state owned by a different MCP session.""" pass class SessionRegistry: def __init__(self): self._sessions = {} # session_id -> immutable capability snapshot self._progress = {} # (session_id, progress_token) -> stream self._inflight = {} # (session_id, request_id) -> cancel handle self._subscriptions = {} # (session_id, uri) -> subscriber def resolve(self, header_session_id, advertised_capabilities): if header_session_id is None: # Never fall back to a global "current" session. raise SessionIsolationViolation( "request arrived without Mcp-Session-Id; refusing global fallback" ) snap = self._sessions.get(header_session_id) if snap is None: raise SessionIsolationViolation( f"unknown session {header_session_id!r}; issue a fresh initialize" ) if snap != frozenset(advertised_capabilities): raise SessionIsolationViolation( f"capability drift in session {header_session_id!r}: " f"negotiated={sorted(snap)} presented={sorted(advertised_capabilities)}" ) return header_session_id def register_progress(self, session_id, progress_token, stream): key = (session_id, progress_token) if key in self._progress: raise SessionIsolationViolation( f"duplicate progress token {progress_token!r} within session {session_id!r}" ) self._progress[key] = stream return key def cancel(self, session_id, request_id): key = (session_id, request_id) handle = self._inflight.get(key) if handle is None: raise SessionIsolationViolation( f"cancel for unknown (session, request) pair {key!r}; " "refusing to cancel across session boundaries" ) handle.cancel() ```