Repository navigation
MCP sessions: a connected agent keeps working after a SatGate restart - #246
Merged
Merged
Conversation
…oken it was issued to Session ids now carry an HMAC tag keyed by the opening token's SHA-256. In header auth mode, a request with an unknown id is served again under that id only when its bearer token verifies (same checks as initialize, plus the revocation checker) and the id's tag matches that token. A DELETEd id is not brought back on the process that ended it. Other auth modes keep answering 404.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
matt-dean-git
marked this pull request as ready for review
October 16, 2026 22:11
…er the insert lock, ResumesStreamSessions Round 2 on #246 (reviews on satgate-enterprise#522): - An unexpired DELETE tombstone is never dropped. When a DELETE finds the tombstone map full of unexpired entries, its id is not recorded and no session is resumed on this process until that id's TTL is over; while the map is full every resume is refused. initialize and live sessions are not affected. - The tombstone check for a resume runs again under streamMu, in the same critical section as the insert, so a DELETE that completed first wins. - SSEServer.ResumesStreamSessions() reports whether unknown ids are resumed (header auth mode); the enterprise wrapper forwards only when it is true.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft. Head: 4675f52 (base 4f7f276). Companion: https://github.com/SatGate-io/satgate-enterprise/pull/522
Problem
Streamable HTTP sessions live in process memory. Every deploy, Fly host event or request that lands on another machine loses them, and the server answers the client's Mcp-Session-Id with 404 "session not found".
What clients do with that 404 (measured on Dean, local gateway restarted mid-session)
Each run used a local satgate-mcp built from main, header auth, a logging proxy in front, and an isolated HOME. The gateway was restarted between two tool calls in one client process.
Streamable HTTP error: Error POSTing to endpoint: session not found. It keeps sending the dead id, so every later call fails until the client is restarted. It never re-initializes.With this branch, all three made their calls across one or more restarts under the same session id with no error: Claude Code (3 turns), goose, and Hermes (whose session survived 3 restarts).
Design
(a) Resume under the same id. Only in header auth mode, and only for the token the id was issued to.
Session ids are now
mcp-<32 hex random>-<32 hex tag>, where the tag is HMAC-SHA256 keyed by SHA-256(opening token) over the random part. No server secret is needed, so any process or machine can check the tag.Suppose a POST carries an id this process does not hold. The server serves it again under that id when all of these hold:
The protocol comes from the MCP-Protocol-Version header (400 if unsupported), or 2025-03-26 if the header is missing. The resumed session counts against the session cap and publishes session_connect with
resumed: true.Unchanged: DELETE and GET of an unknown id, auth modes none and config (they still answer 404), legacy SSE, and every check on a live session.
Why not a Redis session registry: the public module has no Redis. A registry would need a write on every initialize and DELETE, a read on every miss, and a plan for Redis being down. It would also move nothing that matters: a session holds only the token hash, the verified identity and the protocol, and all of that can be rebuilt from the verified token and one header. (a) is stateless across machines, so it also covers requests that land on the second staging machine.
Why bind the id to the token: session ids are logged and shown in events, so they are not secret the way tokens are. With plain (a), anyone holding any valid token could post the id first after a restart and own it. The owner would then get 403 forever, and Claude Code does not recover from that.
Security argument
The id alone grants nothing. A resume runs the initialize checks on the presented token, and every later request is checked against the token hash exactly as before.
No takeover. The tag must match the presented token (128-bit tag, constant-time compare). A different token, from the same tenant or another one, gets 404, nothing is created, and the owner can still resume afterwards.
Revoked or expired tokens are refused before any session exists. A request without a valid token gets 401.
What a token may call is unchanged: calls go through the same Proxy.handleRequest with the resuming token's verified TokenInfo.
DELETE still ends a session. The process that ended it never brings the id back. A process that never saw the DELETE (after a restart, or another machine) serves it again only to the same valid token. That token could open an identical session with initialize at any time.
Limits cannot be bypassed by forcing a new session, because none of them is per session:
Resuming keeps the session id, so the enterprise spend caller scope (and its 24 h duplicate guard) is kept. A call re-sent after the restart is still a duplicate (enterprise end-to-end test).
Memory is bounded: resumed sessions count against the cap, and tombstones use the same cap and sweep.
Known effect: ids issued before this change have no tag, so they are not resumed. The deploy that ships this still breaks Claude Code sessions one last time.
Tests
New in pkg/mcpserver/streamable_resume_test.go. A "restart" is a new Proxy and SSEServer with an empty session map and the same authenticator.
Main run: these tests copied into
git archive 4f7f2768,go test -run Resume ./pkg/mcpserver/, exit 1.Gates on this branch:
gofmt -lon the changed files prints nothing;go vet ./...exit 0;go test -race -count=1 ./pkg/mcpserver/...exit 0.Enterprise bump needed
The enterprise wrapper (internal/mcpgateway/session_spend_http.go) answers 404 for an unknown id before this handler runs. The companion enterprise PR changes that and works with the pinned module, where the answer stays 404. Its end-to-end tests skip until gateway/go.mod is bumped to a version that contains this commit. The bump itself is not part of this lane. With a local
-modfilereplace pointing at this branch, the enterprise gates (vet, build,go test -raceover ./internal/mcpgateway/... ./internal/cloud/...) all pass.CI (head 4675f52)
Actions run 38971164434: Go, Node.js SDK and Python SDK pass; Vercel passes.
Round 2
Answers the NO-GO reviews on satgate-enterprise#522 (Astra B1-B4, Grok B1-B3). Head: 8f02065 (round 1 head 4675f52).
SSEServer.ResumesStreamSessions()(pkg/mcpserver/streamable_resume.go:90), true only in header auth mode. The enterprise wrapper (#522) type-asserts it and forwards an unknown id only when it is there and true. With the module gateway/go.mod pins today it is absent, so Cloud keeps main's immediate 404.markStreamSessionEndedLocked). While the tombstone map (capped at the session cap) is full of unexpired entries, every resume on that process is refused with the existing 404 (streamEndedFullLocked,streamResumeRefusedLocked). A DELETE that finds the map full cannot record its id, so every resume on that process stays refused until that id's own TTL is over (streamEndedFullUntil). Otherwise that id could come back as soon as the earliest tombstone expired. initialize and live sessions are not affected.addStreamSession->streamSessionEndedHere-> 404). A DELETE that completes first always wins.New tests (pkg/mcpserver). Each was also run on the round 1 head 4675f52:
ended[2] with the tombstone map full: status=200deleted session revived under capacity pressure: got 200resume paused across the DELETE: status=200iteration 0: 18 requests sent after the DELETE returned were servedEvery round 1 test is unchanged and passes.
Gates on this head:
gofmt -lon the changed files: empty.go vet ./...: exit 0.go test -race -count=1 ./pkg/mcpserver/...: exit 0 (38.7 s).CI on 8f02065: Actions run 38990828458: Go pass (3m16s), Node.js SDK pass, Python SDK pass; Vercel and Vercel Preview Comments pass.