エージェントがDBを消す前に — 許可リスト・読み取り専用・確認
一言でいうと
破壊的なツールを安全にする仕組みは3層です。万能ツールをなくし、狭いツールを許可リストで有効・無効にします。書き込みが要らない場面では、接続そのものを読み取り専用で開きます。削除するツールは、確認なしには動かしません。仕様の「人間が拒否できなければならない」という文をコードにすると、この3つになります。
なぜ必要なのか
事故の材料は、いつも同じです。便利だからと作ったrun_sqlが1つ。モデルにSQLを書かせれば、ツールを10個作る必要はなく、デモは驚くほどうまくいきます。そしてある日、「先月のテスト注文を整理して」という言葉がDROP TABLE ordersになります。モデルは最も短い道を選んだだけで、サーバーはその道に扉を1つも置いていませんでした。
仕様の概要の「Security and Trust & Safety」の節は、この状況を正面から扱っています。ツールは任意のコード実行であり、それにふさわしい注意で扱わなければなりません。ツールの説明やアノテーション(annotations)は、信頼できるサーバーでなければ信じてはいけません。ホストは、ツールを呼び出す前にユーザーの明示的な同意を得なければなりません。ツール仕様はさらに具体的です。人がツール呼び出しを拒否できる場面が常になければならず(SHOULD)、サーバーはすべての入力を検証し、アクセス制御を実装し、呼び出し頻度を制限しなければなりません(MUST)。仕様自身が、プロトコルの層でこれを強制することはできないと書いているので、実装する人が入れなければ、どこにも存在しません。
どう動くのか
第1に、ツールを狭くします。run_sqlの代わりに、count_orders、list_customers、delete_orderのように、1つの意図に1つのツールを置きます。狭いツールは引数のスキーマが狭く、スキーマが狭ければ、モデルが作れるリクエストの空間も狭くなります。delete_orderの引数はid1つだけなので、「テーブルを削除する方法」がそもそも存在しません。これがアクセス制御の最初の層で、ほかのどの検査よりも確実です。検査すべきものがないからです。
第2に、許可リストで有効にします。サーバーは、定義されたすべてのツール(ALL_TOOLS)と、いま有効にするツール(allowlist.json)を分けます。tools/listはリストにあるものだけを返し、tools/callもリスト外の名前には未知のツールとして返答します(-32602)。リストのファイルがない、または壊れているときは、どのツールも有効にしません。閉じたデフォルト(fail-closed)です。本番環境では、読み取りツールだけを有効にし、削除ツールは必要な作業の時間だけ有効にする、といった使い方をします。「ある中から無効にするものを選ぶ」のではなく、「リストになければ存在しないツール」という形にしておくと、新しいツールが追加されたときに、うっかり公開されてしまうことがありません。
第3に、読み取り専用は接続でかけます。SQL文字列がSELECTで始まるかを検査する方式は、検査すべきものが際限なく増えます。複数の文をつなげた入力、コメントや大文字・小文字の変形、WITHで始まる読み取りの文(SQLiteはWITH ... DELETEも許可します)まで、1つずつ追いかけなければならず、見落とした1つが事故になります。代わりに、Python標準ライブラリのsqlite3がサポートするURIで、mode=roを付けて開くのが答えです。
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
con.executescript("DROP TABLE orders;") # sqlite3.OperationalError: attempt to write a readonly database
書き込みがデータベースエンジンで拒否されるので、文字列をどう飾っても通用せず、サーバーのコードには検査するリストがそもそもありません。サーバーはその例外を捕まえて、isError: trueで返せば足ります。環境変数1つ(MCP_READ_ONLY=1)で有効にし、参照だけが必要なデプロイでは、書き込みの経路がそもそも存在しないようにします。
第4に、破壊的なツールは確認を取ります。delete_orderは、confirm引数がブール値のtrueのときだけ削除します。そうでないときは削除せず、削除していたら何が消えたか(注文番号・状態・金額)をisError: trueのテキストで返します。そのテキストをホストが人に見せ、人が承認すると、モデルがconfirm: trueで再度呼び出します。仕様のいう「human in the loop」が、この往復です。文字列の"true"を受け付けてはいけません。スキーマがbooleanなら、is Trueで比較します。ツール定義のannotationsにはreadOnlyHint・destructiveHintのようなヒントを付けられますが、仕様は、クライアントはこのアノテーションを、信頼できるサーバーでなければ信じてはならない(MUST)としています。ヒントは画面表示用であって、安全装置ではありません。
現場での姿
事故のあとの復旧は、バックアップから行います。しかし、それより重要なのは事故の記録です。原因は何だったのか(ツールの設計)、どの仕組みがなかったのか(許可リスト・読み取り専用・確認)、何を変えるのか。この3行がなければ、次のサーバーもrun_sqlから始まります。このモジュールのラボが、事故の再現 → 復旧 → 記録 → 3層の仕組みという順序になっているのは、そのためです。
もう1つあります。確認のステップを入れたあと、「エージェントが毎回聞いてくるので遅い」という不満が出てきます。答えは、確認をなくすことではなく、ツールをさらに狭くすることです。delete_orderではなくcancel_test_order(テスト注文だけ、状態の変更だけ)のようなツールであれば、確認が要らないほど安全です。安全は、確認ダイアログの数ではなく、ツールにできることの大きさから生まれます。
次のラボですること
run_sqlのサーバーにDROP TABLE ordersを実際に送って、ordersが消えるのを確認し、復旧して、事故の記録を書きます。そのあと、読み取り専用モード、許可リスト、確認引数を順に入れて、同じ攻撃が3か所で防がれることを証明します。