Lifecycle & failure modes
Every WebSocket connection to the MCP gateway produces a session that walks this state machine:
States
Section titled “States”- Disconnected - no WebSocket yet. Tooling surface shows no tools for this app.
- Handshaking - WebSocket open,
tesseron/helloin flight. - Awaiting claim -
welcomesent with aclaimCode. Actions are registered in the gateway but not exposed as MCP tools until claim. - Claimed - agent has submitted a matching claim. Tool list is published. Actions can be invoked.
- Closed - WebSocket closed. Session forgotten by the gateway.
Transitions
Section titled “Transitions”| From → To | Trigger | Side effects |
|---|---|---|
| Disconnected → Handshaking | App opens WebSocket. | tesseron/hello sent. |
| Handshaking → Awaiting claim | MCP gateway returns welcome. | Claim code generated + printed to gateway stderr. |
| Awaiting claim → Claimed | Agent calls tesseron__claim_session with matching code. | notifications/tools/list_changed fires. |
| Awaiting claim → Closed | WebSocket closes or agent never claims within TTL. | Claim code invalidated. |
| Claimed → Closed | WebSocket closes. | All in-flight invocations aborted; subscriptions dropped; tools/list_changed fires so the agent drops stale tools. |
The TTL for an unclaimed session is currently unset; the session persists as long as the WebSocket stays open. In practice, a tab close terminates the WebSocket within seconds.
What pending work does on close
Section titled “What pending work does on close”From inside your handler, on any Closed transition:
ctx.signal.abortedbecomestrue.ctx.progress(…)after close is silently dropped.ctx.sample(…)/ctx.confirm(…)/ctx.elicit(…)in flight reject withTransportClosedError.- The invocation response never reaches the agent - the agent's MCP client detects the tool call ending abruptly and surfaces that to the user.
Handler best practices:
.handler(async (input, ctx) => { const abortable = new AbortController(); ctx.signal.addEventListener('abort', () => abortable.abort()); try { return await longWork(input, { signal: abortable.signal }); } finally { abortable.abort(); // release any resources even on normal exit }});Reconnection: plain tesseron/hello starts over, tesseron/resume doesn't
Section titled “Reconnection: plain tesseron/hello starts over, tesseron/resume doesn't”A plain tesseron/hello after a disconnect yields a new sessionId and new claimCode. The agent must re-claim. In-flight work from the old session is gone.
Why not resumable by default at the protocol level? Two reasons:
- The agent's tool list is cached around the old session. Silently rebinding would make the old tool names still appear to work, while pointing at a different session. That's worse than requiring a fresh claim.
- Claim is meant to be a user-visible act. An invisible reconnection would bypass the "human-in-the-loop authorisation" the claim code represents.
Session resume (tesseron/resume with a stored resumeToken) sidesteps this by binding the new socket to a specific previously-claimed sessionId. The agent sees the same tool list, the user does nothing. @tesseron/web performs this round-trip automatically by default — see the resume page for the four shapes the resume option accepts and how to opt out.
MCP gateway restart
Section titled “MCP gateway restart”If the gateway process dies (plugin disabled, Claude Code restart, crash):
- Every app's WebSocket closes with code 1001 (
Going Away). - Every SDK instance aborts in-flight work and rejects pending requests.
- Apps are free to
connect()again when the gateway comes back. Zombie sessions are held in gateway-process memory only, so a gateway restart wipes every resumable session — a reconnect after gateway restart will always be a freshtesseron/hellowith a new claim code, regardless of what the SDK had stored.
A small "reconnect" loop in your app UI - with exponential backoff - is reasonable. Expose the new claim code to the user when the new session is established.
Next: the security model.