◉ANTIGRAVITY LABJP
Articles/Integrations
⬡ Integrations/2026-08-03Advanced

I Measured Before Writing a Number: MCP Connect and Tool Calls Differed by 486x

Antigravity 2.4.3 lets you set a timeout per MCP server. To find a defensible number I built a stdio server, measured each boundary separately, and found why a single value cannot cover both.

Antigravity409MCP29timeoutsmeasurement8design decisionssolo development4

✦ Premium Article

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.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A complete measurement harness (Node + Python) that times connect, tools/list, and tools/call as separate boundaries on a stdio MCP server
✦Connect at 127.96 ms against tool calls at 0.263 ms — a 486x gap between boundaries that dwarfs the 3.59x gap between light and heavy servers
✦Why the margin belongs on the long-tailed boundary rather than the slow one, and the concrete cost of a single value: waiting 1,034x longer than normal before giving up
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Integrations2026-09-18
When a session starts with MCP tools missing, where do you stop?
When MCP initialization fails at startup, the agent quietly falls back to other means and keeps going. Here is a small script that asks your servers directly which tools they expose, plus the three branches I decided on in advance for when something is missing.
⬡ Integrations2026-07-28
Three Tools Named read_file: Catching MCP Name Collisions Before Startup
Bundle enough MCP servers and tool names collide quietly. Here is what a real 5-server, 21-tool setup measured at 43% collision, plus a Python preflight that catches them before startup and assigns deterministic aliases.
⟐ Editor View2026-08-01
The Date Column Was the Most Expensive Part of My Sales CSV — Measuring What Attachments Really Cost
2.4.3 lets you attach .json, .md, and .csv files directly. I rendered the same table eight ways, priced every column in tokens, and boiled it down to a 1,062-token digest. Every number here came from a run on my own machine.