コンテンツにスキップ

拡張の仕組み:MCP・フック・スキル・プラグイン・Agent SDK

対象読者: 本テーマ第 1・2・4 章(ループ・ツールと権限・文脈の管理)の内容を学んだ人。JSON の読み書きと、プロセスの終了コードの初歩を知っていれば十分で、MCP の知識は仮定しません。

前提となる記事:エージェントループ:モデル呼び出しとツール使用の反復、ツールと権限:許可規則・権限モード・サンドボックス、文脈を保つ・減らす・分ける:要約による圧縮、CLAUDE.md とメモリ、サブエージェント

生 Markdown

Claude Code に自分用の道具や決まりごとを足したいとき、どこに何を差し込めるのでしょうか。この記事は、足し方を 6 通りに分け、それぞれがエージェントの動きのどの場面に効くのかを調べます。読み終えると、「この決まりはモデルに頼めば足りるのか、確実に守らせたいのか」に応じて、使う仕組みを選べるようになります。

  • 拡張の仕組みは、エージェントループのどこに作用するかで分けられます。モデルに見せるツールを増やす(MCP)、決まった時点で外のプログラムを走らせる(フック)、必要なときに指示を文脈に足す(スキル)、別のループに任せる(サブエージェント)、これらを束ねて配る(プラグイン)、ループ自体を自分のプログラムから動かす(Agent SDK)の 6 つです。
  • MCP のメッセージは JSON-RPC 2.0(要求と応答を JSON で書く、呼び出しの取り決め)の要求・応答・通知です。未応答の要求の id が重ならなければ、応答と要求の対応は届く順に依らず一意に決まります(命題 3.3)。
  • MCP の仕様は日付の版を持ち、現行の 2026-07-28 版で接続の手順が変わりました。握手による取り決めは、双方に共通の版があっても成立しないことがあり、要求ごとに版を示す方式は共通の版があれば高々 2 回の要求で成立します(命題 3.4)。
  • ツール実行前のフックがブロックするエラー(終了コード 2。プログラムが終わるときに返す番号で、0 は成功を表す)で終われば、その呼び出しは権限規則やモードに依らず実行されません。フックのプログラムが異常終了したときに止まるか進むかは設定で決まります(命題 4.3)。
  • フックが実行されることはモデルの出力に依りません。指示ファイルに書いた指示に従うかどうかはモデルの出力に依ります(命題 4.5)。
  • この記事の命題は、公式の仕様とドキュメントの記述を模型として定式化したものの中での命題です。MCP についての記述は、節ごとにどの版に基づくかを明記します。出典の確認日は 2026-10-10 です。

第 1 章 で、エージェントループを「モデルを呼ぶ → ツール呼び出しを実行する → 結果を返す」の繰り返しとして定義しました。第 2 章 は実行の直前の関門(権限)を、第 3 章 と 第 4 章 はモデルに渡すメッセージ列の扱いを調べました。

この章は、利用者がこのループに何かを差し込む仕組みを扱います。どの仕組みも、ループの決まった場所に作用します。場所が分かれば、その仕組みで何が保証でき、何がモデルの出力しだいで決まるかが分かります。たとえば「.env を編集しない」という決まりは、指示ファイルに書くこともフックで止めることもできますが、2 つは作用する場所が違い、保証の強さが違います。

拡張は、便利さと引き換えに 2 つのものを増やします。1 つは外から文脈に入る内容(MCP サーバの応答、ツールの説明、スキルの本文)、もう 1 つは利用者の権限で自動的に動くプログラム(フック、MCP サーバのプロセス)です。次章 の安全性の議論は、この章の一覧を対象にします。

定義 2.1(拡張点)↗

エージェントループに対する拡張点を、作用する場所で次のように分類する。

仕組み作用する場所増やすもの
MCP サーバモデルに渡すツール定義の集合と、ツールの実行モデルが呼べるツール。その結果として文脈に入る内容
フックループの決まった時点(ツール実行の前後、利用者の入力時、停止時、圧縮の前など)ハーネスが実行するプログラム
スキルモデルに渡すメッセージ列必要なときに文脈に足される指示
サブエージェントループそのもの(別のメッセージ列を持つループを起動する)別のコンテキストウィンドウ
プラグイン上の 4 つをまとめて導入する単位配布の単位
Agent SDKループの外側(自分のプログラムからループを動かす)ループの呼び出し元

サブエージェントは前章で扱ったので、この章では残りを順に見ます。公式ドキュメント(“Extend Claude Code”、2026-10-10 確認)は、同じものが複数の階層で定義されたときの重なり方が仕組みごとに違うことを述べています。CLAUDE.md は加法的(すべての階層の内容が文脈に入る)、スキルとサブエージェントと MCP サーバは名前で上書き(1 つの定義が勝つ)、フックは併合(登録されたものがすべて発火する)です。この違いは 6 節で扱います。

モデルの呼び出し実行前のフック権限の判定ツールの実行実行後のフック結果をメッセージ列に足して、次の呼び出しへスキル(指示を足す)・ツール定義MCP サーバ(tools/call)
エージェントループと拡張点。破線は拡張が作用する場所。

3. MCP:ツールを外から足すプロトコル

Section titled “3. MCP:ツールを外から足すプロトコル”

Model Context Protocol(MCP)は、AI アプリケーションと、ツールやデータを提供するプログラムとの間の通信の取り決め(プロトコル(定義 2.1)[ネットワーク(TCP/IP)])です。

定義 3.1(MCP の役割)↗

MCP のホストは、1 つ以上のクライアントを管理する AI アプリケーションである。クライアントは、1 つのサーバへの接続を保ち、そのサーバから文脈を得る部品で、ホストはサーバごとにクライアントを 1 つ作る。サーバは、クライアントに文脈(ツール・リソース・プロンプト)を提供するプログラムである。

この定義は、MCP の公式の概説(“Architecture overview”、2026-07-28 版に基づく記述、2026-10-10 確認)によります。Claude Code はホストにあたります。プロトコルは 2 つの層からなります。内側のデータ層は JSON-RPC 2.0 に基づくメッセージの形と意味を決め、外側の輸送層は通信路を決めます。輸送路は、同じ機械の上のプロセスどうしが標準入出力でやり取りする stdio と、HTTP(Web の通信規約)を使う Streamable HTTP の 2 つです。同じ JSON-RPC のメッセージを、どちらの輸送路でも運べます。これは、上の層のデータを下の層の入れ物に入れて運ぶ カプセル化(定義 3.2)[ネットワーク(TCP/IP)] の一例です。

定義 3.2(JSON-RPC のメッセージ)↗

MCP のメッセージは、次の 3 種類の JSON オブジェクトである(JSON-RPC 2.0。MCP の仕様の 2025-11-25 版と 2026-07-28 版に共通)。

  • 要求: jsonrpc(値は "2.0")、id(文字列か整数。素の JSON-RPC 2.0 と違い、MCP では null は不可)、method(文字列)、任意の params を持つ。
  • 応答: jsonrpc、対応する要求と同じ id、および result か error のどちらか一方を持つ。error は整数の code と文字列の message を持つ。
  • 通知: jsonrpc、method、任意の params を持ち、id を持たない。受け手は応答を返してはならない。

id についての要請は版で違います。2025-11-25 版は「要求の ID は、同じセッションの中で要求側が以前に使ったものであってはならない」、2026-07-28 版は「要求の ID は、送り手が出してまだ応答を受け取っていない他の要求の ID と一致してはならない」と定めています(どちらも MUST NOT)。後者のほうが弱い要請ですが、次の命題にはそれで足ります。

次の命題のねらいは、要求を何本も続けて送っても、返ってきた応答がどの要求のものかを id で取り違えない条件を、集合と写像で言い直すことです。

命題 3.3(id による応答と要求の対応の一意性)✓

ある時点で、1 つの送り手が出してまだ応答を受け取っていない要求の集合を QQ とし、id:Q→Id\mathrm{id} : Q \to \mathrm{Id} を各要求の id とする。id\mathrm{id} が単射(QQ の相異なる要求は相異なる id を持つ)ならば、次が成り立つ。

  1. 各応答 ρ\rho について、id(q)=id(ρ)\mathrm{id}(q) = \mathrm{id}(\rho) となる q∈Qq \in Q は高々 1 つである。
  2. QQ の要求への応答がどの順序で届いても、「応答から要求への対応」は同じ写像になる。
  3. 通知は id を持たないので、この対応の定義域にも値域にも入らない。

id\mathrm{id} が単射でなければ、1 は成り立たないことがある。

証明(命題 3.3)

1: q,q′∈Qq, q' \in Q がともに id(q)=id(q′)=id(ρ)\mathrm{id}(q) = \mathrm{id}(q') = \mathrm{id}(\rho) を満たすとすると、単射性から q=q′q = q' です。

2: 対応を c(ρ)=c(\rho) = 「id(q)=id(ρ)\mathrm{id}(q) = \mathrm{id}(\rho) となるただ 1 つの qq」と定めます。この定義は ρ\rho の id と写像 id\mathrm{id} だけを使い、届いた順序を使っていません。したがって、どの順序で届いても cc は同じです。これは、識別子が一意なら、表への登録の順序に依らず解決の結果が決まるという定理(定理 3.2[事例研究])と同じ型の議論です。

3: 通知は id を持たないので、id(ρ)\mathrm{id}(\rho) も id(q)\mathrm{id}(q) も定義されません。

単射でない場合の反例: Q={q1,q2}Q = \{q_1, q_2\} で id(q1)=id(q2)=1\mathrm{id}(q_1) = \mathrm{id}(q_2) = 1 とします。id が 11 の応答 ρ\rho に対して、条件を満たす qq は 2 つあり、どちらの要求への応答かが決まりません。

2026-07-28 版の要請(未応答の要求どうしで id が重ならない)は、ちょうどこの命題の仮定です。2025-11-25 版の要請(セッションの中で再利用しない)はそれより強く、仮定を含みます。応答を待たずに次の要求を送れる(複数のツール呼び出しを並行に出せる)のは、この対応が順序に依らないからです。

MCP の仕様の版は YYYY-MM-DD の形の文字列で、後方互換でない変更が最後に入った日を表します。公式の “Versioning” のページによれば、現行(Current)の版は 2026-07-28 です(2026-10-10 確認)。この版で、接続の手順が大きく変わりました。次の表は、2025-11-25 版と 2026-07-28 版の本文、および 2026-07-28 版の変更点の一覧を読んでまとめたものです。

事柄2025-11-25 版(握手による方式の最後の版)2026-07-28 版(現行)
接続の初めクライアントが initialize 要求を送り、サーバが応答し、クライアントが notifications/initialized を送る握手は無い。プロトコルは状態を持たない
版と能力の伝え方initialize の要求と応答で 1 回すべての要求の params._meta に、版とクライアントの能力を入れる(必須)
版が合わないときサーバが自分の対応する別の版を返す。クライアントが対応していなければ切断するサーバが、対応する版の一覧つきのエラー(コード −32022-32022)を返す。クライアントは共通の版で再試行する
サーバの情報を得る方法initialize の応答server/discover(サーバは実装しなければならない。呼ぶかどうかは任意)
セッションプロトコルの水準のセッションがある無い。呼び出しをまたぐ状態は、サーバが発行した識別子をツールの引数として渡す
要求の向きクライアントからサーバへ、およびその逆クライアントからサーバへ。サーバが追加の入力を要るときは、resultType が "input_required" の結果を返し、クライアントが元の要求を再試行する
結果resultType は無いすべての結果が resultType を持つ(通常は "complete")。一覧の結果は ttlMs と cacheScope を持つ
能力クライアント側に roots・sampling・elicitation など、サーバ側に prompts・resources・tools・logging・completions などRoots・Sampling・Logging は非推奨。ping、logging/setLevel などは削除

Claude Code がどの版を使うかについて、公式ドキュメント(“Connect Claude Code to tools via MCP”、2026-10-10 確認)は次のことを述べています。Claude Code は 2 つのクライアントのランタイムのどちらかで MCP サーバに接続し、新しいほうのランタイムは MCP のプロトコルの 2026-07-28 版に対応する。新しいランタイムでは、HTTP・stdio・claude.ai のコネクタのサーバに新しい版に対応しているかを尋ね、対応しているサーバとはその版を使い、それ以外のサーバには従来のランタイムと同じように接続する。どちらのランタイムを使うかは、Claude Code の版、セッションの種類、環境変数で決まる(特定のサーバを従来の版にとどめる切り替えを Anthropic の側が持つことも書かれています)。従来の接続で使う版の日付は、このページでは確認できませんでした。

次の命題のねらいは、双方に共通の版があっても握手による方式では合意に失敗することがあり、要求ごとに版を示す方式なら高々 2 回で合意できる、という表の違いを確かめることです。CC はクライアントが対応する版の集合、SS はサーバが対応する版の集合で、1〜3 が握手による方式、4 が要求ごとの方式です。

命題 3.4(版の取り決めが成立する条件)✓

版の全体は全順序集合とし、クライアントが対応する版の有限集合を CC、サーバが対応する版の有限集合を SS とする(どちらも空でない)。

  1. (握手による方式。2025-11-25 版の Lifecycle の規則を模型にしたもの)クライアントが版 c∗∈Cc^{\ast} \in C を送り、サーバは c∗∈Sc^{\ast} \in S なら c∗c^{\ast} を、そうでなければある s∗∈Ss^{\ast} \in S を返し、クライアントは返された版が CC に入っていなければ切断する。このとき取り決めが成立するのは、c∗∈Sc^{\ast} \in S または s∗∈Cs^{\ast} \in C のとき、かつそのときに限る。
  2. 1 の方式で C∩S=∅C \cap S = \varnothing なら成立しない。C∩S≠∅C \cap S \ne \varnothing でも、双方が自分の最新の版を出す(c∗=max⁡Cc^{\ast} = \max C、s∗=max⁡Ss^{\ast} = \max S)と成立しないことがある。
  3. 1 の方式で双方が最新の版を出すとき、CC と SS がどちらも版の全順序の中の区間(間の版をすべて含む)で C∩S≠∅C \cap S \ne \varnothing ならば、成立する。
  4. (要求ごとに版を示す方式。2026-07-28 版の規則を模型にしたもの)クライアントが版 c∈Cc \in C を付けて要求を送り、サーバは c∈Sc \in S なら処理し、そうでなければ SS を載せたエラーを返し、クライアントは C∩SC \cap S から版を選んで再試行する。C∩S≠∅C \cap S \ne \varnothing ならば、高々 2 回の要求で成立する。C∩S=∅C \cap S = \varnothing なら成立しない。
証明(命題 3.4)

1: c∗∈Sc^{\ast} \in S なら、サーバは c∗c^{\ast} を返し、c∗∈Cc^{\ast} \in C なのでクライアントは切断せず、成立します。c∗∉Sc^{\ast} \notin S なら、サーバは s∗s^{\ast} を返し、クライアントが切断しないのは s∗∈Cs^{\ast} \in C のときだけです。よって成立は「c∗∈Sc^{\ast} \in S または s∗∈Cs^{\ast} \in C」と同値です。

2: 成立したとき、合意した版は c∗c^{\ast}(∈C∩S\in C \cap S)か s∗s^{\ast}(∈S∩C\in S \cap C)で、どちらも C∩SC \cap S の元です。したがって C∩S=∅C \cap S = \varnothing なら成立しません。後半の例: 版 a<b<ca \lt b \lt c について C={a,c}C = \{a, c\}、S={a,b}S = \{a, b\} とします。C∩S={a}C \cap S = \{a\} は空でありません。c∗=max⁡C=c∉Sc^{\ast} = \max C = c \notin S なので、サーバは s∗=max⁡S=bs^{\ast} = \max S = b を返します。b∉Cb \notin C なので、クライアントは切断します。

3: C=[c1,c2]C = [c_1, c_2]、S=[s1,s2]S = [s_1, s_2] と書きます(区間の両端)。C∩S≠∅C \cap S \ne \varnothing から、共通の元 xx があり、c1≤x≤c2c_1 \le x \le c_2、s1≤x≤s2s_1 \le x \le s_2 です。c2≤s2c_2 \le s_2 の場合、s1≤x≤c2≤s2s_1 \le x \le c_2 \le s_2 なので c2∈Sc_2 \in S(SS は区間)、すなわち c∗=max⁡C∈Sc^{\ast} = \max C \in S で、1 により成立します。s2<c2s_2 \lt c_2 の場合、c1≤x≤s2<c2c_1 \le x \le s_2 \lt c_2 なので s2∈Cs_2 \in C、すなわち s∗=max⁡S∈Cs^{\ast} = \max S \in C で、1 により成立します。

4: 1 回目の要求の版 cc が SS に入っていれば、その要求が処理されて成立します(1 回)。入っていなければ、サーバは SS を載せたエラーを返します。クライアントは CC を知っているので C∩SC \cap S を計算でき、空でなければその元 c′c' を選んで再試行します。c′∈Sc' \in S なので 2 回目の要求は処理されます(合計 2 回)。C∩S=∅C \cap S = \varnothing なら、どの c∈Cc \in C も SS に入らないので、どの要求も処理されません。

握手による方式が失敗しうるのは、サーバが「自分の対応する別の版」を 1 つしか返さないからです。現行の方式ではサーバが集合 SS をまるごと返すので、クライアントが共通部分を計算できます。これは、依存の版の制約を満たす版を探す問題(定義 8.1[ビルドと依存関係])で、候補の集合が見えていれば解を選べる、というのと同じ事情です。MCP の版は日付で、数字の組に意味を持たせる方式(定義 8.6[ビルドと依存関係])とは違い、版の文字列から互換性は読み取れません。

注意 3.5(模型に入れていないこと)

4 は、クライアントとサーバがどちらも現行の方式で話す場合の命題です。握手を待つサーバと現行の方式のクライアントの組み合わせなど、方式そのものが食い違う場合について、2026-07-28 版は互換性の表を載せています(両方の方式に対応するクライアントは、stdio ではまず server/discover を送り、現行の方式のエラーとして認識できない応答や無応答なら握手に切り替える、など)。この表の内容は命題にはしていません。また 1 の「切断する」は仕様では SHOULD、「最新の版を出す」も SHOULD で、必ずそうするとは限りません。

3.3. ツールの一覧と呼び出し、2 種類のエラー

Section titled “3.3. ツールの一覧と呼び出し、2 種類のエラー”

定義 3.6(MCP のツール)↗

サーバは、tools/list 要求に対して、利用できるツールの定義の配列を返す。ツールの定義は name、description、inputSchema(引数の JSON Schema)などからなる。クライアントは tools/call 要求に name と arguments を入れてツールを呼び出し、サーバは content(text などのブロックの配列)と任意の isError を持つ結果を返す。エラーの伝え方は 2 つある。

  • プロトコルのエラー: 要求の形そのものの問題(未知のツール名、形の不正な要求、サーバのエラー)。JSON-RPC の error を持つ応答として返る。
  • ツール実行のエラー: ツールを実行した結果の失敗(API の失敗、入力値の検証の失敗、業務上の誤り)。isError が true の結果として返る。

この定義は 2026-07-28 版の “Tools” のページによります(tools/list・tools/call と 2 種類のエラーは、2025-06-18 版・2025-11-25 版にもあります)。同じページは、クライアントはツール実行のエラーを言語モデルに渡すべきである(SHOULD)、プロトコルのエラーは渡してもよい(MAY)と定めています。安全性に関わる要請として、次の 2 つがあります(次章で使います)。

  • ツールは「モデルが制御する」ものとして設計されているが、ツールの呼び出しを拒否できる人間が常にループの中にいるべきである(SHOULD)。
  • クライアントは、信頼できるサーバからのものでない限り、ツールの注釈(annotations)を信頼できないものとして扱わなければならない(MUST)。

2026-07-28 版には、tools/list は決定的な順序でツールを返すべきである(SHOULD)という要請も加わりました。理由として、クライアント側のキャッシュと、LLM のプロンプトキャッシュの命中率が挙げられています。ツール定義は要求の先頭の層にあるので、順序が変わるとその後ろのキャッシュがすべて命中しなくなる(命題 3.3[コンテキストウィンドウとプロンプトキャッシュ])ことに対応した要請です。

ツールの実行が失敗しても、モデルに返す結果が欠けるとループは先へ進めません。次の命題のねらいは、失敗を「結果」として返せば、第 1 章の対応の不変条件(各 tool_use に同じ id の tool_result がちょうど 1 つある)が保たれ、結果が欠ける場合には破れる、と確かめることです。

命題 3.7(エラーを結果として返すと、ループの不変条件が保たれる)✓

第 1 章 の模型で、あるツールの実行を MCP の tools/call で行うとする。tools/call の帰結は、(i) isError が偽の結果、(ii) isError が真の結果、(iii) プロトコルのエラー、(iv) 応答なし(時間切れ)のどれかである。クライアントが帰結を tool_result に写す規則を θ\theta とする。

  1. θ\theta が (i)・(ii) について「結果の content を内容とし、isError をエラーの印とする tool_result を 1 つ作る」と定められていれば、(i)・(ii) の帰結のもとで、tool_use と tool_result の対応の不変条件は保たれる。エラーの場合もループは続けられる。
  2. θ\theta が (iii)・(iv) について定められていなければ、その帰結のもとで対応する tool_result が作られず、不変条件は破れる。θ\theta がすべての帰結について tool_result をちょうど 1 つ作るように定められていれば、不変条件は保たれる。
証明(命題 3.7)

第 1 章の模型では、ツールの実行は「入力と環境から、結果の文字列・エラーの印・新しい環境への写像」で、すべての入力に値を持つ(全域である)ことが、不変条件の保存(命題 3.4[エージェントループ])の証明で使われています。各 tool_use に対して実行が値を返すので、同じ id の tool_result がちょうど 1 つ作られる、という議論です。

1: (i)・(ii) の帰結に対して θ\theta は tool_result を 1 つ返すので、この 2 つの帰結に限れば実行は値を持ち、第 1 章の命題の議論がそのまま使えます。(ii) はエラーの印が立った tool_result になるだけで、拒否を結果として返す場合(命題 3.5[エージェントループ])と同じ形です。

2: (iii)・(iv) の帰結で θ\theta の値が無ければ、その tool_use に対応する tool_result が次の user メッセージに現れず、「各 tool_use に同じ id の tool_result がちょうど 1 つある」という条件が成り立ちません。θ\theta がすべての帰結で tool_result をちょうど 1 つ作るなら、実行は全域の写像になり、第 1 章の命題が適用できます。

この命題は、仕様が失敗を 2 つに分けている理由の 1 つを説明します。モデルが引数を直せば解決する失敗は、結果としてモデルに見せる((ii))。要求の形そのものの問題は、プロトコルの水準で返す((iii))。ただし (iii) と (iv) でも、ループを続けるには、クライアントが何らかの tool_result を自分で作る必要があります。

3.4. 最小のサーバとクライアント

Section titled “3.4. 最小のサーバとクライアント”

2 つの数の和を返すツール add を 1 つだけ持つ、最小の MCP サーバを書きます。仕様の 2026-07-28 版の形(握手なし、要求ごとの _meta、server/discover、resultType、一覧の ttlMs と cacheScope)に合わせています。輸送路は stdio で、1 行が 1 つのメッセージです。Python の標準ライブラリだけを使い、公式の SDK は使っていません。仕様の一部(ページ分割、購読、追加の入力の要求など)は実装していないので、仕様に適合したサーバではなく、メッセージの形を確かめるための模型です。

# mcp_server.py — 最小の MCP サーバ(標準入出力)。仕様の版 2026-07-28 の形に合わせた模型。
# 握手(initialize)は無い。版と能力は要求ごとに params._meta で届く。
import json, sys
VERSIONS = ["2026-07-28"]
P = "io.modelcontextprotocol/"
TOOLS = [{
"name": "add", "description": "2 つの数の和を返す",
"inputSchema": {"type": "object",
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
"required": ["a", "b"]},
}]
def error(rid, code, message, data=None):
e = {"code": code, "message": message}
if data is not None:
e["data"] = data
return {"jsonrpc": "2.0", "id": rid, "error": e}
def handle(req):
rid, method, params = req.get("id"), req.get("method"), req.get("params", {})
meta = params.get("_meta", {})
if P + "protocolVersion" not in meta or P + "clientCapabilities" not in meta:
return error(rid, -32602, "Invalid params: missing required _meta fields")
if meta[P + "protocolVersion"] not in VERSIONS:
return error(rid, -32022, "Unsupported protocol version",
{"supported": VERSIONS, "requested": meta[P + "protocolVersion"]})
if method == "server/discover":
result = {"supportedVersions": VERSIONS, "capabilities": {"tools": {}},
"ttlMs": 3600000, "cacheScope": "public"}
elif method == "tools/list":
result = {"tools": TOOLS, "ttlMs": 300000, "cacheScope": "public"}
elif method == "tools/call":
if params.get("name") != "add": # プロトコルのエラー
return error(rid, -32602, "Unknown tool: " + str(params.get("name")))
args = params.get("arguments", {})
a, b = args.get("a"), args.get("b")
ok = all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (a, b))
if ok:
result = {"content": [{"type": "text", "text": str(a + b)}], "isError": False}
else: # ツール実行のエラー
result = {"content": [{"type": "text", "text": "a と b は数でなければならない"}],
"isError": True}
else:
return error(rid, -32601, "Method not found")
result["resultType"] = "complete"
result["_meta"] = {P + "serverInfo": {"name": "add-server", "version": "0.1.0"}}
return {"jsonrpc": "2.0", "id": rid, "result": result}
for line in sys.stdin: # 1 行 = 1 メッセージ
msg = json.loads(line)
if "id" not in msg: # 通知には応答しない
continue
sys.stdout.write(json.dumps(handle(msg), ensure_ascii=False) + "\n")
sys.stdout.flush()

クライアントは、このサーバを子プロセスとして起動し、要求を送って、応答を id で突き合わせます。

# mcp_client.py — 最小のクライアント。要求を送り、応答を id で突き合わせる。
import json, subprocess, sys
P = "io.modelcontextprotocol/"
def meta(version):
return {P + "protocolVersion": version, P + "clientCapabilities": {},
P + "clientInfo": {"name": "mini-client", "version": "0.1.0"}}
proc = subprocess.Popen([sys.executable, "mcp_server.py"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, universal_newlines=True)
pending, next_id = {}, 1
def send(method, params=None, version="2026-07-28"):
global next_id
params = dict(params or {})
params["_meta"] = meta(version)
rid, next_id = next_id, next_id + 1 # 未応答の要求と重ならない id
pending[rid] = method
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method,
"params": params}) + "\n")
proc.stdin.flush()
return rid
def receive():
resp = json.loads(proc.stdout.readline())
method = pending.pop(resp["id"]) # id から要求を一意に引く
return resp["id"], method, resp
# 1. 版が合わない要求 → 対応する版の一覧つきのエラー → その版で再試行(高々 2 回)
send("tools/list", version="2099-01-01")
rid, method, resp = receive()
print(rid, method, "->", resp["error"]["code"], resp["error"]["data"])
version = resp["error"]["data"]["supported"][0]
# 2. 4 つの要求をまとめて送り、あとから応答を id で突き合わせる
send("tools/list", version=version)
send("tools/call", {"name": "add", "arguments": {"a": 2, "b": 3}}, version)
send("tools/call", {"name": "add", "arguments": {"a": 2, "b": "x"}}, version)
send("tools/call", {"name": "mul", "arguments": {"a": 2, "b": 3}}, version)
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": 999}}) + "\n") # 通知: 応答は来ない
proc.stdin.flush()
for _ in range(4):
rid, method, resp = receive()
if "error" in resp:
print(rid, method, "-> プロトコルのエラー", resp["error"]["code"], resp["error"]["message"])
elif "tools" in resp["result"]:
print(rid, method, "->", [t["name"] for t in resp["result"]["tools"]], resp["result"]["resultType"])
else:
r = resp["result"]
print(rid, method, "-> isError =", r["isError"], r["content"][0]["text"])
proc.stdin.close()
proc.wait()
print("未応答の要求:", pending, " サーバの終了コード:", proc.returncode)

実行した出力(Python 3.9、2026-10-10 実行):

1 tools/list -> -32022 {'supported': ['2026-07-28'], 'requested': '2099-01-01'}
2 tools/list -> ['add'] complete
3 tools/call -> isError = False 5
4 tools/call -> isError = True a と b は数でなければならない
5 tools/call -> プロトコルのエラー -32602 Unknown tool: mul
未応答の要求: {} サーバの終了コード: 0

1 行目と 2 行目は 命題 3.4 の 4(版が合わなくても 2 回目で成立)、4 行目と 5 行目は 2 種類のエラーの違い(引数の型の誤りは結果の中の印、未知のツール名はプロトコルのエラー)を示しています。通知には応答が無く、未応答の要求の表は空で終わっています。握手による方式のサーバを書く場合は、最初に initialize 要求に答え、notifications/initialized を受け取ってから他の要求を処理する、という状態が加わります。

tools/call の要求と、成功の応答を書き下すと次の形です(上のクライアントが実際に送受信したもの)。

{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "add", "arguments": {"a": 2, "b": 3},
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {"name": "mini-client", "version": "0.1.0"}}}}
{"jsonrpc": "2.0", "id": 3,
"result": {"content": [{"type": "text", "text": "5"}], "isError": false,
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "add-server", "version": "0.1.0"}}}}

3.5. Claude Code での MCP と、ツール定義の遅延読み込み

Section titled “3.5. Claude Code での MCP と、ツール定義の遅延読み込み”

公式ドキュメント(“Connect Claude Code to tools via MCP”、2026-10-10 確認)から、仕組みに関わる事実を挙げます。

  • 輸送路: HTTP(遠隔のサーバへの接続に推奨)、SSE(非推奨)、stdio(利用者の機械の上のプロセス)、WebSocket。
  • 設定の範囲: local(既定。そのプロジェクトで自分だけ)、project(プロジェクトのルートの .mcp.json。バージョン管理で共有)、user(自分のすべてのプロジェクト)。プロジェクトの範囲のサーバは、対話的なセッションでは使う前に承認を求められます。
  • ツールの名前: MCP のツールは mcp__<サーバ名>__<ツール名> の形の名前でモデルに見え、権限規則やフックのマッチャでもこの名前を使います。
  • 出力の上限: MCP ツールの出力が 10,000 トークンを超えると警告が出て、既定の上限は 25,000 トークンです(環境変数 MAX_MCP_OUTPUT_TOKENS で変更)。
  • 遅延読み込み: 既定では、セッションの開始時にはツールの名前と、サーバが与える使い方の指示(server instructions)だけが読み込まれ、ツール定義は、モデルが必要としたときにツール検索を通じて読み込まれます。環境変数 ENABLE_TOOL_SEARCH で、すべて前もって読み込む・定義の合計がウィンドウの一定の割合(既定 10%)未満なら前もって読み込む、などに変えられます。ツールの説明とサーバの指示は、既定でそれぞれ 2,048 文字で切られます。遅延読み込みは、利用する API の接続先やモデルによっては働かず、そのときは前もって読み込まれます。
  • 接続前の注意: 接続する前に、そのサーバを信頼できるか確かめること、外部の内容を取得するサーバはプロンプトインジェクション(取り込んだ文章の中に紛れた指示にモデルが従ってしまう攻撃)の危険を持ち込みうること、が注意されています。

次の命題のねらいは、ツールが多いときに、定義を前もって全部置く方式と、名前だけ置いて使うときに定義を読む方式とで、文脈に置かれる量がどれだけ違うかを式にすることです。4 に出てくるブレークポイントは、プロンプトキャッシュ(前に送った先頭部分の処理を使い回す仕組み)で、先頭からどこまでを使い回すかを示す位置の印のことです。

命題 3.8(ツール定義の常駐コスト)✓

NN 個のツールがあり、ツール ii の定義の長さを did_i、名前の長さを mim_i(mi≤dim_i \le d_i)とする。会話の中で使うツールの集合を UU とする。

  1. 定義を前もって全部読み込む方式で、文脈に置かれるツールの記述の量は ∑i=1Ndi\sum_{i=1}^{N} d_i。
  2. 名前だけを置き、使うときに定義を読み込む方式では ∑i=1Nmi+∑i∈Udi\sum_{i=1}^{N} m_i + \sum_{i \in U} d_i。
  3. 差(1 から 2 を引いたもの)は ∑i∉U(di−mi)−∑i∈Umi\sum_{i \notin U} (d_i - m_i) - \sum_{i \in U} m_i。定義と名前の長さがどれも dd、mm で ∣U∣=u|U| = u のとき、差は (N−u)(d−m)−u m(N - u)(d - m) - u\,m で、2 のほうが小さいのは u<N (d−m)/du \lt N\,(d - m)/d のときである。
  4. 2 の方式で、定義の読み込みをメッセージ列の末尾への追記として行えば、それまでの要求は新しい要求の接頭辞のままで、既存のキャッシュのブレークポイントは命中し続ける。要求の先頭の層にあるツール定義の集合を途中で変える方式では、変えた位置より後ろのブレークポイントはすべて命中しない。
証明(命題 3.8)

1 と 2 は、文脈に置かれる文字列を数えただけです。2 では、すべてのツールの名前と、使ったツールの定義が置かれます。

3: ∑idi−∑imi−∑i∈Udi=∑i∉Udi−∑imi=∑i∉U(di−mi)−∑i∈Umi\sum_{i} d_i - \sum_i m_i - \sum_{i \in U} d_i = \sum_{i \notin U} d_i - \sum_{i} m_i = \sum_{i \notin U} (d_i - m_i) - \sum_{i \in U} m_i です。長さが一定のとき (N−u)(d−m)−u m=Nd−Nm−ud(N - u)(d - m) - u\,m = N d - N m - u d で、これが正であることは u d<N (d−m)u\,d \lt N\,(d - m)、すなわち u<N (d−m)/du \lt N\,(d - m)/d と同値です。

4: 末尾に追記するだけなら、前の要求は新しい要求の接頭辞です。命題 3.3[コンテキストウィンドウとプロンプトキャッシュ] により、内容が変わった位置より前のブレークポイントは影響を受けません。先頭の層のツール定義の集合を変えると、内容が変わる位置がその層の中にあり、同じ命題により、それより後ろのブレークポイントはすべて命中しません。

公式ドキュメント(“How Claude Code uses prompt caching”)は 4 の両方の場合を述べています。ツールが遅延されているときは、会話の最初の要求のツールの一覧を会話のあいだ保つので、途中でサーバが接続・切断しても既存のキャッシュは乱されない。前もって読み込んでいるときは、定義が足されるとキャッシュが無効になる。

例 3.9(200 個のツールのうち 3 個しか使わないとき(仮の値))

N=200N = 200、定義の平均 d=300d = 300 トークン、名前の平均 m=10m = 10 トークン、u=3u = 3 と置きます(仮の値)。前もって読み込むと 200×300=60,000200 \times 300 = 60{,}000 トークン、遅延すると 200×10+3×300=2,900200 \times 10 + 3 \times 300 = 2{,}900 トークンで、差は 57,100=(200−3)(300−10)−3×1057{,}100 = (200 - 3)(300 - 10) - 3 \times 10 です。遅延のほうが小さいのは u<200×290/300≈193.3u \lt 200 \times 290/300 \approx 193.3 のとき、つまり 193 個以下しか使わないときです。次のコードの出力(Python 3.9、2026-10-10 実行)で確かめられます。

N, u, d, m = 200, 3, 300, 10 # 個数、使う個数、定義の平均、名前の平均(仮の値)
upfront = N * d
deferred = N * m + u * d
print("前もって読み込む:", upfront, " 遅延:", deferred, " 差:", upfront - deferred,
" = (N - u)(d - m) - u m =", (N - u) * (d - m) - u * m)
for uu in (0, 3, 50, 193, 194, 200):
print(f"u = {uu:>3}: 遅延 = {N * m + uu * d:>6} 前もって = {upfront}")
前もって読み込む: 60000 遅延: 2900 差: 57100 = (N - u)(d - m) - u m = 57100
u = 0: 遅延 = 2000 前もって = 60000
u = 3: 遅延 = 2900 前もって = 60000
u = 50: 遅延 = 17000 前もって = 60000
u = 193: 遅延 = 59900 前もって = 60000
u = 194: 遅延 = 60200 前もって = 60000
u = 200: 遅延 = 62000 前もって = 60000

この勘定には、定義を探して読み込むための呼び出しそのものの費用(1 回分の往復)は入れていません。

4. フック:決まった時点で必ず走るプログラム

Section titled “4. フック:決まった時点で必ず走るプログラム”

定義 4.1(フック)↗

フックとは、Claude Code のライフサイクルの決まった時点(イベント)で、Claude Code が自動的に実行する、利用者が定義したハンドラをいう。ハンドラは、シェルのコマンド、HTTP の送信先、MCP ツールの呼び出し、LLM へのプロンプト、サブエージェントのどれかである。設定は「イベント → マッチャの組 → ハンドラ」の 3 段の入れ子で書く。

公式の “Hooks reference”(2026-10-10 確認)から、主要なイベントを挙げます。全体は 30 種類を超え、版で増減するので、一覧は出典を見てください。

イベント発火する時点終了コード 2 の効果
SessionStartセッションの開始・再開のときブロックできない
UserPromptSubmit利用者の入力が送られ、モデルが処理する前入力を止める(モデルに届かない)
PreToolUseツール呼び出しを実行する前呼び出しを止める
PostToolUseツール呼び出しが成功したあとブロックできない(ツールはすでに実行されている)
Stopモデルが応答を終えたとき止まるのを防ぎ、会話を続けさせる
PreCompact圧縮の前圧縮を止める
SubagentStart / SubagentStopサブエージェントの起動時 / 終了時起動は止められない / 終了を防ぐ

コマンドのハンドラは、JSON を標準入力で受け取ります。共通の欄として session_id、transcript_path、cwd、hook_event_name などがあり、PreToolUse では tool_name と tool_input が加わります。マッチャは、ハンドラをどの対象(ツールのイベントではツール名)で発火させるかを絞る文字列で、"*"・空文字列・省略はすべてにマッチし、英数字と _・-・空白・,・| だけからなるものは完全一致(または | や , で区切った列挙)、それ以外の文字を含むものは JavaScript の正規表現として評価されます。

設定の場所は、利用者の設定ファイル、プロジェクトの設定ファイル(共有するものとしないもの)、組織の管理設定、プラグインの hooks/hooks.json、スキルとサブエージェントの frontmatter です。マッチしたフックはすべて並行に実行され、同じハンドラが複数の設定ファイルにあれば 1 回だけ実行されます。既定のタイムアウトは、コマンド・HTTP・MCP ツールのハンドラが 600 秒、プロンプトが 30 秒、エージェントが 60 秒で、一部のイベント(UserPromptSubmit など)ではもっと短く設定されています。

定義 4.2(コマンドのフックの帰結と決定)↗

コマンドのフックの 1 回の実行の帰結は、次の 3 つのどれかである(公式ドキュメントの記述による)。

  • 成功: 終了コード 0。標準出力に JSON を出していれば、その欄が適用される。
  • ブロックするエラー: 終了コード 2。ブロックできるイベントでは、その行為が止まる。標準エラーが理由になる。
  • ブロックしないエラー: それ以外の終了コード、または起動できない・不正な JSON を出すなどの失敗。行為は進む。ただし、そのフックに onFailure が "block" と設定されていれば、失敗のとき行為は止まる。

標準出力の内容で帰結が変わる場合がある。公式の表によれば、0 と 2 以外の終了コードでも、検査を通る JSON を標準出力に出していれば、帰結は成功で、JSON の欄だけで決まる(onFailure が "block" のときは失敗に数える)。終了コード 2 は、JSON を出していてもブロックする。以下の命題では、標準出力に JSON を出さないフックを考え、帰結が終了コードだけで決まる場合を扱う。

PreToolUse のフックは、成功のとき JSON の hookSpecificOutput.permissionDecision で決定("allow"・"deny"・"ask"・"defer")を返せる。複数の PreToolUse フックが異なる決定を返したときの優先順位は deny >\gt defer >\gt ask >\gt allow である。終了コード 2 でのブロックは "deny" と同じ経路をたどる。

決定の合成の規則(deny >\gt defer >\gt ask >\gt allow)は、“Hooks reference” の PreToolUse の節と、Agent SDK の “Intercept and control agent behavior with hooks” のページの両方に書かれています。onFailure は、公式ドキュメントによれば Claude Code v2.1.295 以降の機能で、コマンドと HTTP のフックに設定でき、既定値は "continue" です(2026-10 時点)。同じ節によれば、Stop・SubagentStop など一部のイベントのフックと、バックグラウンドで動かすコマンドのフックでは、この設定は効きません。

権限規則との関係は、第 2 章 で扱ったとおりです(命題 7.2[ツールと権限])。公式ドキュメント(“Configure permissions”)は、PreToolUse フックの決定は権限規則を迂回しない(フックが "allow" を返しても、マッチする deny 規則は止め、ask 規則は確認を出す)こと、終了コード 2 で終わるフックは権限規則の評価の前に呼び出しを止めるので、allow 規則があっても止まることを述べています。

次の命題のねらいは、止めるためのフックが「どんな場合に呼び出しを確実に止めるか」を、終了コードと onFailure の設定から読み取ることです。fail-open は、失敗したときに止めずに先へ進める側に倒す設計、fail-closed は、失敗したときも止める側に倒す設計を指します。

PreToolUseフックが終了終了コード 0(成功)その他の終了コード・起動失敗などonFailure が continue(既定)終了コード 2、または失敗で onFailure が block権限の判定へ権限の判定へ呼び出しは実行されない
PreToolUse のコマンドのフックの帰結(JSON を出さない場合)。終了コードと onFailure の設定で、呼び出しが権限の判定へ進むか止まるかが分かれる。

命題 4.3(ブロックするフックの保証と、失敗時の向き)✓

定義 4.2 の規則を模型として置く。あるツール呼び出し (n,x)(n, x) について、マッチャが nn にマッチする PreToolUse のコマンドのフック HH が登録され、実行されるとする。ツールの判定に介入する mod(後述)は導入されていないとする。

  1. HH の帰結がブロックするエラーなら、(n,x)(n, x) は実行されない。これは、権限規則の集合、権限モード、他のフックの決定に依らない。
  2. 危険とみなす呼び出しの集合を Bad\mathrm{Bad} とし、HH は「(n,x)∈Bad(n, x) \in \mathrm{Bad} ならブロックするエラーで終わる」ように書かれたプログラムとする。ところが、ある (n,x)∈Bad(n, x) \in \mathrm{Bad} で HH が異常終了(ブロックしないエラー)したとする。onFailure が "continue"(既定)なら、(n,x)(n, x) は止められず、権限の判定に進む(fail-open)。"block" なら、(n,x)(n, x) は実行されない(fail-closed)。
  3. したがって「Bad\mathrm{Bad} の呼び出しは実行されない」という保証が成り立つための HH への要求は、fail-open では「Bad\mathrm{Bad} のすべての入力で、ブロックするエラーで正しく終わる」こと、fail-closed では「Bad\mathrm{Bad} のすべての入力で、成功の帰結にならない」ことである。後者は前者より弱い。
証明(命題 4.3)

1: 規則により、終了コード 2 は PreToolUse では呼び出しを止め、"deny" と同じ経路をたどります。決定の合成で deny は最上位なので、他のフックがどの決定を返しても結果は deny です。また、このブロックは権限規則の評価の前に働くので、allow 規則やモードの既定は適用される機会がありません。

2: 異常終了はブロックしないエラーです。規則により、onFailure が "continue" なら行為は進み、HH は何の決定も与えなかったのと同じになります。呼び出しは、他のフックと権限規則の判定に進みます。"block" なら、規則により行為は止まります。

3: fail-open では、Bad\mathrm{Bad} の入力で HH の帰結が「ブロックするエラー」でなければ(成功でも、ブロックしないエラーでも)HH は呼び出しを止めないので、保証には「必ずブロックするエラーで終わる」ことが要ります。fail-closed では、ブロックするエラーとブロックしないエラーのどちらでも止まるので、「成功にならない」ことで足ります。「ブロックするエラーで終わる」ならば「成功にならない」ので、後者は前者より弱い条件です。

検査を目的とするフックでは、この違いが大きく効きます。公式ドキュメントは、既定の設定では、パスを間違えたフックや落ちるスクリプトがすべてを通してしまう、と注意しています。止めることが目的のフックは、失敗のときも止まる側に倒しておく(onFailure を "block" にする、または入力を読めない場合も終了コード 2 で終わるように書く)のが、この命題からの帰結です。一方、整形や通知のように副作用だけが目的のフックは、失敗しても作業を止めないほうが都合がよく、fail-open が合います。どちらにするかは、フックが担う役割(検査か、付随的な処理か)で決まります。これは、関数が満たすべき条件を呼ぶ側と呼ばれる側で取り決める 契約(定義 2.2)[契約による設計] の考え方と同じで、フックの「失敗のときの振る舞い」を契約の一部として決めておく、ということです。

注意 4.4(模型の外にあるもの)

1 の「依らない」には、公式ドキュメントが明記する例外があります。プラグインのうち、Claude Code の中で自分のコードを動かすもの(mod)は、ツールの判定に答えを返すことができ、設定ファイルやプラグインの PreToolUse フックのブロックを承認に置き換えられる(組織の管理設定のフックを除く)、と書かれています。命題に「mod は導入されていない」という仮定を入れたのはこのためです。また、フックは無効にでき(disableAllHooks)、対話的なセッションではフォルダの信頼を確認するまで設定ファイルのフックは実行されません。SDK のコールバックとして登録したフックの時間切れの扱いは、コマンドのフックと違う点があります(出典の SDK のページを見てください)。

次のフックは、.env という名前のファイルへの書き込みを止めます。入力を読めない場合も止まるように書いてあります。

#!/usr/bin/env python3
# protect_env.py — PreToolUse フック: .env への書き込みを止める。
# 標準入力の JSON を読み、終了コードで答える。
import json, sys
try:
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
except Exception as e: # 入力を読めないときも止める(fail-closed)
print("hook input unreadable: %s" % e, file=sys.stderr)
sys.exit(2)
if path.replace("\\", "/").split("/")[-1] == ".env":
print(".env は編集できません(protect_env フック)", file=sys.stderr)
sys.exit(2) # 2 = ブロックするエラー
sys.exit(0) # 0 = 成功。呼び出しは権限の判定へ進む

登録する設定は、公式ドキュメントの書式では次の形です(プロジェクトの .claude/settings.json)。

{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect_env.py"
}
]
}
]
}
}

このフックを Claude Code に登録して動かすことは、この記事ではしていません。代わりに、フックのスクリプトそのものに、PreToolUse の入力の形の JSON を標準入力で渡して実行し、定義 4.2 の規則を表にした模型と合わせて確かめます。

# hook_sim.py — フックの終了コードと決定の合成の模型
# (公式ドキュメントの規則を表にしたもの。実物の実装の再現ではない)
import json, subprocess, sys
def run_hook(tool_name, tool_input):
payload = {"session_id": "s1", "cwd": "/repo", "hook_event_name": "PreToolUse",
"tool_name": tool_name, "tool_input": tool_input}
p = subprocess.run([sys.executable, "protect_env.py"], input=json.dumps(payload),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
return p.returncode, p.stderr.strip()
def outcome(code, on_failure="continue"):
if code == 0: return "成功 → 権限の判定へ"
if code == 2: return "ブロック → 実行されない"
return "ブロック → 実行されない" if on_failure == "block" else "ブロックしないエラー → 権限の判定へ"
for name, inp in [("Write", {"file_path": "/repo/src/app.py"}), ("Write", {"file_path": "/repo/.env"})]:
code, err = run_hook(name, inp)
print(name, inp["file_path"], "終了コード", code, "|", outcome(code), "|", err)
for of in ("continue", "block"):
print("異常終了(終了コード 1), onFailure =", of, "|", outcome(1, of))
RANK = {"deny": 3, "defer": 2, "ask": 1, "allow": 0} # deny > defer > ask > allow
def combine(decisions):
return max(decisions, key=RANK.get) if decisions else None
def final(hook_decisions, rule):
"""rule: 権限規則の判定(deny / ask / allow / None)。フックの allow は deny・ask の規則を越えない。"""
h = combine(hook_decisions)
if h == "deny" or rule == "deny": return "deny"
if h == "defer": return "defer"
if h == "ask" or rule == "ask": return "ask"
if h == "allow" or rule == "allow": return "allow"
return "モードの既定"
print("合成:", combine(["allow", "ask"]), combine(["allow", "deny", "ask"]), combine(["allow", "allow"]))
for hooks, rule in [(["allow"], "deny"), (["allow"], "ask"), (["deny"], "allow"), (["allow"], None), ([], "allow")]:
print("フック", hooks, "規則", rule, "->", final(hooks, rule))

実行した出力(Python 3.9、2026-10-10 実行):

Write /repo/src/app.py 終了コード 0 | 成功 → 権限の判定へ |
Write /repo/.env 終了コード 2 | ブロック → 実行されない | .env は編集できません(protect_env フック)
異常終了(終了コード 1), onFailure = continue | ブロックしないエラー → 権限の判定へ
異常終了(終了コード 1), onFailure = block | ブロック → 実行されない
合成: ask deny allow
フック ['allow'] 規則 deny -> deny
フック ['allow'] 規則 ask -> ask
フック ['deny'] 規則 allow -> deny
フック ['allow'] 規則 None -> allow
フック [] 規則 allow -> allow

4.3. フックと指示ファイルの違い

Section titled “4.3. フックと指示ファイルの違い”

命題 4.5(フックの実行はモデルの出力に依らない)✓

第 1 章 のエージェントループの模型に、「ツール呼び出し (n,x)(n, x) を実行する前に、マッチャが nn にマッチするすべての PreToolUse フックを (n,x)(n, x) を入力として実行し、定義 4.2 の規則で止めるかどうかを決める」という段階を加える。フックは有効で、登録されているとする。

  1. モデルがどんな写像であっても、実行されるすべてのツール呼び出し (n,x)(n, x) について、その実行の前に、マッチするフックが (n,x)(n, x) を入力として実行されている。
  2. 文脈に入っているどんな指示の文 II についても、II に従わないモデル(写像)が存在する。したがって、「II が文脈に入っている」ことから「II の内容が守られる」ことは、模型の上では導けない。
証明(命題 4.5)

1: 加えた段階は、ハーネスの手順の一部で、ツールの実行の直前に置かれています。模型では、ツールが実行されるのはこの手順を通ったときだけです。手順の中でフックを実行するかどうかは、ツール名 nn とマッチャだけで決まり、モデルの出力のうち関係するのは呼び出し (n,x)(n, x) そのものだけです。よって、モデルがどんな写像でも、実行された呼び出しの前には、マッチするフックの実行があります。これは実行の列についての不変条件で、各段がそれを保つことから従います(命題 3.4[エージェントループ] の証明と同じ型の帰納法)。

2: 模型ではモデルは任意の写像です。入力のメッセージ列に依らず同じ応答(たとえば、II が禁じる呼び出し 1 つ)を返す定数写像は、モデルとして許されます。この写像は II が文脈にあっても無くても同じ出力を返すので、II に従いません。

2 は「実際のモデルが指示に従わない」という主張ではありません。指示に従うかどうかがモデルという写像の性質であり、ハーネスの構造からは保証できない、という主張です。公式ドキュメントは同じ区別を述べています。フックは決まったイベントで必ず発火し、スキルや CLAUDE.md の指示はモデルが解釈するので結果が変わりうる。「.env を編集しない」という指示は依頼であって保証ではなく、編集を止める PreToolUse フックが強制である。毎回必ず成り立たせたい決まりは、プロンプトの指示ではなくフックにする。方針や慣習は指示ファイルに書く。この使い分けの根拠が、この命題です。

1 にも、公式ドキュメントが明記する範囲があります。特定の組み込みのツール呼び出し 1 種類はフックを通らないこと、SDK ではターンの上限に達したときフックが発火しないことがあること、などです。また PreToolUse が発火するのはモデルがツールを呼んだときだけです。利用者が入力の中でファイルを参照して内容を文脈に入れる操作はツール呼び出しではないので、Read にマッチするフックは発火しない、と公式ドキュメントは注意しています(その経路を止めるのは Read の deny 規則です)。命題の「フックは有効で、登録されている」という仮定は、注意 4.4 の事情を除くためのものです。

5. スキル:必要なときに足される指示

Section titled “5. スキル:必要なときに足される指示”

定義 5.1(スキル)↗

スキルとは、SKILL.md というファイルを持つディレクトリで、ファイルは YAML の frontmatter(いつ使うかの説明など)と Markdown の本文(指示)からなる。モデルが関連すると判断したとき、または利用者が /<スキル名> と入力したときに呼び出される。スキルの説明は会話の初めから文脈に置かれ、本文は呼び出されたときにメッセージとして文脈に入る。

公式ドキュメント(“Extend Claude with skills”、2026-10-10 確認)から、仕組みに関わる事実を挙げます。

  • 置き場所と同名のときの優先: 組織の管理設定、利用者(~/.claude/skills/<名前>/SKILL.md)、プロジェクト(.claude/skills/<名前>/SKILL.md)、プラグイン。同じ名前なら、組織のものが利用者のものに、利用者のものがプロジェクトのものに勝ちます。プラグインのスキルは /<プラグイン名>:<スキル名> という名前空間つきの名前になります。
  • コマンドとの関係: 独自のコマンド(.claude/commands/ のファイル)はスキルに統合されています。.claude/commands/deploy.md と .claude/skills/deploy/SKILL.md は、どちらも /deploy を作ります。同じ名前ならスキルが使われます。
  • 説明の長さ: 一覧に載る説明(description と when_to_use を合わせたもの)は 1,536 文字で切られます。
  • 本文の扱い: 呼び出されると、本文は 1 つのメッセージとして会話に入り、以後のターンでも残ります。ファイルは以後のターンで読み直されません。
  • 圧縮のあと: 呼び出したスキルのそれぞれについて、最新の呼び出しの先頭 5,000 トークンが要約のあとに再び添付されます。再添付の合計の予算は 25,000 トークンで、新しく呼び出したものから埋めるので、多くのスキルを呼び出したセッションでは古いものが落ちます。
  • 欄: frontmatter の欄として、モデルによる自動の呼び出しを止めるもの(disable-model-invocation。このとき説明も文脈に置かれない)、スキルを呼び出したターンで確認なしに使えるツールを指定するもの(allowed-tools)、サブエージェントの中で実行するもの(context: fork)などがあります。本文には、引数の置換($ARGUMENTS)や、シェルコマンドの出力を本文に埋め込む記法があります。

最小のスキルは、次のようなファイルです(.claude/skills/summarize-diff/SKILL.md。内容は自作の例)。

---
description: 未コミットの変更を要約し、危険そうな箇所を挙げる。差分の確認やコミットメッセージの相談のときに使う。
---
## 手順
1. `git diff HEAD` の結果を読む。
2. 変更点を 3 行以内で要約する。
3. エラー処理の抜け、値の決め打ち、更新が必要なテストがあれば挙げる。
4. 差分が空なら、未コミットの変更は無いと答える。

命題 5.2(スキルの文脈コスト)✓

モデルが自分で呼び出せるスキルが nn 個あり、スキル ii の(切られたあとの)説明の長さを ℓi≤ℓ\ell_i \le \ell、本文の長さを bib_i とする。

  1. どのスキルも呼び出していないとき、スキルのために文脈に置かれる量は ∑iℓi≤n ℓ\sum_i \ell_i \le n\,\ell で、本文の長さ b1,…,bnb_1, \ldots, b_n に依らない。
  2. 圧縮のあとに再び添付される量は、スキル 1 個あたりの上限を aa、合計の上限を AA、呼び出した相異なるスキルの数を kk とすると、高々 min⁡(k a, A)\min(k\,a,\ A)。
証明(命題 5.2)

1: 呼び出していないスキルについて文脈に置かれるのは説明だけなので、量は ∑iℓi\sum_i \ell_i です。各 ℓi≤ℓ\ell_i \le \ell なので n ℓn\,\ell 以下です。この式に bib_i は現れません。

2: 再添付はスキルごとに高々 aa で、対象は kk 個なので合計は高々 k ak\,a です。合計には上限 AA もあるので、高々 AA でもあります。両方が成り立つので、min⁡(k a,A)\min(k\,a, A) 以下です。

公開されている値は a=5,000a = 5{,}000 トークン、A=25,000A = 25{,}000 トークンです(2026-10 時点)。k=3k = 3 なら上界は 15,00015{,}000、k≥5k \ge 5 なら 25,00025{,}000 です。説明の上限は文字数(1,536 文字)で定められているので、トークン数での ℓ\ell はトークンへの分割に依ります。公式ドキュメントによれば、一覧の全体にも文字数の予算があり(既定でモデルのコンテキストウィンドウの 1% に比例。設定で変更できる)、一覧がこれを超えると、名前はすべて残したまま、呼び出しの少ないスキルから説明が省かれます。1 の ∑iℓi\sum_i \ell_i は、この予算を適用したあとに実際に置かれた説明の長さの和と読んでください(上界 n ℓn\,\ell は変わりません)。

1 は、スキルが CLAUDE.md と違う点です。CLAUDE.md は全文が毎回の要求に入るので、長い手順書を書くほど常駐のコストが増えます。スキルにすれば、常駐するのは説明だけです。前節の 命題 3.8 と同じ構造(名前や説明だけを置き、本体は使うときに読む)で、どちらもコンテキストウィンドウの節約です。スキルの本文は会話のメッセージとして末尾に足されるので、読み込みは既存のキャッシュを無効にしません(命題 3.8 の 4 と同じ理由)。

6. プラグインと、設定の出所の優先順位

Section titled “6. プラグインと、設定の出所の優先順位”

定義 6.1(プラグインとマーケットプレイス)↗

プラグインとは、スキル・エージェント・フック・MCP サーバなどの部品を入れたディレクトリで、Claude Code が 1 つの単位として導入し読み込むものをいう。名前などを書くマニフェストは .claude-plugin/plugin.json に置く。マーケットプレイスとは、.claude-plugin/marketplace.json を持つリポジトリまたはディレクトリで、プラグインとその取得元の一覧(カタログ)である。

公式ドキュメント(“Plugins overview”、2026-10-10 確認)は、有効なプラグインがセッションに足すものを 3 つ挙げています。モデルが自分で呼び出せるスキル・エージェント・コマンドの名前と説明は、何も使わないセッションでも毎ターンの文脈に入る(本文は使ったときだけ読み込まれる)。プラグインが定義する MCP サーバはセッションと並んで動き、フックはそのイベントで発火する。プラグインが実行するものは、利用者の権限で動く。導入の範囲は、利用者・プロジェクト・ローカルの 3 つです。

同じ名前の定義が複数の出所にあるときの扱いは、仕組みごとに公開されています。

仕組み同名のときの扱い(公式ドキュメントによる)
MCP サーバ優先度の高い出所の定義を使う。順は local、project、user、プラグイン提供、claude.ai のコネクタ。その出所のエントリ全体が使われ、欄ごとの併合はしない
スキル組織、利用者、プロジェクトの順で 1 つが勝つ。プラグインのものは名前空間で分かれる
サブエージェント組織の管理設定、コマンドラインの指定、プロジェクト、利用者、プラグインの順で 1 つが勝つ
フック併合する。登録されたものは、出所に依らずすべて発火する
CLAUDE.md加法的。すべての階層の内容が文脈に入る

次の命題のねらいは、同じ名前の定義が複数の出所にあっても、優先度の順に最上位の出所を採る規則なら、使われる定義が毎回ただ 1 つに決まることを確かめることです。実用上は、どの出所を見れば今の定義が分かるかがはっきりします(直後の例で具体的に見ます)。

命題 6.2(最上位の出所を採る規則は、定義を一意に決める)✓

出所の有限集合 Σ\Sigma に全順序(優先度)が定められ、各出所 σ\sigma は、名前から定義への部分写像 defσ\mathrm{def}_\sigma を与えるとする。名前 ν\nu を定義する出所の集合 Σν={σ:defσ(ν) が定義されている}\Sigma_\nu = \{\sigma : \mathrm{def}_\sigma(\nu) \text{ が定義されている}\} が空でないとき、「Σν\Sigma_\nu の中で優先度が最大の出所 σ∗\sigma^{\ast} の定義 defσ∗(ν)\mathrm{def}_{\sigma^{\ast}}(\nu) を採る」という規則は、採用される定義を一意に決める。採用された定義は、ただ 1 つの出所の定義にそのまま等しい。

証明(命題 6.2)

Σν\Sigma_\nu は有限で空でない全順序集合なので、最大元がちょうど 1 つあります(有限個の元を 2 つずつ比べて大きいほうを残していけば 1 つが残り、全順序なので、それより大きい元はありません。最大元が 2 つあれば互いに相手以上なので等しくなります)。σ∗\sigma^{\ast} が一意なので、defσ∗(ν)\mathrm{def}_{\sigma^{\ast}}(\nu) も一意です。規則はこの値をそのまま採るので、採用された定義は出所 σ∗\sigma^{\ast} の定義に等しくなります。

例 6.3(エントリ全体を採る方式と、欄ごとに併合する方式)

MCP サーバ db が、project の範囲では「URL は https://db.example.com/mcp、ヘッダに認証の値」、local の範囲では「URL は http://localhost:9000/mcp」と定義されているとします(自作の例)。

  • エントリ全体を採る方式(公開されている MCP の規則): local が勝ち、使われる定義は「URL は http://localhost:9000/mcp」だけです。ヘッダは付きません。使われている定義を知るには、local の定義だけを見れば足ります。
  • 欄ごとに併合する方式(比較のための仮の方式): URL は local のもの、ヘッダは project のものになり、project の認証の値が local の URL に送られます。どの欄がどの出所から来たかは、欄ごとに違いえます。出所が kk 個、欄が ff 個なら、欄と出所の組み合わせは最大で kfk^f 通りあり、使われている定義を知るにはすべての出所を見る必要があります。

エントリ全体を採る方式では、命題 6.2 のとおり、採用された定義が 1 つの出所の定義に等しいので、このような「どの出所も書いていない組み合わせ」は生じません。

フックが併合であること(すべて発火する)は、命題 4.3 の 1 と合わせると、安全側に働きます。どの出所のフックがブロックしても呼び出しは止まるので、フックを足しても、止められる呼び出しの集合は減りません。権限規則で deny を足しても許可される集合が増えないこと(命題 3.6[ツールと権限])と同じ向きの性質です。

7. Agent SDK:ループを自分のプログラムから動かす

Section titled “7. Agent SDK:ループを自分のプログラムから動かす”

Agent SDK は、Claude Code と同じ実行ループを、自分のプログラムから動かすためのライブラリです(公式ドキュメント “How the agent loop works” による)。プログラムはプロンプトと選択肢(オプション)を渡し、ループが出すメッセージの列を受け取ります。選択肢は、第 1 章の模型の要素に対応します。

SDK の選択肢(Python の名前)模型の要素
prompt最初の user メッセージ
allowed_tools、disallowed_tools、permission_mode関門(実行するか拒否するかを決める写像)
hooks(PreToolUse など)関門の一部(実行前)、および実行後の処理
mcp_serversツール定義の集合と、各ツールの実行(MCP サーバのツールの分)
max_turnsツール使用ターンの上限 TT
max_budget_usd支出の上限
agentsサブエージェント(別のループ)
setting_sourcesどの設定ファイルと指示ファイルを読み込むか(固定部分)

命題 7.1(SDK のループへの停止性の適用)✓

Agent SDK で組んだループが、上の対応表のとおりに 第 1 章 の模型の実例であるとする。max_turns を有限の値 TT に設定すると、そのループのモデルの呼び出しは高々 T+1T + 1 回で、ループは停止する。max_turns を設定しない(上限なし)場合、模型の上では停止は保証されない。

証明(命題 7.1)

仮定により、このループは第 1 章の模型で、関門・ツールの実行・上限を対応表のとおりに定めたものです。第 1 章の停止性の定理(定理 4.1[エージェントループ])は、モデル・関門・ツールがどんな写像であっても、上限 TT が有限ならモデルの呼び出しは高々 T+1T + 1 回で停止する、と述べています。フックや権限の設定は関門の定め方を変えるだけなので、定理の仮定は満たされたままです。上限が無い場合に停止が保証されないことも、同じ定理(常にツールを呼ぶモデルという反例)によります。

公式ドキュメントは、max_turns がツール使用ターンだけを数える上限であること、既定では上限が無いことを述べています。入力を流し込む使い方では、待ち行列に残っていたメッセージに対して新しいターンが始まり、数え直しになる、とも書かれているので、この上界は「1 つの入力に対する 1 回のループ」についてのものです。サブエージェントは別のループなので、その中のモデルの呼び出しはこの上限には数えられません(前章の入れ子と同時実行の上限、および支出の上限が、そちらの歯止めになります)。

次のコードは、自作のツールとフックを渡してループを動かす最小の形で、公式ドキュメント(“Give Claude custom tools” と “Intercept and control agent behavior with hooks”、2026-10-10 確認)の書式に合わせています。SDK の中のサーバは、プログラムと同じプロセスの中で動く MCP サーバです。このコードは、この記事では実行していません(API を呼ぶため。出力も載せません)。API キーは環境変数から読まれます。

# 未実行。Agent SDK の公式ドキュメントの書式(2026-10 時点)に合わせた最小の形。
import asyncio
from typing import Any
from claude_agent_sdk import (
ClaudeSDKClient, ClaudeAgentOptions, HookMatcher, ResultMessage,
tool, create_sdk_mcp_server,
)
@tool("add", "2 つの数の和を返す", {"a": float, "b": float})
async def add(args: dict[str, Any]) -> dict[str, Any]:
return {"content": [{"type": "text", "text": str(args["a"] + args["b"])}]}
calc = create_sdk_mcp_server(name="calc", version="0.1.0", tools=[add])
async def protect_env(input_data, tool_use_id, context): # PreToolUse のコールバック
path = input_data["tool_input"].get("file_path", "")
if path.split("/")[-1] == ".env":
return {"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": ".env は編集できません",
}}
return {} # 何も決めない
async def main():
options = ClaudeAgentOptions(
mcp_servers={"calc": calc},
allowed_tools=["mcp__calc__add"], # mcp__<サーバ名>__<ツール名>
hooks={"PreToolUse": [HookMatcher(matcher="Write|Edit", hooks=[protect_env])]},
max_turns=5, # ツール使用ターンの上限 T
)
async with ClaudeSDKClient(options=options) as client:
await client.query("add ツールを使って 2 と 3 の和を求めて")
async for message in client.receive_response():
if isinstance(message, ResultMessage):
print(message.subtype, message.result)
asyncio.run(main())

MCP の公式の SDK(言語ごとのライブラリ)を使ってサーバを書く方法もありますが、この記事では扱っていません(3.4 節のサーバは標準ライブラリだけで書いたものです)。

8. まとめ:どこに何を差し込めるか

Section titled “8. まとめ:どこに何を差し込めるか”
仕組み実行の主体モデルの出力に依る部分この章の命題
MCPサーバ(外のプログラム)どのツールをいつ呼ぶか命題 3.3、命題 3.4、命題 3.7、命題 3.8
フックハーネス無い(イベントの発生はループの構造で決まる)命題 4.3、命題 4.5
スキルモデル(指示を読んで従う)いつ呼び出すか、指示に従うか命題 5.2
プラグイン上の組み合わせ部品による命題 6.2
Agent SDK自分のプログラムループの中は同じ命題 7.1

拡張を 1 つ足すと、「外から文脈に入る内容の出所」か「利用者の権限で自動的に動くプログラム」が 1 つ増えます。MCP サーバはその両方で、サーバのプロセスは利用者の機械の上で動き、ツールの説明と結果はモデルの文脈に入ります。次章 では、この一覧をもとに、どこに信頼の境界があるかを調べます。

演習 9.1標準

版 v1<v2<v3<v4v_1 \lt v_2 \lt v_3 \lt v_4 について、次の各場合に、握手による方式(双方が最新の版を出す)と、要求ごとに版を示す方式のそれぞれで、取り決めが成立するかを答えよ。(a) C={v1,v2,v3}C = \{v_1, v_2, v_3\}、S={v2,v3,v4}S = \{v_2, v_3, v_4\}。(b) C={v1,v4}C = \{v_1, v_4\}、S={v1,v2,v3}S = \{v_1, v_2, v_3\}。(c) C={v1,v2}C = \{v_1, v_2\}、S={v3,v4}S = \{v_3, v_4\}。

解答

(a) 握手: max⁡C=v3∈S\max C = v_3 \in S なので成立します(命題 3.4 の 1)。CC も SS も区間で共通部分が空でないので、3 からも分かります。要求ごと: C∩S={v2,v3}≠∅C \cap S = \{v_2, v_3\} \ne \varnothing なので成立します。

(b) 握手: max⁡C=v4∉S\max C = v_4 \notin S。サーバは max⁡S=v3\max S = v_3 を返し、v3∉Cv_3 \notin C なので成立しません。CC が区間でないので 3 は使えません。要求ごと: C∩S={v1}≠∅C \cap S = \{v_1\} \ne \varnothing なので、高々 2 回の要求で成立します。

(c) C∩S=∅C \cap S = \varnothing なので、どちらの方式でも成立しません。

演習 9.2易

クライアントが、id が 77 の要求を送り、その応答を受け取ったあとで、再び id が 77 の要求を送った。これは (a) 2025-11-25 版、(b) 2026-07-28 版の要請に反するか。また、命題 3.3 の仮定は満たされているか。

解答

(a) 反します。2025-11-25 版は、同じセッションの中で以前に使った ID の再利用を禁じています。(b) 反しません。2026-07-28 版が禁じるのは、まだ応答を受け取っていない他の要求と ID が一致することで、最初の要求にはすでに応答が届いています。命題の仮定(未応答の要求の集合の上で id が単射)は、2 つ目の要求を送った時点で未応答の要求が 1 つだけなので満たされていて、応答の対応は一意に決まります。

演習 9.3標準

PreToolUse のフックのスクリプトが、設定に書かれたパスに存在しない(起動できない)とする。(1) onFailure が既定のとき、(2) "block" のとき、マッチするツール呼び出しはどうなるか。「秘密のファイルへの書き込みを止める」ためのフックと、「編集のあとで整形する」ためのフックのそれぞれについて、どちらの設定が合うかを理由とともに答えよ。

解答

起動できないのは失敗(ブロックしないエラー)です。(1) 既定("continue")では行為は進み、呼び出しは権限の判定へ進みます。(2) "block" では呼び出しは実行されません(命題 4.3 の 2)。

書き込みを止めるフックは、止めることが目的です。(1) だと、パスの間違い 1 つで保護が黙って無くなるので、(2) の fail-closed が合います。整形のフックは実行後の付随的な処理で、失敗しても作業を止める理由がありません。なお、整形は PostToolUse で行うのが普通で、このイベントはツールの実行のあとなので、そもそもブロックできません。

演習 9.4易

1 つのセッションで 7 個のスキルを呼び出し、本文はどれも 6,000 トークンだったとする。公開されている上限(1 個あたり 5,000 トークン、合計 25,000 トークン)のもとで、圧縮のあとに再び添付される量の上界と、内容がまったく添付されないスキルが少なくとも何個あるかを求めよ。

解答

上界は min⁡(7×5,000, 25,000)=25,000\min(7 \times 5{,}000,\ 25{,}000) = 25{,}000 トークンです(命題 5.2 の 2)。各スキルは 5,000 トークンに切られ、新しく呼び出したものから予算を埋めるので、25,000/5,000=525{,}000 / 5{,}000 = 5 個で予算が尽きます。残りの 7−5=27 - 5 = 2 個(古いもの)は添付されません。切るときはファイルの先頭が残るので、大事な指示は本文の初めに書くのがよい、と公式ドキュメントは勧めています。

いずれも公式の一次資料で、確認日は 2026-10-10 です。MCP の仕様は版ごとに URL が分かれています。

このテーマの 100 本ノック(40 問)

執筆: Claude Code(Opus 5.5)、Claude Code(Sonnet 5.5)・監査 3 回(Claude Code)・最終 2026-10-12

この記事の誤りを報告する ・運営: 夢現技研合同会社 ・料金 ・利用条件 ・特定商取引法に基づく表記

© 2026 夢現技研合同会社 ・コード例は MIT ライセンスです。