MCP Session Management#
Overview#
MCP (Model Context Protocol) sessions in LangBot manage the full lifecycle of connections to external tool servers. Each server configuration is wrapped in a RuntimeMCPSession object, which handles transport initialization, tool discovery, tool invocation, health monitoring, and shutdown. All sessions are orchestrated by MCPLoader, which loads server configurations from the database at startup.
Key files:
src/langbot/pkg/provider/tools/loaders/mcp.pyβRuntimeMCPSession,MCPLoadersrc/langbot/pkg/provider/tools/loaders/mcp_stdio.pyβBoxStdioSessionRuntime,MCPSessionErrorPhase,_ColdStartRetry
Session Lifecycle#
Each RuntimeMCPSession runs a single persistent background asyncio task β _lifecycle_loop_with_retry β that drives the full session state machine.
Startup#
start() launches the background lifecycle task and blocks on _ready_event.wait() with a configurable timeout (30 s for remote/SSE/HTTP; box_config.startup_timeout_sec + 30 for Box stdio) . The _ready_event (asyncio.Event) is set by _lifecycle_loop once the connection succeeds or all retries are exhausted, ensuring start() doesn't return until the session is in a terminal state.
States#
MCPSessionStatus has three values :
CONNECTINGβ initial / mid-retryCONNECTEDβ healthy, tools availableERRORβ all retries exhausted or non-retriable failure
Shutdown#
shutdown() sets _shutdown_event, then waits up to 5 s for the lifecycle task to exit cleanly. If the task times out, it is cancelled. The _lifecycle_loop finally-block calls exit_stack.aclose() and clears functions/resources/session .
Transport Modes#
_lifecycle_loop dispatches to a transport-specific initializer based on server_config['mode'] :
| Mode | Initializer | Notes |
|---|---|---|
stdio | _init_stdio_python_server | Routes to Box sandbox or legacy host process |
remote | _init_remote_server | Auto-detects Streamable HTTP β SSE fallback |
sse | _init_sse_server | Explicit SSE |
http | _init_streamable_http_server | Explicit Streamable HTTP |
For remote mode, Streamable HTTP is tried first; if the server responds with HTTP 400, 404, or 405 (or the MCP SDK Session terminated sentinel mapped from 404), the transport is torn down and SSE is tried instead .
Retry Strategy with Exponential Backoff#
Fatal (non-transient) failures go through _lifecycle_loop_with_retry :
- Max retries: 3 (4 total attempts) β
_MAX_RETRIES = 3 - Backoff delays:
[2, 4, 8]seconds β_RETRY_DELAYS - BOX_UNAVAILABLE short-circuit: this error phase bypasses all retries immediately, since retrying when Box is disabled is pointless
Two special exception types bypass the fatal retry budget entirely:
| Exception | Behavior |
|---|---|
_TransportReconnect | WS transport dropped but managed process still alive; re-attaches transport immediately with a 1 s sleep |
_ColdStartRetry | Process alive but MCP handshake not yet answerable (e.g., npx -y installing); retries with 2 s sleep, preserving the live process |
Both paths reset status to CONNECTING, clear error_message/error_phase, and call continue without incrementing attempt.
Concurrency: asyncio.Event Primitives#
Two asyncio.Event objects coordinate the session lifecycle :
_ready_eventβ set once the session reachesCONNECTEDor terminallyERROR.start()awaits this; any call toget_tools()/invoke_mcp_tool()before_ready_eventis set will block or seesession = None._shutdown_eventβ set byshutdown(). Checked in_lifecycle_loop_with_retrybefore each retry; once set, no further retries or reconnects are attempted.
For Box stdio sessions, a second coroutine (monitor_process_health) runs concurrently with _shutdown_event.wait() using asyncio.wait(..., return_when=FIRST_COMPLETED) . When the monitor returns first, the lifecycle task checks whether the managed process is still running and raises either _TransportReconnect or a terminal exception accordingly.
Error Phase Tracking#
MCPSessionErrorPhase (defined in mcp_stdio.py) tags which lifecycle stage failed, surfaced via get_runtime_info_dict() :
| Phase | Meaning |
|---|---|
session_create | Box session creation failed |
dep_install | Dependency installation failed |
process_start | Managed process failed to start |
relay_connect | WebSocket relay connection failed |
mcp_init | MCP protocol handshake failed |
runtime | Process exited after successful init |
tool_call | Error during a tool call |
box_unavailable | Box service disabled or unreachable |
Known Limitation: No Tool-Call Timeout#
invoke_mcp_tool() calls await self.session.call_tool(tool_name, arguments) with no timeout wrapper. A slow or hung MCP server will cause the calling coroutine to block indefinitely. There is no per-call cancellation guard; the only protection is the startup timeout on _ready_event. Engineers adding new tool-call paths should wrap invoke_mcp_tool with asyncio.wait_for if they require bounded latency.
Transient vs. Persistent Sessions#
Sessions created from the admin "test" button carry no persisted UUID; the load_mcp_server method detects this, marks the config _transient=True, and generates a random UUID . Transient sessions are isolated from the shared Box session to prevent a failing test from churning healthy live connections .