Blocking Tool Operations
Issue: Slow Tool Calls Block All Other Operations
Frequency: Common
Symptoms
- Agent becomes unresponsive during slow operations
- Other tools timeout while waiting
- Simple queries take as long as complex ones
- Parallel tool calls execute sequentially
- Client disconnects during long operations
Root Cause MCP servers and tool handlers using synchronous I/O block the entire server while one tool executes. A single slow database query or API call with a 30-second timeout blocks every other tool, causing cascading timeouts and client disconnections even for fast operations.
Example
# BAD: Synchronous blocking
import requests
def run_report(report_id: str):
# This blocks for 30+ seconds
response = requests.get(f"https://api.internal/reports/{report_id}",
timeout=30)
return response.json()
def list_products():
# Fast query, but must wait for run_report to finish
return db.query("SELECT * FROM products LIMIT 10")
# Timeline when both called:
# 0s: run_report starts
# 0s: list_products queued (blocked)
# 30s: run_report finishes
# 30s: list_products finally starts
# 31s: list_products returns
# Client timeout at 10s → both fail
---
# GOOD: Async with connection pooling
import httpx
import asyncio
async def run_report(report_id: str):
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.internal/reports/{report_id}")
return response.json()
async def list_products():
return await db.fetch("SELECT * FROM products LIMIT 10")
# Timeline when both called:
# 0s: run_report starts (async)
# 0s: list_products starts (async, concurrent)
# 1s: list_products returns
# 30s: run_report returns
# Fast operations aren't blocked by slow ones
Key Statistics From MCP Server Mistakes Analysis (2026):
- Blocking I/O is #3 most common MCP server mistake
- Default stdio/SSE transports handle one request at a time
- 10-second client timeouts common, 30-second operations fail
- Async I/O improves throughput 5-10x for mixed workloads
Blocking Patterns
| Operation | Typical Duration | Blocking Impact |
|---|---|---|
| Database query | 10-100ms | Low |
| External API | 1-10s | High |
| Report generation | 10-60s | Critical |
| File processing | 5-30s | High |
| AI model call | 2-20s | High |
Contributing Factors
- Default Python I/O is synchronous
- Easy to write blocking code, hard to write async
- Testing with fast operations masks blocking issues
- Single-threaded server architectures
- Tutorials show synchronous examples
Test Scenario & Reproduction
Scenario Setup
- MCP server implements at least one tool using synchronous/blocking I/O (e.g.,
requests.getwith a 30s timeout) alongside a fast tool sharing the same server thread - No async runtime, job queue, or worker-pool isolation between slow and fast tools
- Client configured with a realistic timeout (e.g., 10s)
Trigger Mechanism
- Issue a call to the slow, synchronous tool (e.g.,
run_report) - Immediately issue a call to the fast tool (e.g.,
list_products) from a concurrent client/session - Measure whether the fast tool’s response is delayed until the slow tool completes
Example Reproduction Steps:
1. Deploy the server with run_report (blocking, 30s) and list_products (fast, 10-100ms) as in the example
2. Call run_report(report_id) and immediately call list_products() from a second concurrent request
3. Record the timestamp list_products actually returns
4. Compare against its expected 10-100ms baseline latency
5. Measure: did list_products wait ~30s for run_report to finish, and did the client's 10s timeout fire first?
Expected Failure State
- The fast tool’s response is delayed until the slow tool completes, well past its normal latency
- The client’s timeout fires before either call completes
- No queue-depth or blocked-event-loop alert fires despite the server being single-threaded and blocked
Mitigation Strategies
Prevention
- Migrate to async I/O for all external calls: Replace synchronous
requests/blocking DB drivers withhttpx.AsyncClient,asyncpg, etc., so a 30-second report call no longer holds the single-threaded MCP server hostage while a 10-100ms product query waits behind it. Trade-off: async rewrites touch every I/O call site and require re-testing error/cancellation paths that behave differently than sync code. - Route long-running work through a job queue: For operations like report generation (10-60s) or file processing (5-30s), have the tool enqueue work and immediately return a job ID rather than holding the connection open past typical 10-second client timeouts. Trade-off: the agent must now poll or subscribe for completion, adding a second tool call and more orchestration logic.
- Cap concurrent slow operations with a worker pool: Bound simultaneous AI-model calls or report jobs (e.g., a semaphore of 4-8) so a burst of slow requests can’t starve fast ones even under async I/O. Trade-off: legitimate bursts get queued and delayed rather than all running immediately.
Detection & Response
- Per-tool execution time percentiles: Track p50/p95/p99 execution time per tool; a normally-fast tool (list_products, 10-100ms baseline) whose p95 spikes into seconds signals it’s being blocked behind a slow synchronous call sharing the same server thread.
- Timeout rate correlated by tool pairing: Log which tool was executing when a different tool’s client-side timeout fired; repeated timeouts on fast tools coinciding with slow-tool execution windows confirms blocking rather than tool-specific failure.
- Queue depth under load: Monitor the server’s pending-request queue depth; sustained non-zero depth on a nominally async server indicates a blocking call slipped through (e.g., a sync library invoked from async code).
Architecture Patterns
- Async-first server runtime: Build the MCP server on an async framework (asyncio/FastAPI+uvicorn) from the start so blocking is the exception; deployment consideration — audit third-party client libraries for hidden sync calls (e.g., a sync boto3 client inside an async handler still blocks the event loop).
- Background job + polling pattern: For 10s+ operations, return
{job_id, status: "running"}immediately and expose acheck_job_status(job_id)tool; deployment consideration — requires a job store (Redis/DB) with TTL cleanup so abandoned jobs don’t leak. - Bulkhead isolation: Run slow external-API-bound tools in a separate worker pool or process from fast DB-bound tools so exhaustion in one pool can’t starve the other; deployment consideration — adds operational surface (two pools to monitor/scale) but contains blast radius.
Metrics
- tool_p95_latency_ms (per tool): Target: DB-backed tools < 200ms, external-API tools < 5s; Alert if p95 exceeds 2x its 7-day baseline.
- client_timeout_rate: Target < 0.5% of calls; Alert if > 2% over a 15-minute window.
- event_loop_blocked_ms: Target: 0 sustained blocking on an async server; Alert if any single blocking span exceeds 500ms.
- queue_depth: Target: 0 steady-state; Alert if > 10 pending requests for more than 60 seconds.
Alerts
- Blocking Call Detected (P1): Condition - event_loop_blocked_ms exceeds 500ms on a server expected to be fully async. Action: page on-call, capture a stack trace via profiler, identify and patch the synchronous call site (common culprits: sync DB driver, sync HTTP client, CPU-bound loop with no yield).
- Fast Tool Timeout Spike (P2): Condition - a normally sub-200ms tool’s timeout rate exceeds 2% while a known slow tool executes concurrently. Action: confirm blocking via queue-depth correlation, temporarily cap concurrent slow-tool invocations, file a bug against the blocking call.
- Queue Depth Sustained (P3): Condition - queue_depth > 10 for 5+ minutes. Action: check for a traffic spike vs. a blocking regression; scale the worker pool if traffic-driven, investigate code if not.
References
- 5 MCP Server Mistakes - Mistake #3: Blocking calls
- Aegis: Agent-Environment Failures - Resource exhaustion patterns
- Why Do Multi-Agent LLM Systems Fail? (MAST) - System design issues