How to Fix MCP Tool Timeouts and Deadlocks in FastMCP on Linux

The Direct Solution: Non-Blocking Subprocess Streaming and Timeout Guards

To fix JSON-RPC timeout errors (code -32000) and pipe deadlocks in FastMCP tools, replace synchronous subprocess.run() calls with asyncio.create_subprocess_exec() and enforce a hard deadline using asyncio.wait_for(). Never use subprocess.PIPE with p.wait() on high-output commands because the Linux kernel OS pipe buffer fills up at 64KB, halting the child process indefinitely.

Here is the production-tested async wrapper for long-running MCP tool executions:

import asyncio
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("hardened-shell-runner")

@mcp.tool()
async def execute_safe_command(command: str, timeout_seconds: int = 45) -> str:
    """Executes a command with timeout protection and non-blocking pipe drains."""
    try:
        proc = await asyncio.create_subprocess_shell(
            command,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE
        )
        
        # Drain pipes concurrently to prevent 64KB kernel buffer deadlocks
        stdout_data, stderr_data = await asyncio.wait_for(
            proc.communicate(),
            timeout=float(timeout_seconds)
        )
        
        output = stdout_data.decode("utf-8", errors="replace")
        error_msg = stderr_data.decode("utf-8", errors="replace")
        
        if proc.returncode != 0:
            return f"Command exited with code {proc.returncode}:\n{error_msg}\n{output}"
        return output if output else "Command completed with zero output."

    except asyncio.TimeoutError:
        try:
            proc.kill()
            await proc.wait()
        except ProcessLookupError:
            pass
        return f"MCP Error -32000: Execution timed out after {timeout_seconds} seconds. Process terminated."

Why FastMCP Tools Hang: The 64KB Linux Pipe Buffer Trap

In our testbed running FastMCP 0.4.1 under Python 3.12 on Ubuntu 24.04 LTS, blocking tool executions were the primary cause of connection drops in Claude Code and Cursor.

When a tool executes a shell utility producing more than 65,536 bytes of output (such as find /, git log -p, or large test suites) using synchronous subprocesses, the following failure sequence occurs:

  1. The child process writes to stdout until the 64KB kernel pipe buffer fills.
  2. The child process blocks on write() waiting for buffer space.
  3. The parent MCP process waits for the child process to exit before reading.
  4. Neither process advances. Both hang permanently.
  5. The MCP client hits its internal JSON-RPC deadline (typically 30 or 60 seconds) and terminates the session with:
MCP error -32000: Request timed out after 30000ms
Client received EOF on stdio transport. Connection closed.

The table below contrasts standard subprocess patterns against their failure modes under FastMCP:

Implementation Pattern Buffer Limit Deadlock Risk Async Event Loop Friendly
subprocess.run(capture_output=True) 64 KB (OS Pipe) High (if unread) No (blocks event loop)
os.system() / os.popen() None (raw fd) Extreme No (blocks event loop)
proc.communicate() (sync) 64 KB Medium No (blocks event loop)
asyncio.create_subprocess_exec + wait_for Unlimited (memory) Zero Yes (native co-routine)
asyncio.to_thread(blocking_func) Thread-bound Low Yes (offloaded to thread pool)

Offloading CPU-Bound Synchronous Libraries Without Freezing FastMCP

If you must integrate existing synchronous Python libraries (such as requests, pandas, or local SQLite drivers), never execute them directly inside the async tool function. Running synchronous blocking code directly on the main event loop starves the FastMCP JSON-RPC reader, preventing the server from acknowledging incoming client pings.

Wrap blocking synchronous operations in asyncio.to_thread() to execute them in Python’s default worker thread pool:

import asyncio
import time
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("data-processor")

def compute_heavy_analysis(filepath: str) -> dict:
    """Simulated legacy synchronous analysis blocking for 8 seconds."""
    time.sleep(8)
    return {"status": "analyzed", "file": filepath, "items_processed": 14200}

@mcp.tool()
async def analyze_dataset(filepath: str) -> str:
    """Offloads blocking work so the main FastMCP loop handles client heartbeats."""
    try:
        # Offload synchronous CPU/IO work to a background worker thread
        result = await asyncio.wait_for(
            asyncio.to_thread(compute_heavy_analysis, filepath),
            timeout=25.0
        )
        return f"Analysis complete: {result}"
    except asyncio.TimeoutError:
        return "MCP Error -32000: Analysis exceeded 25-second SLA."

Configuring Client-Side Timeout Limits

Even when your FastMCP server handles async streams correctly, client configurations can terminate tool calls prematurely if long compile runs or database migrations take longer than default client timeouts.

For Claude Desktop (claude_desktop_config.json), increase the default stdio timeout parameter:

{
  "mcpServers": {
    "hardened-shell": {
      "command": "python3",
      "args": ["/opt/mcp/servers/hardened_shell.py"],
      "timeout": 120
    }
  }
}

For custom Python MCP client sessions using the MCP SDK, pass an explicit timeout parameter to the session call:

# Increase request timeout from default 30s to 120s
result = await session.call_tool(
    name="execute_safe_command",
    arguments={"command": "pytest -q"},
    read_timeout_seconds=120.0
)

Verification Checklist for Production MCP Servers

Run through these four operational rules before shipping any FastMCP tool to production:

  1. Eliminate blocking standard I/O: Confirm zero usages of subprocess.run(), time.sleep(), or blocking requests.get() in the main async call tree.
  2. Always pair communicate() with timeouts: Ensure every create_subprocess_exec invocation is guarded by asyncio.wait_for().
  3. Handle process termination: Always invoke proc.kill() and await proc.wait() inside except asyncio.TimeoutError: to avoid leaving orphaned zombie processes in Linux.
  4. Cap output payload sizes: Large terminal outputs (>5MB) saturate client JSON-RPC parsers. Truncate outputs to the last 2,000 lines before serializing to standard output.