壁紙アプリのカタログを扱う MCP サーバのツールが、気づけば12本になっていました。
一覧、検索、条件指定、詳細取得。似た用途のものが少しずつ増えていく、あの膨らみ方です。
個人開発でアプリを複数抱えていますと、こうした補助の仕組みは誰にも相談せず自分ひとりで育てることになります。膨らみに気づくのが遅れるのは、たいていそのためです。
「多すぎるから重いのだろう」と考えて、使用頻度の低い6本を外しました。ところが体感はほとんど変わりません。
削った量が足りなかったのか、そもそも削る場所が違ったのか。判断がつかないまま手を動かし続けるのは、いちばん時間を溶かすやり方です。
測ることにしました。
測定用に、依存のない MCP サーバを書き直す
公式 SDK を使うと、フレーミングやセッション管理の分だけバイト数が上乗せされます。何が何バイトなのかを切り分けたかったので、stdio 上の JSON-RPC 2.0 を素で実装しました。
MCP は要するに「1行1メッセージの JSON-RPC を標準入出力で流す」プロトコルです。initialize / tools/list / tools/call の3つだけなら、この程度の分量で動きます。
カタログの 324 件は、App Store と Google Play に出している壁紙アプリの実データとほぼ同じフィールド構成にしてあります。フィールドを削って測っても、手元の判断材料にはなりませんので。
#!/usr/bin/env node
// server.mjs — 測定用の最小 MCP サーバ(stdio / JSON-RPC 2.0)
import { createInterface } from "node:readline";
// 壁紙カタログ 324 件(実アプリのフィールド構成に合わせています)
const CATALOG = Array.from({ length: 324 }, (_, i) => ({
id: `wp_${String(i + 1).padStart(4, "0")}`,
title: `Wallpaper ${i + 1}`,
category: ["nature", "abstract", "minimal", "space", "city"][i % 5],
width: 2796,
height: 1290,
bytes: 380000 + (i * 977) % 220000,
sha256: Array.from({ length: 64 }, (_, k) => "0123456789abcdef"[(i * 7 + k) % 16]).join(""),
tags: ["calm", "blue", "gradient", "night"].slice(0, (i % 4) + 1),
license: "internal",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-07-30T00:00:00Z",
overlayScrim: { top: 0.42, bottom: 0.18 },
}));
const TOOLS = [
{ name: "list_wallpapers",
description: "カタログの壁紙を一覧します。カテゴリで絞り込めます。",
inputSchema: { type: "object", properties: {
category: { type: "string", description: "カテゴリ名" },
limit: { type: "integer", description: "最大件数" } } } },
{ name: "search_wallpapers",
description: "カタログの壁紙をキーワードで検索します。タグと題名が対象です。",
inputSchema: { type: "object", properties: {
query: { type: "string", description: "検索語" },
limit: { type: "integer", description: "最大件数" } }, required: ["query"] } },
// …以下、find_wallpapers / get_wallpaper / get_locale_keys /
// get_missing_translations / check_i18n_completeness / get_build_config /
// get_app_config / list_categories / get_catalog_stats / validate_catalog
];
function call(name, args = {}) {
if (name === "list_wallpapers") {
let rows = CATALOG;
if (args.category) rows = rows.filter((r) => r.category === args.category);
return rows.slice(0, args.limit ?? rows.length);
}
if (name === "list_wallpapers_projected") {
let rows = CATALOG;
if (args.category) rows = rows.filter((r) => r.category === args.category);
return rows.slice(0, args.limit ?? rows.length)
.map((r) => ({ id: r.id, title: r.title, category: r.category }));
}
if (name === "get_wallpaper") return CATALOG.find((r) => r.id === args.id) ?? null;
if (name === "list_categories") return [...new Set(CATALOG.map((r) => r.category))];
return { ok: true };
}
const rl = createInterface({ input: process.stdin });
rl.on("line", (line) => {
if (!line.trim()) return; // 空行は無視。ここを忘れると JSON.parse で落ちます
const req = JSON.parse(line);
let result;
if (req.method === "initialize") {
result = {
protocolVersion: "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "wallpaper-catalog", version: "0.1.0" },
};
} else if (req.method === "tools/list") {
const n = req.params?.n ?? TOOLS.length; // 測定用にツール数を可変にしています
result = { tools: TOOLS.slice(0, n) };
} else if (req.method === "tools/call") {
const payload = call(req.params.name, req.params.arguments);
result = { content: [{ type: "text", text: JSON.stringify(payload) }] };
} else {
result = {};
}
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: req.id, result }) + "\n");
});
計測に使った環境は Node.js v22.22.3 の Linux 上で、子プロセスとして起動した本サーバに対し標準入出力越しに要求を投げています。以下の数値はすべてこの構成で実際に走らせて取得したものです。実機やネットワーク越しの数字ではありません。
tools/list にわざと n パラメータを足してあるのは、ツール数を1本ずつ変えながら応答サイズを測るためです。仕様外の余計な引数ですが、測定用のサーバなので割り切りました。
ツール定義のコストは、想像していたより二桁小さい
まず tools/list の応答サイズです。ツール数を変えながら、返ってきた result を UTF-8 のバイト数で数えました。
| ツール数 | tools/list の応答 | 1本あたり平均 |
| 1 | 297 bytes | 297 bytes |
| 3 | 873 bytes | 291 bytes |
| 5 | 1,260 bytes | 252 bytes |
| 7 | 1,665 bytes | 238 bytes |
| 10 | 2,106 bytes | 211 bytes |
| 12 | 2,377 bytes | 198 bytes |
1本目から12本目までの差を本数で割ると、追加1本あたりの限界コストは 189.1 バイト でした。
つまり私が外した6本は、合計でおよそ 1.2 KB です。
この時点で、削ったのに変わらなかった理由の見当がつきました。1.2 KB は、そもそも体感が変わる大きさではありません。
効いていたのは応答の側だった
同じサーバに tools/call を投げて、返ってくるテキストのバイト数を測ります。
| 呼び出し | 応答サイズ | 全件・全フィールド比 |
| 全324件・全フィールド | 110,298 bytes | 100.0% |
| 全324件・3フィールドへ射影 | 19,659 bytes | 17.8% |
| 1カテゴリ(65件)・全フィールド | 22,115 bytes | 20.1% |
| 1カテゴリ(65件)・3フィールドへ射影 | 3,944 bytes | 3.6% |
| 20件・3フィールドへ射影 | 1,192 bytes | 1.1% |
ツール定義12本ぶんの 2,377 バイトは、この一番大きな応答の 2.2% にすぎません。
返すフィールドを id / title / category の3つに絞るだけで 110,298 バイトが 19,659 バイトになりました。減少率 82.2% です。ツールを半分に減らして得られた約 1.2 KB とは、桁が2つ違います。
ここが私にとって一番意外だった点でした。「ツールが多いと重い」という言い方をよく見かけますし、私も疑わずにそう理解していました。実際に測ると、定義は固定費として小さく、変動費は完全に応答の側にあります。
ついでに往復のレイテンシも 500 回測っておきました。get_wallpaper を1件取得する呼び出しで p50 = 0.21 ms、p95 = 0.27 ms、p99 = 0.35 ms、最大 0.67 ms です。stdio の往復そのものは、少なくともローカルでは律速になっていません。遅さを感じたときに疑うべきはトランスポートではなく、載せているデータの量だということになります。
1セッションぶんで足し合わせると差がさらに開きます
initialize と tools/list はセッションの最初に1回だけ流れます。対して tools/call は、作業のあいだ何度も往復します。
実測値をそのまま使って、1セッションで20回呼び出した場合を足してみます。
| 構成 | 定義(1回) | 応答(20回) | 合計 | 定義の占める割合 |
| 射影なし(1カテゴリ・全フィールド) | 2,377 B | 442,300 B | 444,677 B | 0.5% |
| 射影あり(1カテゴリ・3フィールド) | 2,377 B | 78,880 B | 81,257 B | 2.9% |
射影を入れるだけで、1セッションの合計が 444,677 バイトから 81,257 バイトへ下がります。81.7% の削減です。
そして定義の割合は、どちらの構成でも 3% を超えません。回数を重ねるほど、定義の存在感は薄まっていきます。
射影は「返す直前」ではなく「ツールの契約」に書く
対策として最初に思いついたのは、返す直前に map で絞る書き方でした。動きはします。
ただ、これだと呼び出し側からは「全フィールド返ってくるかもしれないツール」に見えたままです。数か月後の自分が list_wallpapers を別の用途に流用したとき、静かに 110 KB が復活します。
私は、返すフィールドをツールの description に明記する形に変えました。「返すのは id・title・category のみです」と書いてしまえば、それは実装の都合ではなく契約になります。全フィールドが要るときは get_wallpaper で1件ずつ取る、という分担も同時に決まります。
バイト境界で切ると、必ず壊れる
応答に上限を設けようと考えたとき、最初に書いてしまいがちなのが「JSON 文字列を N バイトで切る」というコードです。
これがどれくらい危ないのかを確かめました。全324件の JSON 文字列(110,298 バイト)を、1,000 バイトから 19,000 バイトまで 100 バイト刻みで切り、そのまま JSON.parse に通します。
結果は 181 通り中 181 通り、つまり 100.0% が解釈不能でした。
考えてみれば当然です。配列の途中で切れば閉じ括弧がありません。文字列の途中で切ればクォートが閉じません。ごくまれに運良く境界に当たることもあり得ますが、324 件のカタログでは一度も起きませんでした。
切るならレコードの境界です。バイト予算を先に決め、1件ずつ足しながら予算を超えたところで止めます。
// bytes 予算からページを切り出す。境界はレコード単位に固定します
const byteLen = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
function pageByByteBudget(rows, budget, offset = 0) {
const out = [];
let bytes = 2; // 空配列 "[]" の分をあらかじめ計上します
let i = offset;
for (; i < rows.length; i++) {
const add = byteLen(rows[i]) + (out.length ? 1 : 0); // カンマ1バイトを忘れないこと
if (bytes + add > budget) break;
out.push(rows[i]);
bytes += add;
}
// 1件目すら予算に入らない場合。無限ループを避けるため、ここで明示的に失敗させます
if (out.length === 0 && i < rows.length) {
throw new Error(
`record at ${i} exceeds budget: ${byteLen(rows[i])}B > ${budget}B. ` +
`射影を強めるか budget を上げてください`
);
}
return {
rows: out,
bytes: byteLen(out),
nextOffset: i < rows.length ? i : null, // null なら終端です
};
}
out.length === 0 のときに例外を投げる分岐は、後から足しました。射影を忘れたまま予算を 2 KB にしたところ、1件も入らず nextOffset が進まないまま呼び出し側が延々とページをめくり続けたためです。
エラーにならず、ただ止まらないだけという落とし穴でした。境界条件で黙って進まなくなる作りは、原因にたどり着くまでが長くなります。本番運用に入れる前に踏めたのは運が良かっただけで、対処としては「1件も入らないなら即座に失敗させる」以外にありません。
予算別の実測は次のとおりです。
| バイト予算 | 収まった件数 | 実際のサイズ | 充填率 |
| 2,048 B | 34 件 | 2,034 B | 99.3% |
| 4,096 B | 68 件 | 4,075 B | 99.5% |
| 8,192 B | 136 件 | 8,189 B | 100.0% |
| 16,384 B | 270 件 | 16,363 B | 99.9% |
充填率は 99.3〜100.0%。件数で limit: 20 のように決め打ちするより、予算に対して素直です。
そして「8 KB なら 136 件」という数字が手元にあると、limit の既定値をいくつにするかで迷わなくなります。私は既定を 50 件、上限を 136 件に設定しました。
ツールを統合する理由は「量」ではなく「見分けやすさ」だった
ここまでの実測で、ツールを減らす動機は失われたように見えます。定義は 189 バイトしかかからないのですから。
それでも私は12本を6本へ畳みました。理由が入れ替わったからです。
外していたとき、実は困っていたのはサイズではなく、意図した通りのツールが選ばれない頻度でした。list_wallpapers を想定した問い合わせに search_wallpapers が呼ばれる、といった取り違えです。
これは測れます。ツール名と説明を連結し、文字の bigram 集合にして Jaccard 係数を出せば、「どのツール同士が見分けにくいか」が数字になります。
// ツール定義の「見分けにくさ」を測る。名前+説明の文字 bigram で Jaccard 係数を取ります
const bigrams = (s) => {
const t = s.toLowerCase().replace(/[_\s]/g, "");
const set = new Set();
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
return set;
};
const jaccard = (a, b) => {
let inter = 0;
for (const x of a) if (b.has(x)) inter++;
return inter / (a.size + b.size - inter);
};
function collisionReport(tools, threshold = 0.30) {
const sets = tools.map((t) => bigrams(t.name + " " + t.description));
const pairs = [];
for (let i = 0; i < tools.length; i++) {
for (let j = i + 1; j < tools.length; j++) {
pairs.push({ a: tools[i].name, b: tools[j].name, score: jaccard(sets[i], sets[j]) });
}
}
pairs.sort((x, y) => y.score - x.score);
return {
worst: pairs.slice(0, 5),
over: pairs.filter((p) => p.score >= threshold),
mean: pairs.reduce((s, p) => s + p.score, 0) / pairs.length,
};
}
12本に対して走らせた結果です。
| 類似度 | ツールの組 |
| 45.5% | get_build_config × get_app_config |
| 33.3% | get_catalog_stats × validate_catalog |
| 32.3% | list_wallpapers × search_wallpapers |
| 30.0% | list_wallpapers × find_wallpapers |
| 25.0% | find_wallpapers × get_wallpaper |
66 組のうち 30% 以上が 4 組、平均は 13.1% でした。
首位の get_build_config × get_app_config が 45.5% というのは、名前も説明もほぼ同型だということです。「ビルド設定を取得します」と「アプリ設定を取得します」では、どちらを呼ぶべきかを説明文から決められません。人間でも迷います。
統合は次の方針で行いました。
- 検索系3本(
list / search / find)を query_wallpapers 1本にまとめ、category / tag / query / limit を引数に落とします
- 設定系2本を
get_config 1本にし、どちらを取るかは profile 引数で表します
- i18n 系3本を
i18n_report 1本にし、網羅率と未翻訳キーを同じ応答で返します
get_wallpaper は「1件・全フィールド」の唯一の入口として残します
- 説明文には必ず「何を返さないか」を1文入れます
畳んだあとの6本を同じ尺度で測ると、最大 17.0%、平均 9.4% でした。最大値が 45.5% から 17.0% へ、平均が 13.1% から 9.4% へ下がっています。
数字が下がったこと自体より、下げるべき対象が「本数」ではなく「説明文の重なり」だったと分かった点が、私にとっての収穫でした。本数を減らしても説明が似たままなら、取り違えは残ります。逆に、説明が十分に離れているなら 12 本あっても構いません。
統合しすぎると別の問題が出ます
正直に書いておくと、query_wallpapers に引数を4つ持たせた設計は万能ではありません。引数の組み合わせが増えるほど、意図しない組み合わせで呼ばれる余地も広がります。
私の場合は、引数を4つまでに抑え、それを超えそうになったらツールを分けるという線引きにしました。引数がどうしても5つ必要になるこの場合は、関心事が2つ混ざっていると考えて分割するほうを選びます。5つ目が欲しくなった時点で、それは別の関心事だという判断です。この境目に理論的な根拠はなく、いまのところ破綻していないという程度の話です。
自分のサーバを測るための小さなハーネス
ここまでの数字を出すのに使った測定側のコードも置いておきます。既存の MCP サーバに向けても、server.mjs のパスを差し替えれば動きます。
// bench.mjs — 手元の MCP サーバのバイト数と往復時間を測ります
// 実行: node bench.mjs ./server.mjs
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
const target = process.argv[2] ?? "./server.mjs";
const child = spawn("node", [target], { stdio: ["pipe", "pipe", "inherit"] });
const rl = createInterface({ input: child.stdout });
const pending = new Map();
let id = 0;
rl.on("line", (line) => {
const msg = JSON.parse(line);
const resolve = pending.get(msg.id);
if (resolve) { pending.delete(msg.id); resolve(msg); }
});
const rpc = (method, params) =>
new Promise((resolve) => {
const i = ++id;
pending.set(i, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n");
});
const B = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
await rpc("initialize", {});
// (1) 定義側のコスト
const list = await rpc("tools/list", {});
console.log(`tools/list: ${B(list.result)} bytes / ${list.result.tools.length} tools`);
// (2) 応答側のコスト — 一番大きくなりそうな呼び出しを1つ選びます
const call = await rpc("tools/call", { name: "list_wallpapers", arguments: {} });
const respBytes = Buffer.byteLength(call.result.content[0].text, "utf8");
console.log(`tools/call: ${respBytes} bytes`);
console.log(`比率: 応答は定義の ${(respBytes / B(list.result)).toFixed(1)} 倍`);
// (3) 往復時間 — トランスポートが律速かどうかの確認
const samples = [];
for (let i = 0; i < 200; i++) {
const t0 = process.hrtime.bigint();
await rpc("tools/call", { name: "list_categories", arguments: {} });
samples.push(Number(process.hrtime.bigint() - t0) / 1e6);
}
samples.sort((a, b) => a - b);
console.log(`round trip p50=${samples[100].toFixed(2)}ms p95=${samples[190].toFixed(2)}ms`);
child.kill();
rl.on("line") で id を突き合わせている部分は、省略すると応答の取り違えが起きます。JSON-RPC は応答順序を保証しませんので、id で対応づけるのが本来の作法です。
比率の行が2桁になっていれば、この記事と同じ状況です。応答の側から手を付けてください。
いまの設計に落ち着いた3つのルール
一連の測定を経て、私が MCP サーバを書くときに守ることにしたのは次の3つです。
- ツールの本数を気にしない。1本あたり 189 バイト前後という固定費は、応答の変動費に対して無視できます
- 応答は必ず射影とバイト予算で縛る。返すフィールドを説明文に明記し、レコード境界で切るページャを通します
- 説明文の類似度を測ってから統合する。30% を超える組があれば、統合するか説明を書き分けることを推奨します
もし手元に MCP サーバがあるなら、最初にやることは2つだけです。tools/list の応答を1回バイト数で数えること。そして一番大きな tools/call の応答を1回数えること。この2つの数字が2桁違っていれば、削るべき側は決まります。
私自身、思い込みで6本を消して半日を使ってしまいました。測るのは 20 行のスクリプトで済みます。同じ回り道を省く材料になれば嬉しいです。
カタログの応答サイズという観点では、キー短縮で 19% 減らしても配信は 3% しか軽くならない — カタログ JSON の圧縮実測と配置換え で、gzip をかけたあとに何が残るかも測っています。あわせて読んでいただけると、どこを削ると本当に効くのかの感覚がつかめると思います。
お読みいただきありがとうございました。