Stateful vs Stateless MCP: Why Your AI Agents Crash and How to Build a Resilient Harness for zsh A developer has published a guide on fixing AI agent crashes caused by stateful Model Context Protocol (MCP) services, arguing that the default stdio process-spawning model breaks stateful tools like Telegram sessions, authenticated browsers, and PostgreSQL pools through database lock contention and orphaned processes. The proposed harness splits tools into stateless stdio utilities and stateful Streamable HTTP daemons run under launchd or systemd, using Python's FastMCP with a lifespan-managed Telegram client, and claims compact CLI wrappers cut token overhead by 80%. When you connect the first few tools to an AI agent Cursor, Claude Code, Antigravity, or a custom script using the Model Context Protocol MCP , it feels like magic. You define stdio servers in your config, and the agent reads files, queries databases, and runs shell commands seamlessly. However, once your setup scales past 10+ tools and includes stateful services Telegram MTProto sessions, authenticated Chrome instances, persistent PostgreSQL connection pools, or background workers , things quickly fall apart: sqlite3.OperationalError: database is locked . stdin/stdout pipes. In this guide, we break down how to design a production-grade AI agent harness: separating MCP into Stateless and Stateful layers, deploying local Streamable HTTP daemons under a system supervisor launchd on macOS or systemd on Linux , and slashing token overhead by 80% using compact CLI wrappers. stdio Destroys Stateful Services By default, MCP promotes stdio process spawning: { "mcpServers": { "telegram": { "command": "python3", "args": "/path/to/telegram server.py" } } } For purely stateless utilities like a currency converter, read-only file viewer, or git diff , this is completely fine. The process starts, handles the JSON-RPC request over standard I/O, outputs the response, and exits. Now, consider what happens with a stateful client like Telegram via Telethon or Pyrogram : Telethon stores cryptographic session keys inside a local SQLite database anon.session . When your agent runtime restarts or launches concurrent subagents, a second Python process starts up and tries to access the exact same database. It immediately hits a lock contention, crashes, and leaves a dangling orphan process in your operating system. To achieve rock-solid reliability, we split our tool stack into two distinct tiers: | Metric | Stateless stdio | Stateful Streamable HTTP Daemon | |---|---|---| | Startup Overhead | Slow cold-starts Python interpreter every invocation | Instant daemon is pre-warmed in memory | | Socket & Network State | Dropped on session exit | Maintained 24/7 in background | | Concurrent Access | Dangerous risk of file corruptions & locks | Safe synchronized via asyncio event loop | | Resource Footprint | N processes per N subagents | 1 single lightweight daemon for the entire machine | Let's convert our stateful service into a long-running daemon supporting Server-Sent Events SSE and Streamable HTTP using Python's FastMCP : python tg daemon.py import asyncio import os from contextlib import asynccontextmanager from mcp.server.fastmcp import FastMCP from telethon import TelegramClient API ID = int os.environ "TG API ID" API HASH = os.environ "TG API HASH" SESSION PATH = os.path.expanduser "~/.local/share/tg session/client.session" client = TelegramClient SESSION PATH, API ID, API HASH @asynccontextmanager async def lifespan app : await client.connect if not await client.is user authorized : raise RuntimeError "Session not authorized. Run auth helper first." yield await client.disconnect mcp = FastMCP "TelegramStatefulService", lifespan=lifespan @mcp.tool async def send broadcast chat id: str, message: str - str: """Send message to target chat without reconnecting""" entity = await client.get entity chat id sent = await client.send message entity, message return f"Message delivered successfully. ID: {sent.id}" @mcp.tool async def fetch unread summary limit: int = 10 - str: """Read unread counters from warm connection""" dialogs = await client.get dialogs limit=limit unreads = f"{d.name}: {d.unread count} unread" for d in dialogs if d.unread count 0 return "\n".join unreads if unreads else "No unread messages" if name == " main ": mcp.run transport="sse", host="127.0.0.1", port=8765 Now the daemon holds a single, warm connection to Telegram servers. It never drops socket connections and is 100% immune to SQLite locking bugs. In your agent's MCP configuration, point to the local HTTP endpoint: { "mcpServers": { "telegram": { "url": "http://127.0.0.1:8765/sse" } } } launchd To ensure the daemon starts on boot and restarts automatically upon any system crash, register it as a user-level daemon in ~/Library/LaunchAgents/com.mika.tg-mcp.plist :