設定ファイルを開いて、timeout_seconds と打ったところで手が止まりました。
2.4.3 で MCP サーバーごとにタイムアウトの秒数を指定できるようになったので、自分の環境にも入れておこうと思ったのです。ところが、次に打つべき数字が浮かびませんでした。
30 でしょうか。60 でしょうか。
深夜のジョブが無応答のサーバーを待ち続けて朝を迎えた経験があるので、短めにしたい気持ちはあります。一方で、短くしすぎて健全なサーバーを切ってしまえば、朝には別の種類の失敗ログが並びます。
個人開発で回している夜間のジョブには、見ている人がいません。異常に気づくのは、翌朝の私です。だからこの数字は、失敗を防ぐためというより、失敗を早く知るために置くものだと考えています。
どちらの数字にも根拠がありませんでした。根拠のない数字を設定ファイルへ書き込むのは、あとで自分を困らせるだけです。それで、書く前に測ることにしました。
何を測ればいいのか、が最初の壁でした
測ろうと決めたところで、すぐに次の問題にぶつかりました。「MCP サーバーの応答時間」という一つの量が存在しないのです。
エージェントがサーバーに触れる場面は、少なくとも三つに分かれます。
プロセスを起こして初期化を終える接続。使えるツールの一覧を取る問い合わせ。そして実際のツール呼び出し。
この三つを一つの平均値へ潰してしまうと、どの数字も現実のどこにも対応しなくなります。私が知りたいのは平均ではなく、「何秒待ったら異常と判断してよいか」という上限のほうでした。
そこで、境界ごとに分けて測る計測ハーネスを書きました。
計測に使った stdio サーバー
実在のサーバーで測ると、そのサーバー固有の事情が混ざります。起動時に何をしているのか外から分からないため、数字の解釈もできません。
なので、中身が全部見えるサーバーを自分で書きました。JSON-RPC を行区切りで受け、initialize と tools/list と tools/call に応答するだけの最小構成です。
違いを作るのは起動時の作業量だけにしました。環境変数 MCP_WEIGHT で、何もしない light と、指定ディレクトリ配下のファイルを全部読んでハッシュを取る heavy を切り替えます。
#!/usr/bin/env node
// 最小の MCP 風 stdio サーバー(JSON-RPC 2.0 / 行区切り)
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();
// 起動時の初期化。実サーバーが起動時にやりがちな走査を模す
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: 'ファイルの sha256 を返す' },
{ name: 'count_lines', description: '行数を返す' },
];
function handle(req) {
const { id, method, params } = req;
if (method === 'initialize') {
// indexed を返すのが後で効きます(後述)
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');
}
});クライアント側は Python です。プロセスの起動から initialize の応答が返るまでを接続として一つの数字にまとめ、以降は往復ごとに計ります。
#!/usr/bin/env python3
"""stdio MCP サーバーの境界別レイテンシを実測する。"""
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)
# 起動そのものにかかった時間を接続へ含めます
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:
# 無応答ではなく終了。区別できないと原因の切り分けで詰まります
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)}実行環境は Linux のコンテナで、4 vCPU・メモリ 3.8 GB、Node v22.22.3、Python 3.10.12 です。走査対象には手元にある実ファイル群(MDX 2,002 個・合計 32 MB)を使いました。合成データではなく、日々触っているディレクトリです。