Free lesson · GenAI Agent Engineering

Manage MCP server lifecycle

You can handle connection failures, react to server-down during a session (graceful degradation), choose retryable vs fatal errors, decide sync vs async client patterns, run health checks, harden against untrusted servers, choose connection-pooling vs on-demand connections, and respond to tool-deprecation notices.

Course: GenAI Agent Engineering · Chapter 25 · The MCP Client

Free to read — no subscription required.

Introduction

When you run MCP servers as subprocesses, your client becomes responsible for the entire lifetime of that process—from spawning and handshaking to detecting crashes and restarting cleanly. Without deliberate lifecycle management, a single server failure can leave dangling processes, stale connections, and agents that silently stop responding. By the end of this lesson, you'll be able to model server states as an explicit enum, configure health-check parameters, and implement graceful startup, shutdown, and exponential-backoff restart so your MCP client handles failures reliably in production.

Key Terminology

  • ServerState — An Enum whose values (STOPPED, STARTING, RUNNING, STOPPING, FAILED) represent every distinct phase an MCP server subprocess can occupy, making lifecycle transitions explicit and unambiguous in code.
  • HealthConfig — A dataclass that centralizes every health-monitoring tunable — check_interval, timeout, max_failures, restart_delay, and max_restart_delay — so policy is declared once and separated from the manager's mechanism.
  • ServerLifecycleManager — The orchestrating dataclass that drives a StdioServerParameters-backed server through its full lifecycle: spawning the subprocess, initializing the ClientSession, and restarting on failure with backoff.
  • Exponential backoff — A restart strategy in which the delay before each reconnection attempt doubles (restart_delay × 2^restart_count), capped at max_restart_delay, to prevent rapid reconnection storms against a repeatedly-failing server.
  • ClientSession — The MCP SDK object returned by ServerLifecycleManager.start() after the stdio transport opens and initialize() completes the MCP handshake; represents a fully negotiated, ready-to-use connection to the server.
  • stdio transport — The subprocess communication channel opened by stdio_client(server_params), providing the (read, write) stream pair that ClientSession wraps into structured MCP messages.

Concepts

Loading diagram...

Why Server State Deserves Its Own Enum

When a client manages a subprocess, the process is never simply "up" or "down" — it moves through a sequence of transient phases: not yet launched, in the middle of startup, fully operational, winding down, or crashed. Collapsing these into a boolean flag or inferring state from raw exceptions makes illegal transitions invisible and debugging hard. An explicit ServerState enum forces every code path to name the phase it enters, so you can always answer "where exactly did this fail?"

The lesson's start() method illustrates why the granularity matters: it sets STARTING before touching the transport, and only moves to RUNNING after session.initialize() returns cleanly. If either step raises, the state lands in FAILED — a first-class signal, not a silently swallowed exception. The FAILED state tells the health-check loop it needs to act, rather than leaving the manager in an ambiguous intermediate condition.

Separating Health Policy from Manager Mechanism

HealthConfig exists because health-monitoring decisions — how often to probe, how long to wait for a response, how many consecutive failures warrant a restart, and how aggressively to back off — are policy, not mechanics. Scattering those numbers across the manager makes them invisible to callers and impossible to vary without editing core logic.

By collecting every tunable into one dataclass, the lesson makes health policy swappable: a latency-sensitive agent might use a tight timeout and a low max_failures, while a batch-processing client might tolerate longer intervals. It also makes the policy independently testable — you can assert that a given HealthConfig would trigger a restart after exactly three failures without spawning any subprocess (see Code Walkthrough).

Exponential Backoff: Preventing Reconnection Storms

When an MCP server crashes, an immediate and repeated retry loop hammers a server that is still recovering, compounding the outage. Exponential backoff solves this by doubling the wait between attempts — delay = restart_delay × 2^restart_count — so the client backs off progressively rather than flooding the process. The max_restart_delay ceiling prevents the gap from growing unboundedly: after enough retries the client settles into a steady polling cadence instead of waiting forever.

Critically, _restart_count persists on the ServerLifecycleManager instance across the full outage window. This means backoff accumulates correctly over multiple restart cycles rather than resetting to zero on each attempt, which is the common implementation mistake that turns an exponential curve back into a flat retry loop (see Code Walkthrough).

The Startup Handshake: Transport Open vs. Session Ready

Opening a stdio transport and having a working MCP session are two distinct events. _open_transport() establishes the raw read/write streams, but ClientSession.initialize() performs the MCP capability handshake — confirming the server is ready to respond to tool calls. A client that treats transport-open as session-ready will occasionally call tools on a server mid-startup and receive malformed or missing responses.

Wrapping both steps in asyncio.wait_for(..., timeout=health_config.timeout) closes a second gap: a slow-to-start server cannot block the agent indefinitely. If the handshake exceeds the deadline, start() increments _failure_count and transitions to FAILED, so the health loop's failure counter stays accurate even for timeout-class failures, not just crashes.

Code Walkthrough

Now that you understand the five lifecycle states an MCP server moves through—Stopped, Starting, Running, Stopping, and Failed—you can wire those states into a concrete manager that drives real subprocess connections.

The first block defines the data types that capture state and health-monitoring policy. ServerState gives each phase a distinct value so transitions are unambiguous. HealthConfig centralises every tunable: how often to probe the server, how long to wait for a response, how many consecutive failures trigger a restart, and how fast backoff grows.

Code snippetpython
1import asyncio 2import logging 3from dataclasses import dataclass, field 4from enum import Enum, auto 5 6logger = logging.getLogger(__name__) 7 8class ServerState(Enum): 9 STOPPED = auto() 10 STARTING = auto() 11 RUNNING = auto() 12 STOPPING = auto() 13 FAILED = auto() 14 15@dataclass 16class HealthConfig: 17 check_interval: float = 30.0 # seconds between health probes 18 timeout: float = 5.0 # probe response deadline 19 max_failures: int = 3 # consecutive failures before restart 20 restart_delay: float = 1.0 # initial backoff (seconds) 21 max_restart_delay: float = 60.0 # backoff ceiling

The second block shows a minimal lifecycle manager built on the MCP SDK's stdio_client. The start method transitions to STARTING, opens the stdio transport, initialises the session, and moves to RUNNING. The restart_with_backoff method computes an exponential delay capped at max_restart_delay before delegating back to start, which prevents rapid reconnection storms against a repeatedly-failing server.

Code snippetpython
1from mcp import ClientSession 2from mcp.client.stdio import stdio_client, StdioServerParameters 3 4@dataclass 5class ServerLifecycleManager: 6 server_params: StdioServerParameters 7 health_config: HealthConfig = field(default_factory=HealthConfig) 8 state: ServerState = ServerState.STOPPED 9 _restart_count: int = 0 10 _failure_count: int = 0 11 12 async def start(self) -> ClientSession: 13 self.state = ServerState.STARTING 14 try: 15 read, write = await asyncio.wait_for( 16 self._open_transport(), timeout=self.health_config.timeout 17 ) 18 session = ClientSession(read, write) 19 await session.initialize() 20 self.state = ServerState.RUNNING 21 self._failure_count = 0 22 logger.info("MCP server running") 23 return session 24 except Exception as exc: 25 self._failure_count += 1 26 self.state = ServerState.FAILED 27 logger.error("Startup failed: %s", exc) 28 raise 29 30 async def _open_transport(self): 31 async with stdio_client(self.server_params) as (read, write): 32 return read, write 33 34 async def stop(self) -> None: 35 self.state = ServerState.STOPPING 36 self.state = ServerState.STOPPED 37 logger.info("MCP server stopped") 38 39 async def restart_with_backoff(self) -> ClientSession: 40 delay = min( 41 self.health_config.restart_delay * (2 ** self._restart_count), 42 self.health_config.max_restart_delay, 43 ) 44 self._restart_count += 1 45 logger.info("Restarting in %.1fs (attempt %d)", delay, self._restart_count) 46 await asyncio.sleep(delay) 47 return await self.start()

Verify by instantiating ServerLifecycleManager with a StdioServerParameters pointing at a local MCP server process, calling start(), and asserting that manager.state == ServerState.RUNNING and the returned ClientSession can successfully call list_tools() without raising an exception.

Do's and Don'ts

Having walked through managing server lifecycle above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do model server phases as a ServerState enum — using STOPPED, STARTING, RUNNING, STOPPING, and FAILED as distinct values makes transitions unambiguous and prevents logic that confuses a crashed server (FAILED) with one that was deliberately stopped (STOPPED).
  2. Do centralise every health-check tunable inside HealthConfig — grouping check_interval, timeout, max_failures, restart_delay, and max_restart_delay in one dataclass means you can adjust backoff behaviour or probe cadence in one place without hunting through manager logic.
  3. Do cap exponential backoff with max_restart_delay — computing restart_delay * (2 ** restart_count) without the min(…, max_restart_delay) ceiling lets delays grow unboundedly, turning a briefly-failing server into one your agent waits minutes to reconnect to.

Don'ts

  1. Don't skip wrapping _open_transport() in asyncio.wait_for — without the timeout deadline from HealthConfig, a subprocess that hangs during stdio handshake blocks start() indefinitely, leaving state stuck in STARTING and the agent unable to proceed or detect the failure.
  2. Don't share a single _failure_count across restarts without resetting it on successful startup — if you omit the self._failure_count = 0 line in start(), prior failures bleed into the new session and can trigger an immediate max_failures restart even after the server recovers cleanly.
  3. Don't bypass restart_with_backoff and call start() directly after a crash — skipping the exponential delay causes rapid reconnection storms against a repeatedly-failing server process, which can exhaust file descriptors and make recovery harder rather than easier.

This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.

From · cancel anytime

More free lessons in GenAI Agent Engineering

All free lessons in GenAI Agent Engineering