# Which invariants prevent silent session-identity drift when resuming an MCP SSE session across the streamable-HTTP transport boundary under OAuth 2.1 resource-server binding **Date of Record:** 2026 November 14 **Scope:** Applies to MCP servers that expose both the legacy HTTP+SSE transport (`GET /sse` + `POST /messages`) and the streamable-HTTP transport (`POST /mcp` with `Mcp-Session-Id`), where a client may resume an interrupted SSE stream by re-issuing requests against the streamable endpoint carrying a prior session identifier, and where the resource server (RS) validates bearer tokens bound to a specific audience and `sub`. Does not apply to stdio transports, to pure SSE deployments with no streamable endpoint, or to RS configurations that perform token introspection on every request without session caching. ## Core Distinction A *session identifier* (`Mcp-Session-Id`) and a *resource-server binding* (the `aud`/`sub`/`scope` triple validated against the token) are two independent namespaces that the MCP transport spec treats as orthogonal. The SSE-to-streamable resumption path is the only place in the protocol where a client presents both simultaneously: it carries the old session id forward while presenting a token that may have been re-minted, refreshed, or issued by a different authorization server instance. The invariant that must hold is not "the session exists" but "the session was created by the same principal that the current token authorizes, under the same audience." Silent drift occurs when the transport layer accepts a session id whose creation-time principal differs from the request-time principal, because the streamable endpoint's session lookup is keyed only on the opaque id string. ## What Was Observed A client opened an SSE session against `/sse`, received `Mcp-Session-Id: A1`, and began a long-running `tools/call` whose result stream was interrupted at the proxy (a 502 after 90 s of idle). The client's OAuth library refreshed the access token in the background; the new token carried the same `sub` but a re-issued `aud` claim (the AS rotated the audience value from `mcp://srv` to `mcp://srv/v2` during a deploy). The client resumed by POSTing to `/mcp` with `Mcp-Session-Id: A1` and the refreshed token. The streamable endpoint found session `A1` in its in-memory table, validated the token signature and expiry, and replayed the buffered event stream — but the session's stored `aud` binding was the pre-rotation value. The RS's per-request audience check passed (the new `aud` was valid), while the session's *recorded* binding was never re-checked. The client received tool output that had been authorized under the old audience, and subsequent `tools/list` calls on the same session returned a tool set that the new audience was not entitled to. No error was raised. The drift was detected only when a downstream resource rejected the tool's output because the tool itself had been called with a scope the rotated token lacked. ## Condition of Validity The drift is possible only when all of the following hold: (1) the server maintains session state across the transport boundary rather than re-deriving it per request; (2) the session record stores the authorization context captured at creation time; (3) the streamable endpoint's resume path validates the *presented* token but does not compare it against the *recorded* binding; and (4) the authorization server can change `aud` or `scope` for a stable `sub` without invalidating outstanding tokens. If any of these fails — e.g., the server re-derives the binding on every request, or the AS uses token binding that pins `aud` for the token lifetime — the invariant holds trivially. The failure is therefore a property of the *composition* of session caching and OAuth rotation, not of either mechanism alone. ## Decision Boundary The correct boundary is: **a resumed session must be re-authorized against the current token's full binding, not merely authenticated.** Concretely, on every streamable-HTTP request that carries `Mcp-Session-Id`, the server must compare the request token's `(aud, sub, scope)` against the session record's stored binding and reject on any mismatch with `403` and a fresh session id — *even when* the token itself is valid and unexpired. The tempting shortcut — "the token is valid, the session exists, proceed" — is exactly the drift. The boundary is between *authentication* (is this a valid token?) and *authorization continuity* (is this the same authorization context the session was created under?). Resumption must fail closed on the second question. A corollary: session ids must be treated as *bearer-equivalent* secrets and rotated on any binding change, because a leaked id plus a valid token from a different principal is otherwise sufficient to hijack the buffered stream. ## Operational Effect When the invariant is enforced, the observable behavior is that a token refresh that changes `aud` or `scope` forces a new session: the client receives `403` with `Mcp-Session-Id` absent, re-initializes, and re-issues in-flight calls. This is correct but disruptive — long-running tool calls are lost. When the invariant is *not* enforced, the observable behavior is silence: the client believes resumption succeeded, the server believes it authorized the request, and the divergence surfaces only as downstream authorization failures or, worse, as data returned under a stale entitlement. The operational lesson is that the streamable-HTTP resume path is the single point where two independently-rotating namespaces (session lifetime and token binding) meet, and that point must be instrumented with an explicit binding-equality assertion rather than relying on the token validator's success as a proxy for continuity. ```python class SessionBindingDriftError(RuntimeError): """Raised when a resumed MCP session's recorded authorization binding does not match the binding of the token presented on the resume request.""" pass def resume_streamable_session( session_id: str, presented_token: dict, session_store: dict, ) -> dict: """Resume an SSE-originated session on the streamable-HTTP endpoint. presented_token is the decoded, signature-validated token claim set: {"aud": str, "sub": str, "scope": str, "exp": int} session_store maps session_id -> {"aud", "sub", "scope", "events"}. """ record = session_store.get(session_id) if record is None: raise SessionBindingDriftError( f"unknown session {session_id!r}; refusing to resume" ) recorded = (record["aud"], record["sub"], record["scope"]) presented = ( presented_token["aud"], presented_token["sub"], presented_token["scope"], ) # Authentication succeeded upstream; this is the authorization-continuity # check that prevents silent drift across the transport boundary. if recorded != presented: # Fail closed: drop the buffered stream and force re-initialization. del session_store[session_id] raise SessionBindingDriftError( "session binding drift on resume: " f"recorded={recorded!r} presented={presented!r}; " "session invalidated, client must re-initialize" ) return {"session_id": session_id, "events": record["events"]} ```