# Which invariants prevent silent event loss when resuming MCP HTTP-native SSE streams under per-tool OAuth resource indicators **Date of Record:** 2026 November 11 **Scope:** Applies to MCP servers that expose the HTTP-native streaming transport (POST request → SSE response, with `Last-Event-ID` resumption) and enforce per-tool OAuth 2.0 resource indicators (RFC 8707) on the `aud` claim. Does not apply to stdio transports, to servers that buffer whole responses, or to deployments where a single resource indicator covers all tools. ## Core Distinction There are two distinct notions of "the same stream" in this transport, and they are not interchangeable: - **Transport identity:** the SSE stream is identified by the `Mcp-Session-Id` plus the event ID sequence. Resumption is a transport-layer operation: reconnect with `Last-Event-ID: ` and the server replays events `n+1...` from its buffer. - **Authorization identity:** each tool invocation carries a resource indicator that determines the `aud` of the access token required to execute it. The token is scoped to a specific resource (often a per-tool URI such as `https://mcp.example.com/tools/search`). The invariant that is easy to violate: **a resumed stream is replayed against the authorization context of the *reconnect* request, not the authorization context of the original request.** If the reconnect presents a token whose resource indicator differs from the original — because the client rotated tokens, because a multi-tool session multiplexes several resource indicators over one stream, or because a token refresh minted a narrower `aud` — the server may accept the transport resume (valid session, valid event cursor) but reject or silently drop events that belong to tool calls authorized under the original indicator. ## What Was Observed A client opened a stream with a token whose `aud` was the aggregate resource `https://mcp.example.com`. It issued `tools/call` for two tools, `search` and `fetch`. Mid-stream, the connection dropped after event ID 41. The client refreshed its token; the authorization server, following a least-privilege policy, issued a token with `aud: https://mcp.example.com/tools/search` only. The client reconnected with `Last-Event-ID: 41` and the narrower token. The server returned `200 OK`, replayed events 42–47 (results for `search`, which the token authorized), then reached event 48, a `fetch` result. Because the event's required resource indicator (`.../tools/fetch`) was not in the token's `aud`, the server did not emit an error frame on the stream — it terminated the replay and closed the connection with a normal `event: end`. The client saw a clean stream end and concluded the `fetch` call had produced no output. The result was lost with no protocol-level signal: no `401`, no `403`, no JSON-RPC error. The `fetch` tool call remained `pending` in the client's bookkeeping until a separate timeout fired. The failure is non-obvious because every individual component behaved correctly: the transport resumed, the auth check on each event was correct, and the stream close was well-formed. The loss is an emergent property of composing resumable replay with per-event authorization. ## Condition of Validity The invariant "resume replays the full remaining buffer" holds only when the following are all true: 1. The reconnect token's `aud` set is a **superset** of the union of resource indicators for all tool calls whose events remain in the replay buffer. 2. The server treats an authorization failure on a buffered event as a **hard error** (emitting an SSE `event: error` or closing with a non-2xx status on the resume), not as end-of-stream. 3. The client correlates each `tools/call` with a terminal event (result or error) before considering the call complete, rather than treating stream termination as implicit completion. If (1) fails and (2) is not enforced, events are silently dropped. If (1) fails and (2) is enforced, the client gets a resumable error and can re-authorize with a broader token — the correct recovery. If (3) fails, the client cannot distinguish "no output" from "output lost," regardless of server behavior. ## Decision Boundary - **Single-tool streams:** resume with any token whose `aud` includes that tool's resource indicator. No special handling needed; the invariant is trivially satisfied. - **Multi-tool streams with token rotation:** do **not** resume with a narrowed token. Either (a) resume with a token whose `aud` covers every tool with buffered events, or (b) do not resume — open a fresh stream and re-issue the pending calls, accepting the loss of in-flight events. Option (b) is correct when the authorization server will not mint a broad token; it trades replay efficiency for correctness. - **Servers implementing replay:** treat per-event authorization failure as a terminal, resumable error. Emit `event: error` with the offending event ID and the required resource indicator, then allow the client to reconnect with `Last-Event-ID` set to the last successfully delivered event. Do not advance the cursor past an event that was not delivered. - **Clients:** never treat `event: end` or a closed connection as evidence that a `tools/call` completed. Require an explicit result or error correlated by request ID. ## Operational Effect The practical consequence is that resumable SSE and per-tool resource indicators are **not independently composable**. Resumption assumes the replay is authorization-neutral; per-tool indicators make every event authorization-bearing. The composition is safe only under an explicit superset invariant on the resume token, enforced by both the server (hard error on unauthorized buffered event, cursor not advanced) and the client (terminal-event correlation per call). Absent that, the failure mode is silent loss of tool results — the worst class of failure, because it is indistinguishable from a tool that legitimately returned nothing, and it surfaces only as a downstream timeout far from the actual cause. When the superset invariant cannot be guaranteed, prefer abandoning the stream and re-issuing calls over resuming with a narrowed token. ```python from dataclasses import dataclass, field class StreamResumeAuthorizationError(RuntimeError): """Raised when a resumed SSE stream cannot legally replay a buffered event.""" @dataclass class BufferedEvent: event_id: int tool_resource_indicator: str # e.g. "https://mcp.example.com/tools/fetch" @dataclass class ResumeRequest: session_id: str last_event_id: int token_audiences: frozenset[str] buffer: list[BufferedEvent] = field(default_factory=list) def plan_replay(req: ResumeRequest) -> list[BufferedEvent]: """ Decide which buffered events may be replayed to a resuming client. Invariant: the resume token's `aud` set must be a superset of the resource indicators of every buffered event at or after the cursor. If it is not, we must NOT silently skip the event (that loses tool results) and we must NOT advance the cursor past it. We raise so the caller can emit a resumable error frame and leave `Last-Event-ID` at the last delivered event. """ pending = [e for e in req.buffer if e.event_id > req.last_event_id] if not pending: return [] required = {e.tool_resource_indicator for e in pending} missing = required - req.token_audiences if missing: offending = next(e for e in pending if e.tool_resource_indicator in missing) raise StreamResumeAuthorizationError( f"cannot resume session {req.session_id!r} at event " f"{req.last_event_id}: buffered event {offending.event_id} requires " f"resource indicator {offending.tool_resource_indicator!r} not present " f"in token audience {sorted(req.token_audiences)!r}; " f"re-authorize with a superset audience or abandon the stream" ) return pending def handle_resume(req: ResumeRequest) -> list[BufferedEvent]: try: return plan_replay(req) except StreamResumeAuthorizationError: # Caller must emit `event: error` and keep the client's cursor unchanged. raise ```