「確認して修正しておきました」——Claudeのこの報告を信用して数日後にファイルを開いたら、直したはずの設定ファイルが上書きされていました。原因は、自作MCPサーバーの書き込み系ツールをLLMが別用途で呼べてしまう素通しだったことです。
この記事では、自作MCPサーバーに最低限の保護(読み取り専用モード / 書き込み先の絶対パス指定 / sudo禁止)を入れる完全なコードと、実装中に引っかかった5つの落とし穴を書きます。すべて実際に起きた事故です。
結論: 保護の置き場はプロンプトでなく「ツール定義」
LLMは渡されたツールを都合よく使います。「書き込みしないで」とプロンプトで頼んでも、書き込み系ツールがツール一覧に載っている状態では実行を止められません。保護は3層に分けて入れます。
| レイヤー | 実装 | 役目 |
|---|---|---|
| ツール定義 | read_onlyの時は書き込み系ツールを定義しない | LLMは存在しない道具を呼べない(第1防御) |
| 絶対パス | 書き込み系ツールに許可パス以外を拒否するガードを入れる | 許可域外の書き込みは必ず失敗(第2防御) |
| OS層 | サーバー起動はsudoでなく一般ユーザーで | 万一突破されても被害が当該ユーザー権限で止まる(最後の城) |
「書かないでね」はお願いであって制御ではない、というのが今回の教訓でした。
落とし穴1: startswithのままだと親ディレクトリ逃げで抜けられる
最初に書いたガードは、path.startswith("/home/user/projects/app") の一択だった。これに /home/user/projects/app/../secrets を渡すと、先頭一致で通ってしまいます。判定の直前に os.path.realpathで正規化し、「許可ルートと同一か、その直下であること」を見るようにする必要があります。
落とし穴2: 許可リストの末尾スラッシュで別ディレクトリが一致する
許可リストに /tmp/app/ と書き、入力に /tmp/app_backup/x.txt が来ると、素のstartswithでは /tmp/app が先頭一致して通ってしまいます。比較の際にルート側の末尾に os.sep を足して「ディレクトリ境界での一致」に統一する必要があります(以下のコードに入れてあります)。
完全実装: ガード付きツール層(このまま動く)
# safe_mcp_tools.py -- MCPサーバーのツール層に入れる最小ガード
# 単体でも検証できるようにmain付き(python3 safe_mcp_tools.py)
import os
class ToolDenied(Exception):
pass
def _allow_roots() -> list:
raw = os.environ.get("MCP_ALLOW_ROOTS", "")
return [r for r in raw.split(":") if r]
def assert_writable(path: str) -> str:
"""書き込みの直前に必ず通す。許可域外ならToolDenied。"""
real = os.path.realpath(path)
for root in _allow_roots():
root = os.path.realpath(root)
# ルート側は末尾にos.sepを足して「境界一致」に統一する
if real == root or real.startswith(root + os.sep):
return real
raise ToolDenied("path outside allow-roots: " + real)
def read_file(path: str) -> str:
"""読み取り用ツール(READ_ONLYでも提示してよい)。"""
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path: str, text: str) -> str:
"""書き込み用ツール。READ_ONLYの際はツールごと存在しない。"""
real = assert_writable(path)
with open(real, "w", encoding="utf-8") as f:
f.write(text)
return "wrote %d chars to %s" % (len(text), real)
def build_tools() -> dict:
"""LLMに提示するツール一覧。READ_ONLYの場合は書き込み系を除外。"""
tools = {"read_file": read_file}
if os.environ.get("MCP_READ_ONLY", "1") != "1":
tools["write_file"] = write_file
return tools
if __name__ == "__main__":
import tempfile
demo_root = tempfile.mkdtemp(prefix="mcp_demo_")
os.environ["MCP_ALLOW_ROOTS"] = demo_root
os.environ["MCP_READ_ONLY"] = "0"
print("1) normal write:", write_file(demo_root + "/a.txt", "hello"))
try:
write_file(demo_root + "/../evil.txt", "boom")
except ToolDenied as e:
print("2) traversal blocked OK:", e)
os.environ["MCP_READ_ONLY"] = "1"
print("3) tools in READ_ONLY:", list(build_tools().keys()))
デモの期待出力は次の3行です。
1) normal write: wrote 5 chars to /tmp/mcp_demo_xxxx/a.txt
2) traversal blocked OK: path outside allow-roots: /tmp/evil.txt
3) tools in READ_ONLY: ['read_file']
3)が本質です。READ_ONLYフラグが立っていると、write_file の実体がLLMから見えなくなります。許可された書き込み自体は行えてしまうが、LLMへの提示自体がないため、LLMはその経路を選べません。
落とし穴3~5: 実装後に気づいて直したこと
残り3つは、事故が起きる前には気づけなかった地雷です。
| 起きた問題 | 直したこと | 役目 |
|---|---|---|
| ガードの拒否まででサーバー全体が落ちる | 拒否はMCPの応答にisError=Trueで返す | AIが失敗を認識して別方法を採る。セッションごと死なない |
| ツールの説明が素っ気なうちでAIが無駄な書き込みを試す | 説明に「許可以外は必ず拒否」を明記 | 空振り呼び出し(トークン無駄)が減る |
| 書き込み事故後に気づかない | 書き込み前のpathとバックアップ先をstderrに出力 | 上書きされても切り戻せる。出力しておかないと切り戻し性がない |
| sudo(権限昇格)を要求するツールが存在する | 昇格を削除し、必要な作業は人間が実行 | 1回昇格されるとガード全体の意味が消える |
まとめ
- 保護はプロンプトでなくツール定義に置く。READ_ONLYで書き込み系を除外するのが第1防御
- 書き込みツールを提供する場合は、realpath正規化+絶対パス指定を入れる(startswith単独は抜けられる)
- 拒否は isError=True で返す(サーバーを落とさない)
- sudoでの起動は禁止。MCPだけでなく、LLMに実行権限を渡す全ての場面で同じことが言える
次のアクションは、自分のMCPサーバーで build_tools() 相当を呼んで「READ_ONLYで書き込み系が除外されているか目視」することです。書き込み系が残っていた場合は、今回の assert_writable を必ず入れてください(上書き事故は、書き込むつもりのない呼び出しから起きました)。
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
参考書籍: MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)