MCPのTools仕様を読み解く — tools/callで何が起きているか
MCPのTools仕様が定めるtools/list・tools/callの挙動を、2026-07-28版の仕様原文から読み解きます。キャッシュ、エラー分類、追加入力の往復、ヘッダー転記まで扱います。
MCPのTools仕様は、サーバーがモデルに何をどう渡すかを細かく決めています。ツール名の文字種、結果の返し方、追加入力が必要になったときの往復、一覧のキャッシュまで、定義は仕様書の複数ページにまたがります。この記事では2026-07-28版の仕様原文を、サーバーを実装していて引っかかりやすい場面ごとに読み解きます。
仕様が決めていること、決めていないこと
Toolsは、サーバーが外部システムへの操作をモデルに公開する仕組みです。データベースへの問い合わせ、API呼び出し、計算の実行などが該当します。ツールは名前とスキーマで識別され、モデルが文脈から自動的に発見して呼び出す「モデル制御」の設計です。
仕様が定めるのは、tools/listとtools/callのメッセージの形と、結果の中身の構造です。UIの形は強制しません。危険な操作に人間の確認を挟むことは「SHOULD」(推奨)の位置づけで、プロトコルが強制する挙動ではありません。
ツール一覧が毎回変わるとキャッシュが効かない
toolsの能力(capability)を宣言したサーバーは、tools/listに必ず応答します。一覧は空でも、時間とともに変わってもかまいません。禁じられているのは、接続ごとに集合が違うこと、そして他のリクエストの副作用で変わることです。
権限で見えるツールが変わるのは許容されます。認可情報はリクエストごとの入力であって、接続の状態ではないからです。
同じツール集合なら毎回同じ順序で返すことも推奨されています。クライアントが一覧を安心してキャッシュでき、一覧をモデルの文脈に含める場合はプロンプトキャッシュのヒット率も上がります。
一覧のレスポンスにはttlMsとcacheScopeが付き、tools/listでは必ず含める決まりです。キャッシュの細則は別ページ(Caching)にあります。
ttlMsはミリ秒単位の鮮度ヒントで、HTTPのCache-Control: max-ageに近い扱いです。0なら即座に古い扱い、省略されたときも0として扱われます。負の値は0に読み替えます- TTLは「その間は取り直さなくてよい目安」であって、データが変わらない保証ではありません
listChanged: trueを宣言していて、クライアントがsubscriptions/listenでtoolsListChanged: trueを指定したストリームを開いているときは、変更通知が届いた時点でキャッシュを破棄します- ページ分割した一覧は、ページごとに独立してキャッシュされます。ページ間の一貫性は保証されず、途中でデータが変わると重複や欠落が見えることがあります
- 一覧リクエストを複数ページで返すときは、全ページに同じ
cacheScopeを付けなければなりません(MUST) input_requiredの結果、およびそれを受けた再送の結果はキャッシュ不可です
cacheScopeには落とし穴があります。publicは「ユーザー固有の情報を含まない」という宣言で、認証付きのエンドポイントから返った一覧でも、別のアクセストークンのクライアントとキャッシュが共有されえます。ユーザーごとに見えるツールが違うならprivateにします。仕様はさらに、cacheScopeだけでは不正アクセスを防げず、ツールごとのアクセス制御を別に実装する必要があると明記しています。
ツール定義のannotationsは、信頼できるサーバーから来たものでない限り、クライアントが信用してはいけません。「読み取り専用です」という申告をそのまま信じて確認を省くと、悪意あるサーバーに突かれます。
サーバー側には、ツールの入力検証、適切なアクセス制御、呼び出しのレート制限、出力のサニタイズが義務として課されています(MUST)。
複数サーバーをつなぐとツール名が衝突する
ツール名は1〜128文字、英数字・アンダースコア・ハイフン・ドットのみを推奨し、大文字小文字は区別します。スペースやカンマは避けます。getUser・DATA_EXPORT_v2・admin.tools.listが仕様の挙げる有効な例です。
名前の一意性が保証されるのは、サーバー1台の範囲だけです。複数のMCPサーバーを束ねるクライアントやプロキシでは、2台が同じsearchを公開する衝突が起こります。サーバー識別子をツール名の前に付けるような曖昧さ解消を、集約側が用意する前提です。
サーバー名(serverInfoのname)は一意とは限らず、仕様は曖昧さ解消に頼らないよう求めています(SHOULD NOT)。ゲートウェイやルーターを自作するなら、この前提を最初の設計に入れておく必要があります。
結果をどの型で返すか
tools/callの引数はツール名と引数だけですが、返り値は選択肢が多くなります。content配列には5種類を混在させられ、別にstructuredContentという返し方があります。
tools/callの結果の返し方
text
プレーンテキストです。説明文・要約・エラーメッセージを返します。
image / audio
Base64エンコードしたメディアデータを
mimeType付きで直接返します。resource link
resources/readで取得できるURIだけを返します。大きなファイルなど、必要になるまで中身を送りたくない場合向けです。embedded resource
リソースの中身ごと結果に含めます。呼び出し直後にモデルへ読ませたい小さなリソース向けです。
structuredContent
outputSchemaに沿ったJSON値です。オブジェクトに限らず、配列や数値も返せます。
resource linkで返したURIは、resources/listの一覧に現れるとは限りません。後からresources/readで参照させるつもりなら、サーバー側がそのURIを読める状態にしておく必要があります。embedded resourceを使うサーバーは、resources能力も実装することが推奨されています(SHOULD)。失敗したDAG runの調査結果を1回の呼び出しで返す設計の実例は、Airflow MCPサーバーで確認できます。
outputSchemaを定義した場合、サーバーはそのスキーマに適合した構造化結果を返す義務があり、クライアントは検証することが推奨されます。後方互換のため、構造化データを返すときは同じ内容をシリアライズしたJSONテキストもcontentに入れることが推奨されています。このstructuredContentは、LLMの「構造化出力」(モデルの生成をスキーマで縛る機能)とは無関係の、サーバーが作るデータです。
{
"result": {
"resultType": "complete",
"content": [
{ "type": "text", "text": "{\"temperature\": 22.5, \"conditions\": \"Partly cloudy\"}" }
],
"structuredContent": {
"temperature": 22.5,
"conditions": "Partly cloudy"
}
}
}引数のないツールのinputSchemaは、{ "type": "object", "additionalProperties": false }が推奨形です。{ "type": "object" }も有効ですが、任意のオブジェクトを受け付けてしまいます。nullは不可です。
モデルが同じ失敗を繰り返すとき — エラーの2系統
エラーは、モデルが自力で直せるかどうかで2系統に分かれます。
どちらのエラーで返すか
プロトコルエラー
存在しないツール名、CallToolRequestのスキーマを満たさない不正なリクエスト、サーバー内部の障害が該当します。標準のJSON-RPCエラー(code: -32602など)で返します。
ツール実行エラー
API障害、日付形式や範囲外の入力値、業務ロジック上の失敗が該当します。tools/callの結果にisError: trueを付けて返します。
クライアントは、ツール実行エラーをモデルに渡して自己修正を促すことが推奨されています。プロトコルエラーのほうは、渡してもよい(MAY)という扱いで、回復の見込みは低いと仕様自身が書いています。
仕様の例では、Invalid departure date: must be in the future. Current date is 08/08/2025.という本文をisError: trueで返しています。何が悪く、どう直せばよいかが本文に入っているのがポイントです。
入力検証の失敗までJSON-RPCエラーに寄せると、モデルは「日付の形式が違う」ことに気づけず、同じ呼び出しを繰り返します。
途中でユーザーに聞き返したいとき — input_required
ツールの実行中に追加の入力が要ることがあります。GitHubのユーザー名の確認が仕様の例です。tools/callはresultType: "input_required"を返せます。
追加入力の往復(multi round-trip requests)
- 1
サーバーが追加入力を要求する
inputRequestsに尋ねる内容(elicitation/createのフォーム定義など)を、requestStateに不透明な文字列を入れて返します。最初のリクエストはここで終了です。 - 2
クライアントが入力を集める
ユーザーなどから入力を集めます。
inputRequestsが無くrequestStateだけなら、集める作業なしにすぐ再送できます。 - 3
同じ呼び出しを再送する
同じツール名・同じ引数に
inputResponsesとrequestStateを足し、新しいidで送ります。requestStateはサーバーから受け取った値を一字一句そのまま返します。 - 4
サーバーが結果を返す
十分な情報が揃えば最終結果を返します。足りなければ、エラーにせず新たな
input_requiredで聞き直します。
{
"result": {
"resultType": "input_required",
"inputRequests": {
"github_login": {
"method": "elicitation/create",
"params": { "mode": "form", "message": "GitHubのユーザー名を入力してください" }
}
},
"requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."
}
}この方式が使えるのはtools/callのほか、prompts/getとresources/readです。ほかのリクエストにinput_requiredを返してはいけません。クライアントが宣言していない能力、たとえばelicitation未対応のクライアントにelicitation/createを要求することも禁じられています。
2026-07-28版で導入されたこの方式は、旧来の「サーバー起点のリクエスト」を置き換える破壊的変更です。roots/list、sampling/createMessage、elicitation/createはこの形で送る決まりになりました。サーバー間で状態を共有するストレージ層も、ステートフルなロードバランシングも要らなくなるのが狙いです。古い実装ガイドに沿ったサーバーは、ここで動かなくなります。
requestStateは攻撃者が書き換えられる入力として扱う
requestStateはクライアントを経由するので、仕様は「攻撃者に制御されうる入力」と位置づけています。認可・リソースアクセス・業務ロジックに影響させるなら、HMACやAEADで完全性を守り、検証に失敗した状態は拒否しなければなりません(MUST)。書き換えられても失敗するだけで済む場合に限り、保護を省けます。
リプレイ対策として、保護したペイロードの中に次の3点を入れて検証することが推奨されています。
- 認証済みの主体(別のユーザーが提示した状態を拒否する)
- 短い有効期限
- 元のリクエストを識別する情報(メソッド名と主要パラメータのダイジェストなど)
ただし、これらは再利用の範囲を狭めるだけで、1回限りの使用までは保証しません。1回しか使えない処理(一度きりの引き換えなど)は、サーバー側で別途強制する必要があります。
呼び出しをまたいで状態を持たせたいとき
MCPにはプロトコルレベルのセッションがありません。カート、開いたブラウザーのコンテキスト、データベーストランザクションのように状態を持たせたいなら、作成用ツールが明示的なハンドルを返し、以降の呼び出しがそれを引数で受け取る設計にします。create_basketがbasket_idを返し、add_itemにそのbasket_idを渡す形です。
この節の内容は仕様の中でも拘束力のない設計ガイダンスで、プロトコル上のハンドルは、結果に入る普通の文字列にすぎません。注意点は4つあります。
| 注意点 | 内容 |
|---|---|
| 認可 | 内容認証付きサーバーではハンドルは「名前」であって権限ではない。呼び出しのたびに認可を確認する。認証なしサーバーではハンドル自体が持参人トークンになるため、UUIDv4程度のエントロピーと有効期間の上限を持たせる |
| 不透明性 | 内容内部構造を埋め込むと解析や推測を招く。意味を持たない識別子にする |
| 寿命 | 内容接続より長く生きるので、保持方針を作成ツールの説明文に書く(例:「24時間操作がないと失効」)。モデルは状態を作る前にそれを見て判断できる |
| 失効時 | 内容失効または未知のハンドルへの呼び出しは、その旨を伝えるツール実行エラーで返す |
失効の知らせも、エラー2系統の理屈は同じです。isError: trueで返せば、モデルは失効に気づいてcreate_basketを呼び直せます。
ネットワーク機器でルーティングしたいとき — x-mcp-header
Streamable HTTPを使うサーバーには、x-mcp-headerという拡張プロパティがあります。inputSchema内のプロパティにこの注釈を付けると、値がMcp-Param-{name}というHTTPヘッダーにも複製されます。ロードバランサーやWAFが、リクエストボディを解析せずにルーティングできるようにするためです。たとえばregionに"x-mcp-header": "Region"を付けてus-west1で呼ぶと、Mcp-Param-Region: us-west1が付きます。
守る制約は、注釈の値とパラメータの型でそれぞれ決まっています。
- 注釈の値は、空でなく、HTTPのフィールド名として有効な文字だけで、制御文字(CR・LFを含む)を含まず、
inputSchema内で大文字小文字を区別せず重複しないこと - 付けられるのは整数・文字列・真偽値のパラメータだけで、
number型は不可です。整数は±(2の53乗−1)の範囲に収めます propertiesのキーだけをたどって到達できる位置にしか付けられません。items、oneOf・anyOf・allOf、$refなどを経由する位置に付けると、ツール定義ごと無効になります
違反した定義を受け取ったクライアントは、そのツールをtools/listの結果から外します。外すのはそのツール1つだけで、ほかのツールは使えます。呼び出しの引数に該当する値が無いときは、そのヘッダーは付きません。stdioなど別のトランスポートでは、この注釈を無視してかまいません。
ヘッダーに入れる値は、ASCIIで安全に表せないとき(日本語などの非ASCII文字、制御文字、前後の空白)にBase64で符号化します。形式は=?base64?{Base64}?=で、Hello, 世界はMcp-Param-Greeting: =?base64?SGVsbG8sIOS4lueVjA==?=になります。=?base64?で始まり?=で終わる素のASCII値も、曖昧さを避けるために符号化が必要です。
パスワード、APIキー、トークン、個人情報には付けないでください。ヘッダーはネットワーク中間層から見えるためです。
まとめ
2026-07-28版へ上げる既存サーバーで最初に壊れるのは、サーバー起点のroots/list・sampling/createMessage・elicitation/createです。input_requiredへの置き換えを済ませたうえで、requestStateの署名と有効期限を足す順で進めると、仕様との差が見つけやすくなります。
自分でMCPサーバーを作るところから始めたい場合はMCPサーバー自作ガイドを、動作確認にはMCP Inspectorの使い方を参照してください。Resources仕様の読み解きはMCPのResources仕様を読み解くで扱っています。