MCP Session Lifecycle
Use this skill when debugging session state, changing reconnection logic, working on the proxy, or reasoning about races between background rejoin and user-initiated tool calls.
Source checkpoint: 2026-09-04 at 6bff3e7b. The decision record is
docs/adr/mcp-session-lifecycle.md. Read the source functions below rather
than copying an abbreviated session struct or reconnect algorithm.
Three Layers
- Process supervision: installed
nteract-mcpand developmentmcp-supervisoruse therunt-mcp-proxylibrary to supervise arunt mcpchild. The library is not an executable entrypoint. - MCP session state: the child owns one active
NotebookSession, a bounded map of parked sessions, explicit activation generations, and the daemon watch loop. - Daemon room state:
runtimedowns notebook rooms, runtime state, kernels, recovery, and peer accounting. Kernel teardown and room reaping are separate.
The shipped MCP entrypoints use stdio. Multiple MCP clients can run separate children against the same daemon, including the same notebook room. Concurrent requests on one stdio connection share that child's active slot; they are not independent MCP clients. The daemon's multiplexed notebook frames are a separate protocol, not an HTTP MCP endpoint.
Entry points: crates/runt/src/lib.rs,
crates/nteract-mcp/src/lib.rs, and
crates/mcp-supervisor/src/main.rs.
Proxy Layer
McpProxy::track_session and session::extract_session_id track one preferred
restart target, despite the field name last_notebook_id:
- Successful connect/create calls prefer the child's canonical file path for local file-backed notebooks, falling back to a UUID. Hosted targets retain their URL identity.
- Successful
save_notebookpromotes a local UUID target to the saved path. - Disconnecting the active notebook clears the handoff. Disconnecting another parked notebook preserves it. Failed calls do not replace the target.
- Restart re-resolves the child executable and seeds
NTERACT_MCP_REJOIN_NOTEBOOK. It does not reconstruct the parked map. - Exit 75 is an intentional daemon-upgrade handoff, separate from the normal crash budget. Other restarts are subject to the proxy's restart controls.
- Daemon-version banners compare the old and new child's reported
ServerInfo.server_info.title, not binary SHA. A banner says rejoin was requested; it does not prove that notebook readiness has completed. - The
reconnecttool restarts the child, not the daemon.
See crates/runt-mcp-proxy/src/session.rs:17,
crates/runt-mcp-proxy/src/proxy.rs:310, :962, and :1268.
A closed-child forwarding failure is retried once; this is not an exactly-once
mutation guarantee (proxy.rs:658).
Active and Parked Sessions
NteractMcp holds the active slot and parked map in
crates/runt-mcp/src/lib.rs:119. NotebookSession in
crates/runt-mcp/src/session.rs carries the handle, target, activation identity,
readiness evidence, and local daemon incarnation when applicable.
Switching targets parks the previous peer instead of immediately disconnecting
it. MAX_PARKED_SESSIONS is eight; overflow removes an entry by arbitrary
HashMap iteration order. Parked peers keep their rooms from reaching zero
peers, so they can keep kernels alive. Local switch-back establishes a fresh
activation and removes the old parked peer after successful publication;
parked-handle reuse is not a universal reconnect contract.
Most notebook tools operate on the active target. Exceptions include explicit parked-session disconnect and notebook-ID resource reads against connected or parked local sessions. A parked map does not provide independent active tool contexts for multiple MCP clients.
See park_session, install_activated_session, and disconnect_notebook in
crates/runt-mcp/src/tools/session.rs:74, :742, and :949, and
handle_for_notebook in crates/runt-mcp/src/resources.rs:285.
The Watch Loop State Machine
crates/runt-mcp/src/daemon_watch.rs:238 reconciles session ownership against a
live daemon incarnation (pid + started_at), not a disconnect latch:
- A daemon event wakes the watcher. Lagged delivery also requires a fresh
observation. The watcher directly calls
query_daemon_info(socket_path); it does not decide fromDaemonConnection's cached heartbeat info. - A failed identity query alone is not proof of daemon loss. Without an
explicit
Disconnectedevent, defer reconciliation and retain the handles. - Compare a live version with the startup baseline. A mismatch exits with 75; if startup had no daemon, the first live version establishes the baseline.
- Remove active and parked local handles bound to a different incarnation, or to no live incarnation after confirmed absence. Hosted sessions are excluded from this local ownership reconciliation.
- Preserve the removed active session's best recovery target, preferring a saved path. Local recovery requires a live daemon and empty slot; try the proxy handoff target first, then that preserved target.
Hosted proxy handoffs are attempted immediately at watcher entry, without a local daemon event or incarnation. Failed hosted recovery has a bounded retry timer independent of the local event stream. Both paths retain the explicit session intent epoch and publication slot guards; local recovery still checks daemon incarnation before and after connection/readiness.
A same-incarnation heartbeat leaves healthy bindings alone. A same-version
restart changes incarnation and invalidates old local handles even if a
Disconnected event was missed. Removing parked local handles does not enqueue
recovery for every parked notebook.
See RecoveryState, reconcile_sessions, and watch. Focused tests in the
same file cover same-incarnation heartbeats, lagged delivery, failed identity
queries, stale parked handles, and tool-installed replacements.
The Session-Write Guard
Background rejoin connects outside the session lock. Local rejoin samples the
expected daemon incarnation before and after connection/readiness. Then
publish_rejoined_session checks the captured session_intent_epoch and slot
emptiness under the same write lock that installs the session. Any already
installed session wins, including one for the same notebook. Explicit
disconnect advances the epoch under that lock so a completed background
connection cannot resurrect the disconnected session.
Explicit connect/create activation has a separate generation owner:
SessionActivation. Same-target in-flight connects share a result. Selecting a
different canonical target supersedes the older attempt; A→B→A must not join
stale A work. ActivationLease::install_in_slot_recovering rechecks ownership
under the slot lock and restores the previous occupant if the installation
commit is refused. A failed replacement does not invalidate the healthy
installed session.
Use these production helpers, not a read-lock check followed by a separate
write. See crates/runt-mcp/src/daemon_watch.rs:365, :493, :567,
crates/runt-mcp/src/session_activation.rs:76, :225, and
crates/runt-mcp/src/tools/session.rs:977.
Session Access Pattern
An installed session is not a readiness guarantee. Acquire access through
require_session_access! or require_handle! with the appropriate
SessionRequirement. NteractMcp::session_access checks installed activation
identity and delegates to NotebookSession::access; the returned handle is
owned, so the slot lock is released before async work.
| Requirement | Gate |
|---|---|
ProjectionRead |
Retained projection or interactive document; use the bounded projection before interactivity |
DocumentRead, DocumentMutation |
Interactive local document with the required readiness evidence |
KernelControl |
Interactive document; a running kernel is not required to launch or restart it |
RuntimeRead |
Connected, ready local RuntimeStateDoc |
Execute |
Interactive document and ready runtime; execution keeps its causal required_heads gate |
Retained projection reads do not authorize mutations or execution. Local sync
failure, source degradation, runtime unreadiness, and superseded activation
have distinct error paths. Use ensure_session_access_current after async work
before continuing an operation on the captured active target. Do not keep a
session lock across connection, file loading, projection, or sync waits.
See crates/runt-mcp/src/tools/mod.rs:18, crates/runt-mcp/src/lib.rs:273,
crates/runt-mcp/src/session.rs:323, :485, and :595.
Local connect_notebook returns a retained control-plane projection while
peers converge. create_notebook and background local rejoin still await
session readiness. Do not apply the progressive-connect contract to all three
paths. The response fields and historical API sketch are distinguished in
docs/memos/mcp-connect-initial-projection.md.
Rejoin: File-Backed vs Ephemeral
Prefer a saved file path for automatic rejoin. The watcher verifies that the
source file exists and calls connect_open(path). UUID-only attachment is
also recoverable when the daemon has a resident room, a persistent UUID/path
registry binding with available source, or a persisted untitled document.
The registry is identity, not content: a missing source file is not permission
to load a stale mirror or invent an empty notebook.
For a UUID target, call connect(uuid) and trust the daemon's attach-only
admission. SyncError::NotebookUnavailable is definitive and records
Evicted without retry. Do not use list_rooms as an existence precheck; an
unlisted room may still be recoverable. Transient failures retain the recovery
target for later attempts.
Daemon authority is not an atomic check-and-load guarantee. The legacy snapshot
existence check at crates/runtimed/src/daemon.rs:3122 precedes awaited room
creation. If that snapshot disappears and no journal is recovered,
crates/runtimed/src/notebook_sync_server/room.rs:1945–1955 can create a fresh
document. Keep this limitation distinct from the already-absent UUID refusal.
Persisted untitled notebooks are not the same as explicitly ephemeral
notebooks. MCP create_notebook defaults to ephemeral=true; do not promise
recovery after loss of its content merely because a UUID is known.
See crates/runtimed/src/daemon.rs:3041,
crates/runt-mcp/src/daemon_watch.rs:485, :506, :615, and
crates/runt-mcp/src/tools/session.rs:1602. The daemon integration tests at
crates/runtimed/tests/integration.rs:4015 and :4054 cover refusal without a
phantom room and saved-path recovery by UUID across restart.
Daemon Room Lifetime
Only the last peer leaving schedules kernel teardown, after keep_alive_secs
(default 30 seconds). Room state, autosave, and file watchers remain resident.
Teardown revalidates peer count and connection generation before destructive
work; the destructive latch tells reconnecting peers not to reuse a doomed
kernel.
The ghost-room reaper separately sweeps eligible peerless, kernel-less rooms every five minutes, with a 24-hour TTL and soft cap of 32. Removal requires the durability barrier and final admission checks; reconnects and reservations protect rooms from stale reaping decisions.
See crates/runtimed/src/notebook_sync_server/peer_eviction.rs:107, :269,
and crates/runtimed/src/daemon.rs:484, :5834. Kernel teardown is not proof
that a notebook has become unavailable.
Session Drop Tracking
last_session_drop is best-effort recovery context, not another session:
Switched: the old target was replaced and may still be parked.Disconnected: stale local ownership was removed, recovery failed, or the user explicitly disconnected. Explicit disconnect cancels automatic rejoin.Evicted: rejoin received a definitive unavailable refusal or found a missing saved source. It does not mean every kernel keepalive timeout deletes a room.
SessionDropInfo retains notebook ID, path, and rejoin target for
no_session_error. See crates/runt-mcp/src/session.rs:636 and the recording
sites in daemon_watch.rs and tools/session.rs.
Concurrent MCP Clients and Attribution
Separate MCP children sharing a daemon are implemented. Each child has its own
active selection; the room can have multiple peers. The upstream handshake
supplies the display label and an agent:<slug>:<session> operator suffix.
The proxy preserves the suffix across child restarts. Attribution is not a
separate authorization boundary for every same-user local client.
Multiple independently routed MCP clients inside one child are not implemented
by the shipped stdio entrypoints. Neither concurrent request IDs nor parked
notebooks provide that routing. See crates/runt-mcp/src/lib.rs:48, :119,
:464, and crates/runt-mcp-proxy/src/proxy.rs:180, :227.
MCP Protocol Checkpoint
crates/mcp-transport/src/lib.rs supports both legacy initialize-based
sessions and native MCP 2026-07-28 per-request negotiation. The first valid
native request opens the lifecycle; invalid metadata must not start recovery
or runtime setup. require_protocol, ProtocolSession::wait_for_native, and
server_with_protocol keep the two modes distinct. A legacy initialized
session cannot switch to native negotiation in place. Read this source and
its wire tests before changing transport admission; support for the native
lifecycle is not a blanket claim of complete protocol conformance.