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.

Antigravity348MCP21timeoutsmeasurement6design 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-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.
Integrations2026-06-22
Scope the MCP Tools You Hand an Agent: A Least-Privilege Allowlist Design
As you add MCP servers to Antigravity 2.0, the set of tools every agent can reach quietly grows into an all-you-can-eat buffet. An agent that only needs to read files seeing delete and deploy tools is an accident waiting to happen. This walks through a least-privilege design that scopes tools per agent role, denies at call time, and gates destructive operations behind a second step, with working Python and field notes.
Integrations2026-06-01
Fixing spawn npx ENOENT When an Antigravity MCP Server Won't Start
Your MCP server config JSON looks correct, but Antigravity logs spawn npx ENOENT and the server stays gray. This is almost always a PATH inheritance problem, not a broken server. Here is how to diagnose and fix it.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →