自作の MCP サーバを Antigravity につないで最初は快適に動いていたのに、リポジトリやデータが育ってくると、ある日からエージェントの応答が急に鈍くなった——そんな経験はないでしょうか。私自身、個人開発で4つの技術ブログの記事更新を自動化していて、記事一覧を返す自作ツールが800件を超えたあたりで、エージェントが途中から指示を取りこぼし始めました。原因はモデルの劣化でもプロンプトの崩れでもなく、ツールが一回の呼び出しで全件を返していたことでした。
ツール結果はそのままエージェントのコンテキストに積まれます。つまり MCP サーバの出力設計は、そのままエージェントの「残り思考容量」の設計でもあります。ここでは、結果が大きく育っても破綻しないサーバ側の作り方を、実装と実測の両面から整理します。
大きなツール結果は、エラーにならずに静かに効く
厄介なのは、コンテキストが圧迫されても多くの場合は例外が飛ばないことです。モデルは入る分だけ読み、古い指示や前段のツール結果を静かに押し出していきます。結果として「さっき渡したルールを無視する」「途中の手順を飛ばす」という、再現性の低い不具合として表面化します。
私が運用しているツールの素朴な実装は、こういう形でした。
// ❌ 育つと破綻する: 全件をそのまま返す
server.registerTool(
"list_articles",
{
description: "サイトの記事一覧を返す",
inputSchema: { site: z.string() },
},
async ({ site }) => {
const articles = await db.allArticles(site); // 800件以上
return {
content: [{ type: "text", text: JSON.stringify(articles, null, 2) }],
};
}
);
1件あたり本文抜粋やタグを含めて平均 600 トークンだとすると、800件で約 48 万トークンです。これはどのモデルのウィンドウにも収まりません。仮に収まっても、その瞬間にエージェントの作業余力はほぼゼロになります。
まず「ツール結果が何トークンか」を測る
設計を変える前に、現状のコストを数値で押さえます。MCP サーバ側でツール結果のサイズをログに出すだけでも、どのツールが重いかがすぐ見えてきます。
import { encoding_for_model } from "tiktoken";
const enc = encoding_for_model("gpt-4o"); // 概算用。厳密でなくてよい
function logResultCost(toolName: string, payload: string) {
const tokens = enc.encode(payload).length;
console.error(`[mcp] ${toolName} -> ${tokens} tokens (${payload.length} chars)`);
return tokens;
}
console.error を使うのは、MCP の stdio トランスポートでは標準出力がプロトコル専用で、ログを混ぜると通信が壊れるためです。これは自作サーバで最初にハマりやすい落とし穴なので、デバッグ出力は必ず標準エラーへ送ってください。
実際に測ると、私の環境では list_articles が単独で 48 万トークン相当、次に重い search が 6 万トークン相当でした。重いツール上位2〜3個を直すだけで、体感はほぼ元に戻ります。
原則1:サーバ側で「全部返す」をやめる(フィールド射影)
エージェントが一覧から必要とするのは、たいてい「どの記事があるか」を判断するための最小情報です。本文やメタデータ全部ではありません。そこで、呼び出し側が欲しいフィールドだけを指定できるようにします。
const FIELDS = ["slug", "title", "updatedAt", "premium"] as const;
server.registerTool(
"list_articles",
{
description: "記事の要約一覧を返す。fields で取得する列を絞れる",
inputSchema: {
site: z.string(),
fields: z.array(z.enum(FIELDS)).default(["slug", "title"]),
},
},
async ({ site, fields }) => {
const rows = await db.allArticles(site);
const projected = rows.map((r) =>
Object.fromEntries(fields.map((f) => [f, r[f]]))
);
return { content: [{ type: "text", text: JSON.stringify(projected) }] };
}
);
これだけで1件 600 トークンが 30 トークン前後まで落ちます。ただし件数そのものが多ければ、射影しても総量は線形に増えます。次の手はページングです。
原則2:ページングと継続トークン(cursor)
一覧は必ず上限付きで返し、続きは継続トークンで取りに来させます。MCP の仕様にも nextCursor を使うページングの考え方があり、これに合わせると Antigravity 側の挙動とも素直に噛み合います。
const PAGE_LIMIT = 20;
server.registerTool(
"list_articles",
{
description: "記事一覧をページ単位で返す。続きは cursor を渡す",
inputSchema: {
site: z.string(),
cursor: z.string().optional(),
limit: z.number().min(1).max(50).default(PAGE_LIMIT),
},
},
async ({ site, cursor, limit }) => {
const offset = cursor ? decodeCursor(cursor) : 0;
const rows = await db.allArticles(site);
const page = rows.slice(offset, offset + limit);
const next = offset + limit < rows.length
? encodeCursor(offset + limit)
: null;
const summary = page.map((r) => ({ slug: r.slug, title: r.title }));
return {
content: [{
type: "text",
text: JSON.stringify({
items: summary,
nextCursor: next,
total: rows.length,
}),
}],
};
}
);
// cursor は中身を推測されないよう base64 で包むだけでも実用上は十分です
const encodeCursor = (n: number) => Buffer.from(String(n)).toString("base64");
const decodeCursor = (c: string) => Number(Buffer.from(c, "base64").toString());
total を一緒に返すのが地味に効きます。エージェントは「全体で何件あるか」を1ページ目で把握できるので、闇雲に全ページをめくる代わりに、必要な範囲だけを取りに来るようになります。
原則3:resource_link で「中身ではなく参照」を返す
本文のような大きなデータは、テキストとして埋め込むのではなく resource_link で参照だけを返すのが効果的です。エージェントは一覧を眺め、本当に開きたい1件だけを後から取得します。これは「全部を一度コンテキストに載せる」のとは正反対の発想で、人間がファイラーでファイル名を眺めてから開くのに近い動きになります。
async ({ site, cursor, limit }) => {
const { page, next, total } = await fetchPage(site, cursor, limit);
const links = page.map((r) => ({
type: "resource_link" as const,
uri: `article://${site}/${r.slug}`,
name: r.title,
description: `${r.updatedAt} ${r.premium ? "[premium]" : ""}`.trim(),
mimeType: "text/markdown",
}));
return {
content: [
{ type: "text", text: JSON.stringify({ nextCursor: next, total }) },
...links,
],
};
}
参照先は別のリソースハンドラ(article://...)で解決します。本文 600 トークンが、リンク1件あたり 30〜40 トークンに圧縮されます。20件ぶん並べても 1 ページ 1,000 トークン未満に収まる計算です。
サーバ側要約は強力だが、契約を濁す
「重いなら要約して返せばいい」と考えたくなりますが、私はこれを既定の方針にはしていません。要約はモデル呼び出しを1段増やし、レイテンシとコストを足し、しかも要約の品質がツールの信頼性に直結してしまいます。エージェントが「一覧ツールは事実を返す」と期待しているところに、解釈の入った文章が混ざると、後段の判断が静かにぶれます。
要約を入れるなら、次の条件をすべて満たすときに限るのが安全だと考えています。
- 元データが構造化しにくい自由文である(ログ、長文ドキュメント等)
- 要約専用の別ツール名にして、一覧ツールとは分ける
- 要約の根拠となる resource_link を必ず併記し、原文へ辿れるようにする
つまり要約は「一覧の代わり」ではなく「明示的に頼まれたときの別サービス」として切り出します。一覧・検索系のツールは、あくまで射影とページングで軽くするのが本筋です。
出力予算をツール契約に組み込む
ここまでの工夫を、各ツールに散らばった暗黙ルールにしておくと、いずれ破綻します。私は「1回のツール結果は N トークンを超えない」という出力予算を、ツールの入力スキーマと実装の両方に明文化しています。
const OUTPUT_BUDGET = 2000; // トークン上限(ツール共通の契約)
function enforceBudget(payload: string, toolName: string): string {
const tokens = enc.encode(payload).length;
if (tokens <= OUTPUT_BUDGET) return payload;
// 予算超過は「黙って切る」のではなく、超過を明示して縮小を促す
return JSON.stringify({
error: "OUTPUT_BUDGET_EXCEEDED",
tool: toolName,
tokens,
budget: OUTPUT_BUDGET,
hint: "limit を下げるか fields を絞って再呼び出ししてください",
});
}
超過時に結果を無言で切り詰めると、エージェントは「途中までのデータを全体だと誤認」します。これは一番たちの悪い失敗です。代わりに、予算超過を構造化エラーとして返し、どう呼び直せばよいかのヒントを添えます。Antigravity のエージェントはこのヒントを読んで limit を下げて再試行してくれるため、人間が介入しなくても自律的に収束します。
実測:4サイト運用のツールに適用した前後
私が運用している記事更新の自動化で、list_articles にフィールド射影・ページング・出力予算を入れた前後を測った結果です。1ページあたりの上限は 20 件にしました。
| 項目 | 適用前 | 適用後 |
1回の list_articles 結果 | 約 48 万トークン | 約 1,400 トークン |
| 一覧取得後に残る作業余力 | ほぼゼロ | ウィンドウの 9 割超 |
| 「前段の指示を取りこぼす」頻度 | 体感で 3 回に 1 回 | 再現せず |
| 1記事を開くまでのツール往復 | 1 回(全件取得) | 2〜3 回(一覧→参照解決) |
往復回数は増えますが、1往復が軽くなったので総レイテンシはむしろ短くなりました。なにより、エージェントが指示を取りこぼす不具合が消えたことが大きく、夜間のバッチ実行を安心して任せられるようになりました。
offset カーソルは、ページの間でデータが動くと崩れる
ここまで簡単のため offset ベースのカーソルを使いましたが、本番で長く運用すると一つ注意点が出てきます。1ページ目と2ページ目を取得する間に記事が追加・削除されると、offset がずれて重複や取りこぼしが起きます。私の自動化でも、夜間に別タスクが記事を1本追加した瞬間に、エージェントが同じ記事を二度処理しかけたことがありました。
実害が出るほど更新が頻繁なら、offset ではなく「最後に見た行のキー」を継続トークンに使うキーセット方式へ切り替えます。
// 安定キー(updatedAt + slug)でカーソルを作る
const decodeKey = (c?: string) =>
c ? JSON.parse(Buffer.from(c, "base64").toString()) : null;
async function fetchPageStable(site: string, cursor: string | undefined, limit: number) {
const after = decodeKey(cursor); // { updatedAt, slug } or null
const rows = await db.articlesAfter(site, after, limit + 1); // 1件多めに取る
const hasMore = rows.length > limit;
const page = rows.slice(0, limit);
const last = page.at(-1);
const next = hasMore && last
? Buffer.from(JSON.stringify({ updatedAt: last.updatedAt, slug: last.slug })).toString("base64")
: null;
return { page, next };
}
ポイントは、ソートキーに一意性を持たせること(updatedAt だけだと同時刻の記事で取りこぼすので slug を併用)と、limit + 1 件取って「次があるか」を1クエリで判定することです。一覧の整合性が崩れると、エージェントは静かに同じ作業を繰り返したり、存在するはずの記事を見落としたりします。コンテキスト量だけでなく、ページングの正しさもツールの信頼性に直結すると考えています。
どこから手を付けるか
全ツールを一度に作り変える必要はありません。最初の一手は、いま動いているサーバに console.error のトークンログを1行足し、重いツール上位を特定することです。多くの場合、犯人は一覧系か検索系の1〜2個に集中しています。そこへフィールド射影と上限付きページングを入れ、本文のような重いペイロードを resource_link に置き換えれば、体感の大半は戻ります。
ツールの境界設計や権限の絞り込みまで踏み込みたい場合は、自作 MCP サーバの本番運用ガイド と MCP ツールの最小権限アロウリスト設計 が次の足がかりになります。コンテキスト全体のコスト戦略としては エージェントのプロンプトキャッシュとコンテキストコスト設計 も合わせて読むと、出力予算の置きどころが見えてくるはずです。
お読みいただきありがとうございました。同じように自作ツールでエージェントが鈍る場面に出会っている方の、最初のひと押しになれば嬉しいです。