I opened the config file, typed timeout_seconds, and stopped.
Antigravity 2.4.3 added a per-server timeout for MCP, so I wanted it in place. The problem was the next character. I had no idea what number belonged there.
Thirty? Sixty?
I have watched an overnight job sit against an unresponsive server until morning, so short numbers appeal to me. But cut too aggressively and healthy servers start failing, which just swaps one pile of morning logs for another.
The overnight jobs I run as an indie developer have nobody watching them. I am the one who notices, the next morning. So this number exists less to prevent failure than to shorten the gap before I hear about it.
Neither number had any evidence behind it. Writing an unjustified number into a config file only creates a problem for a future version of me, so I decided to measure first.
The first obstacle was deciding what to measure
Once I committed to measuring, the next problem arrived immediately: there is no single quantity called "MCP server response time."
An agent touches a server at three distinct points. Spawning the process and completing initialization. Asking which tools exist. Actually calling one.
Collapse those into one average and the resulting number corresponds to nothing real. What I needed was not a typical value but a ceiling — the point past which waiting means something is wrong.
So I built a harness that measures each boundary separately.
The stdio server I measured against
Measuring a real server mixes in that server's own quirks. You cannot see what it does at startup, which makes the numbers uninterpretable.
I wrote one where everything is visible instead. Line-delimited JSON-RPC, responding to initialize, tools/list, and tools/call, and nothing else.
The only variable is how much work happens at startup. MCP_WEIGHT switches between light, which does nothing, and heavy, which reads and hashes every file under a directory.
#!/usr/bin/env node
// Minimal MCP-style stdio server (JSON-RPC 2.0, line-delimited)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const WEIGHT = process.env.MCP_WEIGHT || 'light';
const ROOT = process.env.MCP_ROOT || process.cwd();
// Startup work, imitating what real servers tend to do on boot
let index = new Map();
function buildIndex(dir, depth) {
if (depth < 0) return;
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) buildIndex(p, depth - 1);
else {
try {
const b = fs.readFileSync(p);
index.set(p, crypto.createHash('sha1').update(b).digest('hex'));
} catch (e) {}
}
}
}
if (WEIGHT === 'medium') buildIndex(ROOT, 1);
if (WEIGHT === 'heavy') buildIndex(ROOT, 3);
const TOOLS = [
{ name: 'hash_file', description: 'returns sha256 of a file' },
{ name: 'count_lines', description: 'returns line count' },
];
function handle(req) {
const { id, method, params } = req;
if (method === 'initialize') {
// Reporting `indexed` matters more than it looks — see below
return { jsonrpc: '2.0', id, result: {
protocolVersion: '2026-03-26',
serverInfo: { name: 'demo', weight: WEIGHT, indexed: index.size } } };
}
if (method === 'tools/list') {
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
}
if (method === 'tools/call') {
const name = params && params.name;
const target = (params && params.arguments && params.arguments.path) || __filename;
if (name === 'hash_file') {
const b = fs.readFileSync(target);
return { jsonrpc: '2.0', id, result: { content: [
{ type: 'text', text: crypto.createHash('sha256').update(b).digest('hex') }] } };
}
if (name === 'count_lines') {
const b = fs.readFileSync(target, 'utf8');
return { jsonrpc: '2.0', id, result: { content: [
{ type: 'text', text: String(b.split('\n').length) }] } };
}
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool: ' + name } };
}
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown method: ' + method } };
}
let buf = '';
process.stdin.on('data', (chunk) => {
buf += chunk;
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
if (!line.trim()) continue;
let res;
try { res = handle(JSON.parse(line)); }
catch (e) { res = { jsonrpc: '2.0', id: null, error: { code: -32700, message: String(e) } }; }
process.stdout.write(JSON.stringify(res) + '\n');
}
});The client is Python. Process spawn through the initialize response is folded into a single connect figure; everything after that is timed per round trip.
#!/usr/bin/env python3
"""Measure per-boundary latency of a stdio MCP server."""
import json, os, subprocess, time
HERE = os.path.dirname(os.path.abspath(__file__))
SERVER = os.path.join(HERE, "server.js")
def pct(xs, p):
if not xs:
return float("nan")
xs = sorted(xs)
k = (len(xs) - 1) * p / 100.0
lo, hi = int(k), min(int(k) + 1, len(xs) - 1)
return xs[lo] + (xs[hi] - xs[lo]) * (k - lo)
class Session:
def __init__(self, weight, root):
env = dict(os.environ, MCP_WEIGHT=weight, MCP_ROOT=root)
t0 = time.perf_counter()
self.p = subprocess.Popen(
["node", SERVER], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
env=env, text=True, bufsize=1)
# Spawn cost belongs to connect, not to the first call
self.spawn_ms = (time.perf_counter() - t0) * 1000
self._id = 0
def call(self, method, params=None):
self._id += 1
req = {"jsonrpc": "2.0", "id": self._id, "method": method}
if params is not None:
req["params"] = params
t0 = time.perf_counter()
self.p.stdin.write(json.dumps(req) + "\n")
self.p.stdin.flush()
line = self.p.stdout.readline()
ms = (time.perf_counter() - t0) * 1000
if not line:
# Exit, not a hang. Conflating the two wrecks triage later.
raise RuntimeError("server closed stdout")
return json.loads(line), ms
def close(self):
try:
self.p.stdin.close()
self.p.wait(timeout=5)
except Exception:
self.p.kill()
def collect(weight, root, target, sessions, calls):
conn, listing, call = [], [], []
indexed = None
for _ in range(sessions):
s = Session(weight, root)
t0 = time.perf_counter()
res, _ = s.call("initialize")
conn.append(s.spawn_ms + (time.perf_counter() - t0) * 1000)
indexed = res["result"]["serverInfo"]["indexed"]
_, ms = s.call("tools/list")
listing.append(ms)
for _ in range(calls):
_, ms = s.call("tools/call",
{"name": "hash_file", "arguments": {"path": target}})
call.append(ms)
s.close()
return {"connect": conn, "list": listing, "call": call, "indexed": indexed}
def stats(xs):
return {"n": len(xs), "p50": round(pct(xs, 50), 3), "p95": round(pct(xs, 95), 3),
"p99": round(pct(xs, 99), 3), "p999": round(pct(xs, 99.9), 3),
"max": round(max(xs), 3),
"max_over_p50": round(max(xs) / pct(xs, 50), 2)}Everything ran on a Linux container with 4 vCPUs and 3.8 GB of memory, Node v22.22.3 and Python 3.10.12. The directory being scanned holds real working files — 2,002 MDX documents totalling 32 MB — rather than synthetic data.