設定ファイルを開いたら、半分が「もう配られていない」ものだった
きっかけは、動かなくなった一行でした。
しばらく触っていなかった Antigravity の settings.json を開いて、mcpServers に並んだサーバーを順に起動し直したところ、いくつかが黙って立ち上がらない。ログを追う前に、そもそもそのパッケージがまだ配布されているのかを確かめるべきでした。
npm レジストリに問い合わせた結果は、想像より厳しいものでした。当時よく紹介されていた構成のうち、6本が deprecated、2本はそもそも存在しない。設定ファイルとしては何の警告も出ないまま、ただ起動しないだけです。
MCP は仕様としては安定していますが、リファレンス実装の配布はかなり動いています 。ここでは「どのサーバーが良いか」を並べる代わりに、手元の設定が生きているかを機械で判定する方法 と、サーバーを増やしたときに何がどれだけ削られるのか を測ったところから始めます。すべて再現できる手順で書きますので、この記事の数値も、ご自身の環境で取り直していただければと思います。
settings.json を機械で監査する
まず、設定ファイルを人間の記憶で判断するのをやめます。mcpServers の各エントリから npm パッケージ名を抜き、レジストリの dist-tags と time と deprecated を見て判定するだけの小さなスクリプトです。
#!/usr/bin/env node
// mcp-audit.mjs — settings.json の mcpServers に並ぶ npm パッケージが
// まだ生きているかを npm レジストリに問い合わせて判定する。
import { readFileSync } from "node:fs" ;
const configPath = process.argv[ 2 ];
if ( ! configPath) {
console. error ( "usage: node mcp-audit.mjs <path-to-settings.json>" );
process. exit ( 2 );
}
const cfg = JSON . parse ( readFileSync (configPath, "utf8" ));
const servers = cfg.mcpServers ?? {};
// args から npm パッケージ名らしきトークンを拾う(-y や --db-path 等のフラグは除外)
function pickPackage ( entry ) {
const args = entry.args ?? [];
if ( ! / ^ (npx | pnpx | bunx) $ / . test (entry.command ?? "" )) return null ;
return args. find (( a ) => ! a. startsWith ( "-" ) && / ^ (@ [\w.-] + \/ ) ? [\w.-] +$ / . test (a)) ?? null ;
}
const STALE_DAYS = 365 ;
let worst = 0 ;
for ( const [ name , entry ] of Object. entries (servers)) {
const pkg = pickPackage (entry);
if ( ! pkg) { console. log ( `- ${ name }: npm 配布ではないため判定を省略` ); continue ; }
const res = await fetch ( `https://registry.npmjs.org/${ pkg . replace ( "/" , "%2f" ) }` );
if (res.status === 404 ) { console. log ( `❌ ${ name }: ${ pkg } は npm に存在しません` ); worst = Math. max (worst, 2 ); continue ; }
if ( ! res.ok) { console. log ( `? ${ name }: ${ pkg } 問い合わせ失敗 (HTTP ${ res . status })` ); continue ; }
const doc = await res. json ();
const latest = doc[ "dist-tags" ]?.latest;
const published = doc.time?.[latest];
const deprecated = doc.versions?.[latest]?.deprecated;
const ageDays = Math. floor ((Date. now () - Date. parse (published)) / 86400000 );
if (deprecated) {
console. log ( `❌ ${ name }: ${ pkg }@${ latest } は deprecated — ${ String ( deprecated ). slice ( 0 , 60 ) }` );
worst = Math. max (worst, 2 );
} else if (ageDays > STALE_DAYS ) {
console. log ( `⚠️ ${ name }: ${ pkg }@${ latest } は最終公開から ${ ageDays } 日(${ published . slice ( 0 , 10 ) })` );
worst = Math. max (worst, 1 );
} else {
console. log ( `✅ ${ name }: ${ pkg }@${ latest }(${ published . slice ( 0 , 10 ) } 公開・${ ageDays } 日前)` );
}
}
process. exit (worst);
依存はありません。Node 22 の組み込み fetch だけで動きます。当時よく見かけた構成をそのまま食わせた結果が次の通りです。
❌ github: @modelcontextprotocol/server-github@2025.4.8 は deprecated — Package no longer supported.
❌ postgres: @modelcontextprotocol/server-postgres@0.6.2 は deprecated — Package no longer supported.
❌ sqlite: @modelcontextprotocol/server-sqlite は npm に存在しません
❌ notion: @notionhq/client-mcp は npm に存在しません
✅ filesystem: @modelcontextprotocol/server-filesystem@2026.7.10(2026-07-10 公開・42 日前)
✅ playwright: @playwright/mcp@0.0.79(2026-08-06 公開・15 日前)
- internal: npm 配布ではないため判定を省略
exit=2
終了コードを 0 / 1 / 2 で返しているのは、CI に置くためです。私は月に一度これを回すようにしました。設定ファイルは書いた瞬間から腐り始めるので、腐ったことに気づく仕組みだけは自動にしておきたいところです。
STALE_DAYS を 365 にしているのは、MCP のリファレンス実装が概ね年単位でリリースされているためです。四半期ごとに更新される社内サーバーを採用する場合は、この閾値を短めに倒したほうが実態に合います。
いま生きている公式リファレンスサーバー
上記の判定を @modelcontextprotocol スコープ全体に広げた結果です。2026-08-21 時点でレジストリから取得した値をそのまま載せます。
パッケージ 最新 最終公開 状態
server-filesystem2026.7.10 2026-07-10 維持
server-memory2026.7.4 2026-07-04 維持
server-sequential-thinking2026.7.4 2026-07-04 維持
server-everything2026.8.18 2026-08-18 維持
server-pdf1.7.5 2026-07-23 維持
server-github2025.4.8 2025-04-08 deprecated
server-gitlab2025.4.25 2025-04-25 deprecated
server-slack2025.4.25 2025-04-25 deprecated
server-puppeteer2025.5.12 2025-05-12 deprecated
server-brave-search0.6.2 2024-12-04 deprecated
server-sqlite— — 404
ここから読み取れることは1つです。「公式サーバーだから安全」という選定基準は、もう選定基準として機能していません。 リファレンス実装は仕様のデモンストレーションとして残り、実運用に耐える統合はサービス提供元自身が配るようになりました。Notion は @notionhq/notion-mcp-server(2.5.1・2026-07-25)、ブラウザ操作は @playwright/mcp(0.0.79・2026-08-06)というように、ベンダー名前空間に移っています。
GitHub・GitLab・Slack・PostgreSQL については、npm 経由の配布が止まっているという事実だけを述べるにとどめます。置き換え先は提供元が案内している経路をご確認ください。ここで私が推測を書くと、この記事自体が来年また同じように腐ります。だからこそ、判定は記事ではなくスクリプトに任せる形にしました。
ツール定義が食う文脈量を測る
サーバー選定で本当に効いてくるのは、star 数でも更新頻度でもなく、そのサーバーを繋いだ瞬間にコンテキストから引かれる量 です。MCP クライアントは接続時に tools/list を取得し、ツール名・説明・入力スキーマをモデルに渡します。会話が始まる前に、その分だけ席が埋まります。
測ってみました。公式リファレンスサーバー4本に接続し、tools/list の応答を JSON 化して cl100k_base でトークン化した結果です。
サーバー ツール数 JSON バイト トークン 1ツールあたり
server-filesystem 14 13,663 3,330 237
server-memory 9 11,503 2,945 327
server-sequential-thinking 1 4,707 1,091 1,091
server-everything 13 8,124 2,060 158
4本合計 37 — 9,426 255
4本繋いだだけで9,426トークンです。何も指示していない状態で、これがすでに載っています。
sequential-thinking の行が示すものが特に興味深いところでした。ツールは1本きりなのに1,091トークンを使います。説明文が2,781文字あるためです。逆に everything は13本で2,060トークン、1ツールあたり158トークンに収まっています。ツール数はコストの指標になりません。説明文とスキーマの記述量がコストです。
この測定は次のコードで再現できます。クライアント側から見た実測なので、ドキュメントの記述ではなく実際にモデルへ渡る形に近い値になります。
// probe.mjs — 接続先サーバーの tools/list が何トークン相当かを測る
import { Client } from "@modelcontextprotocol/client" ;
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio" ;
import { writeFileSync } from "node:fs" ;
const targets = [
[ "filesystem" , [ "node" , "node_modules/@modelcontextprotocol/server-filesystem/dist/index.js" , "/tmp/sandbox" ]],
[ "memory" , [ "node" , "node_modules/@modelcontextprotocol/server-memory/dist/index.js" ]],
];
const out = {};
for ( const [ name , argv ] of targets) {
const transport = new StdioClientTransport ({ command: argv[ 0 ], args: argv. slice ( 1 ) });
const client = new Client ({ name: "probe" , version: "1.0.0" }, { capabilities: {} });
await client. connect (transport);
const { tools } = await client. listTools ();
const json = JSON . stringify (tools);
out[name] = tools;
console. log (
`${ name . padEnd ( 22 ) } tools=${ String ( tools . length ). padStart ( 3 ) } ` +
`bytes=${ String ( Buffer . byteLength ( json , "utf8" )). padStart ( 6 ) }`
);
await client. close ();
}
// トークン化は別プロセスに任せる(tiktoken 等)
writeFileSync ( "tools.json" , JSON . stringify (out, null , 1 ));
出力した tools.json を任意のトークナイザに通せば、上の表と同じ列が揃います。私は cl100k_base を使いましたが、モデルによって符号化は異なるので、絶対値ではなくサーバー間の比率 を見るほうが判断には使えます。
実務上の落としどころとして、私は次のように運用しています。常時接続するのは3本まで。それを超えるものはプロジェクト単位の設定ファイルに分け、必要なときだけ有効にする。「とりあえず繋いでおく」を一度やめてみると、応答の質が変わることに気づきます。モデルが選択肢を絞りやすくなるためだと考えています。
npx で起動するか、ローカルの dist を直接叩くか
もう1つ、設定ファイルの書き方で差が出る箇所があります。command に npx を書くか node を書くかです。
同じ server-filesystem に対して、接続から tools/list 完了までを3回ずつ測りました。npx 側はキャッシュが温まった状態です。
起動方法 中央値 3回の実測
node node_modules/.../dist/index.js246ms 237 / 246 / 248ms
npx -y @modelcontextprotocol/server-filesystem615ms 598 / 615 / 632ms
差は約2.5倍、実数で369ms です。1本なら誤差の範囲かもしれません。ただしこれはサーバーごとに発生します。5本繋いでいれば、起動のたびに約1.8秒がパッケージ解決に消えている計算になります。しかもキャッシュが冷えていれば、ここにダウンロードが乗ります。
npx -y には別の側面もあります。バージョンを固定していない限り、起動のたびに解決結果が変わり得る ということです。昨日動いていた構成が今朝は動かない、という事態の入口はたいていここにあります。本番に近い環境ほど、依存として明示的にインストールし、node で直接叩く形に寄せるほうが落ち着きます。
3つのプリミティブと、実際に使うことになるもの
MCP の通信は Tools・Resources・Prompts の3つで構成されます。仕様上は対等ですが、実際に組んでみると使用頻度は大きく偏ります。
Tools — モデルが呼び出す関数。ほぼこれだけで組めてしまいます
Resources — モデルが読み取るデータ。URI で参照でき、ツール呼び出しを介さずに文脈へ載せられます
Prompts — 再利用可能なテンプレート。クライアント側の UI 対応が前提になるため、自作サーバーでは後回しにして差し支えありません
自作サーバーを書き始めるときは、Tools だけで通します。Resources が効いてくるのは、同じデータを何度も参照する場合です。毎回ツールを呼ぶより、リソースとして一度載せたほうがラウンドトリップが減ります。
カスタムサーバーを SDK 2.x で書き直す
SDK も動いています。@modelcontextprotocol/sdk は 1.30.0(2026-07-27)が最新ですが、同じ日付で @modelcontextprotocol/server /client /node /core の 2.0.0 が公開され、パッケージが分割されました。サーバーだけを書くなら @modelcontextprotocol/server を入れるだけで済みます。
以前よく見かけた Server クラスに ListToolsRequestSchema と CallToolRequestSchema のハンドラを登録し、switch でツール名を分岐する書き方は、いまは McpServer と registerTool に置き換わっています。手で書く JSON Schema も不要になりました。次は実際に起動して動作を確認したコードです。
// inventory-server.mjs
import { McpServer } from "@modelcontextprotocol/server" ;
import { serveStdio } from "@modelcontextprotocol/server/stdio" ;
import { z } from "zod" ;
const DB = { "SKU-1001" : { tokyo: 12 , osaka: 0 }, "SKU-1002" : { tokyo: 0 , osaka: 5 } };
serveStdio (() => {
const server = new McpServer ({ name: "inventory" , version: "1.0.0" });
server. registerTool ( "get_inventory" , {
title: "在庫照会" ,
description: "商品IDの在庫数を倉庫別に返す。warehouseId 省略時は全倉庫合計。" ,
inputSchema: z. object ({
productId: z. string (). regex ( / ^ SKU- \d {4}$ / ),
warehouseId: z. enum ([ "tokyo" , "osaka" ]). optional (),
}),
outputSchema: z. object ({
productId: z. string (),
total: z. number (),
byWarehouse: z. record (z. string (), z. number ()),
}),
annotations: { readOnlyHint: true , idempotentHint: true , openWorldHint: false },
}, async ({ productId , warehouseId }) => {
const row = DB [productId];
if ( ! row) {
// 「次に何をすればよいか」をモデルが読める形で返す
return {
isError: true ,
content: [{
type: "text" ,
text: `商品ID ${ productId } は存在しません。有効な例: ${ Object . keys ( DB ). join ( ", " ) }` ,
}],
};
}
const byWarehouse = warehouseId ? { [warehouseId]: row[warehouseId] ?? 0 } : row;
const output = {
productId,
total: Object. values (byWarehouse). reduce (( a , b ) => a + b, 0 ),
byWarehouse,
};
return { content: [{ type: "text" , text: JSON . stringify (output) }], structuredContent: output };
});
return server;
});
以前の書き方と比べて変わった点を3つ挙げます。
inputSchema に zod スキーマをそのまま渡せるようになりました。JSON Schema は SDK が生成します。手書きのスキーマとハンドラ内の型注釈が二重管理になり、片方だけ直して食い違う——という事故がなくなります。
outputSchema と structuredContent の対で、戻り値にも型がつきます。テキストだけを返していた頃は、モデルが JSON をパースし損ねる余地が残っていました。
annotations の readOnlyHint と idempotentHint は、クライアントが確認ダイアログを出すかどうかの判断材料になります。読み取り専用のツールにこれを付けておかないと、無害な照会にも確認が挟まって体感が悪くなります。逆に書き込み系には付けない ことが重要です。
handler の中で throw すると、AI は何を読むのか
エラーの返し方は、実際に投げてみないと分からない部分でした。throw すればプロトコルエラーになって呼び出しが失敗するのか、それともツールレベルのエラーとして返るのか。手元で確認した結果は次の通りです。
ケース クライアントが受け取る内容
正常 content + structuredContent(例外なし)
構造化した isError を返す isError: true +「有効な例: SKU-1001, SKU-1002」
スキーマ違反の引数(productId: "nope") isError: true + Input validation error: ... must match pattern に違反した正規表現がそのまま付く
handler 内で throw new Error("boom") isError: true + boom だけ
3点、実務に効く発見がありました。
第一に、throw は例外として伝播せず、SDK が捕まえて isError: true に変換します。 呼び出し自体は成功扱いで返ってきます。つまり throw と isError の違いは「落ちるか落ちないか」ではありません。モデルが読める文字列の質だけが違います。 throw new Error("boom") だと、モデルに届くのは boom の4文字です。そこから復帰の手がかりは得られません。
第二に、スキーマ違反はハンドラに到達する前に弾かれます。 productId: "nope" は正規表現で止まり、しかもエラーメッセージに違反したパターンがそのまま含まれます。モデルはこれを読んで正しい形式に直せます。入力を z.string() で広く取ってハンドラ内で検証するより、スキーマを狭く書くほうが、バリデーション処理を書かずに済むうえに復帰しやすい形になります。
第三に、エラーメッセージは人間ではなくモデルに向けて書く べきものだ、ということです。「商品が見つかりません」で終えず、有効な値の例を添える。ログに残す文章とモデルに返す文章は、目的が違います。私はこれに気づいてから、isError の text を書くときは「これを読んだモデルが次の一手を選べるか」だけを基準にするようにしました。
権限とシークレットの置き場所
最小権限は方針として正しいのですが、実際に効くのは「付与しない」判断のほうです。
GitHub 系のトークンなら repo までで足り、admin:org は不要、delete_repo は付与する理由がありません。Slack 系も chat:write と channels:read があれば大半の用途を満たします。ここで迷うのは、往々にして「後で必要になるかもしれない」という予測が働くときです。必要になったときに追加するほうが、事故の期待値は確実に下がります。
シークレットは設定ファイルに直書きせず、環境変数で渡します。
# .env.antigravity(.gitignore への追加を忘れずに)
GITHUB_TOKEN = YOUR_GITHUB_TOKEN
SLACK_BOT_TOKEN = YOUR_SLACK_BOT_TOKEN
NOTION_API_KEY = YOUR_NOTION_KEY
サードパーティのサーバーを入れるときは、@modelcontextprotocol/inspector(2.3.0・2026-08-19)で先にツール一覧と実際の応答を確認する習慣をお勧めします。npx @modelcontextprotocol/inspector node dist/index.js で起動し、ツールを手動実行して、想定外のネットワークアクセスや過剰なスコープ要求がないかを見ます。設定ファイルに書いて Antigravity から呼ぶより先に、単体で覗いておくほうが安全です。
個人開発者の視点から(実体験メモ)
MCP を触り始めた頃、私は繋げるだけ繋いでいました。使うかどうかは後で考えればいい、選択肢は多いほうがいい、と思っていたのです。
考えを変えたのは、上の9,426トークンという数字を目にしたときでした。会話を始める前に、すでにこれだけ埋まっている。しかもそのうち大半は、その日のタスクとまったく関係のないツールの説明文です。「使わないなら害はない」と思っていたものが、はっきりと席を占めていました。
個人開発では、この種の見えないコストが積み上がっても、誰も指摘してくれません。チームなら誰かが気づくのかもしれませんが、一人で回していると「なんとなく最近まとまりが悪いな」で終わってしまいます。だから測る仕組みだけは先に作っておく、というのが今の私の方針です。上の probe.mjs も mcp-audit.mjs も、賢いコードではありません。ただ、賢くないコードが定期的に回っていることのほうが、結果的に助けになっています。
throw の挙動を確かめたときも似た感覚がありました。ドキュメントを読んで分かったつもりでいたことが、実際に投げてみると違っていた。isError に変換されると知らなければ、私はいつまでも throw new Error() を書き続けて、モデルが boom の4文字だけを受け取っていることに気づかなかったはずです。
手を動かして確かめる時間は、記事を1本読む時間とそれほど変わりません。それでも、後者に流れがちなのが正直なところで、私自身まだそこを直している途中です。
次に手を動かすなら
順番はこの3つで足ります。
いまお使いの settings.json に mcp-audit.mjs を通す。所要は1分ほどです。exit=2 が返ってきたら、その設定ファイルは動かないサーバーを抱えたまま放置されていたことになります
生き残った分を probe.mjs に通し、常時接続しているサーバーの合計トークンを出す。数字を見てから「これは外せる」と判断するほうが、感覚で整理するより迷いません
自作サーバーがあれば registerTool へ寄せ、throw を構造化した isError に置き換える。ここは半日あれば終わります
自作サーバーの設計をもう一段深く詰めたい場合は、Antigravity カスタム MCP サーバーの本番運用 も併せてご覧いただければと思います。
測ってから決める、という順番に慣れると、エコシステムの動きに振り回される量が目に見えて減ります。お読みいただきありがとうございました。