TT Lab
はじめる
学ぶ 学習パス コース

エージェントが私のDBを消した

誰が、いつ、何を呼んだか — 監査ログ・スキーマ検証・タイムアウト

TT Labで続きを見る

一言でいうと

運用されるMCPサーバーには、さらに4つのものが必要です。すべての呼び出しを記録する監査ログ、実行前の引数スキーマ検証、ツールごとの実行時間の上限、破壊的ツールの呼び出し頻度の制限です。仕様は、最初の2つと最後の1つをサーバーのMUSTとして、タイムアウトをクライアントのSHOULDとして書いていますが、実務では4つともサーバーが持つ方が安全です。

なぜ必要なのか

事故が起きたあとの最初の問いは、「誰が、いつ、何を、どんな引数で呼んだのか」です。その答えがなければ、原因を推測で埋めることになり、推測で直したサーバーは同じ事故をまた起こします。前のモジュールの事故記録にcause=を書けたのは、たまたまリクエストのログが残っていたからで、実際の運用では、その偶然に頼ってはいけません。

ツール仕様のセキュリティに関する節は、サーバーがすべてのツール入力を検証し、アクセス制御を実装し、呼び出し頻度を制限し、出力をサニタイズしなければならない(MUST)と書いています。クライアントには、機微な操作でのユーザー確認、呼び出し前の入力の表示、結果の検証、ツール呼び出しのタイムアウト、監査用の使用記録を勧めています(SHOULD)。このモジュールは、その一覧をサーバー側のコードに移します。クライアントがどのホストなのかを、サーバーは選べないからです。

どう動くのか

監査ログは呼び出し単位です。ツールを実行する関数を1つ包んで開始時刻を測り、結果のisErrorで成否を決め、1行のJSONをファイルに追記します。残すのは、いつ(ts)、何を(tool)、どんな引数で(arguments)、成功したか(ok)、どれだけかかったか(duration_ms)、失敗したならその理由(error)です。プロトコルエラーで拒否された呼び出しも残します。不正な引数を繰り返し送ってくるクライアントは、それ自体が兆候です。パスを環境変数で受け取るようにしておけば、採点ツールでもテストでも、一時ファイルで動かしてみることができます。

スキーマ検証は実行の前にあります。tools/listで返したinputSchemaはモデルへの約束であり、その約束をサーバー自身が守るのが検証です。requiredが欠けていたり、typeが食い違っていたりしたら、実行せずに、JSON-RPC 2.0の-32602 Invalid paramsで返答します。ツールが実行されている途中で失敗したのではなく、リクエストが間違っているので、isErrorではなくプロトコルエラーです。Pythonで注意する点が1つあります。boolはintのサブタイプなので、isinstance(True, int)は真になります。integerの位置にtrueが入ってくる場合は、別に防ぐ必要があります。

TYPES = {"string": str, "integer": int, "boolean": bool}
for key in schema.get("required", []):
    if key not in args:
        raise RpcError(-32602, f"Invalid params: missing {key}")
for key, spec in schema["properties"].items():
    if key in args and not isinstance(args[key], TYPES[spec["type"]]):
        raise RpcError(-32602, f"Invalid params: {key} must be {spec['type']}")

タイムアウトはサーバーが自分でかけます。ライフサイクルのタイムアウトの節は、送ったリクエストごとにタイムアウトを設けて、止まった接続とリソースの枯渇を防ぐよう求めており、進捗通知が届いても最大タイムアウトは必ず守るよう求めています。これはクライアント側の話ですが、サーバーも同じ理由で自分のツールに上限をかけます。止まったツール1つが、シングルスレッドのサーバー全体を占有してしまうからです。標準ライブラリのsignal.alarmでN秒後にSIGALRMを受け取るようにして、ハンドラーで例外を投げれば、time.sleepや遅いクエリの最中でも抜け出せます。終わったらsignal.alarm(0)で解除します。時間を超えた呼び出しはisError: trueで返答し、監査ログに失敗として残します。

ログをプロトコルでも送ります。ロギングユーティリティは、サーバーがloggingケーパビリティを宣言し、notifications/message通知で構造化されたログを送る方法を定めています。levelはRFC 5424の8段階(debug・info・notice・warning・error・critical・alert・emergency)で、loggerとdataが付きます。通知なので、idはありません。stderrのログが人のためのものだとすれば、これはホストが構造化された形で受け取り、画面に表示したり収集したりするログです。呼び出しごとに、レスポンスの前に1行送れば足ります。

呼び出し頻度の制限は、破壊的なツールから始めます。セッションはプロセス1つなので、モジュールのグローバルな辞書でツールごとの回数を数えれば足ります。delete_orderを1セッションにつき3回までに制限しておけば、間違ったループに入ったエージェントがすべての注文を削除してしまう事態は、3件で止まります。上限を超えた呼び出しは、削除せずにisError: trueで返答します。

現場での姿

監査ログができると、それを読むツールが必要になります。ツールごとの呼び出し数と失敗数を数えるスクリプト1つで、「直近1時間にdelete_orderが何回呼ばれ、何回拒否されたか」に答えることができ、その数字がそのままアラートルールの材料になります。失敗率が急に上がったなら、スキーマを変えたのにモデル側の説明を直していないのであり、タイムアウトが増えたなら、背後のDBが遅くなっています。

タイムアウトの値は、ツールごとに違います。参照は2秒で十分でも、レポートの生成には30秒が必要かもしれません。まず1つの値で始め、監査ログのduration_msの分布を見てからツールごとに分ける、という順序です。最初からツールごとに異なる値を推測で入れると、根拠のない数字がコードに残ります。

次のラボですること

前のモジュールのサーバーに、監査ログ、スキーマ検証、slow_reportツールと2秒の上限、ログ通知、delete_orderの3回制限を順に入れ、最後に監査ログを読んでツールごとの呼び出し数と失敗数を集計するスクリプトを書きます。