速水拓真 営業×AI 実践シリーズ
書き下ろし

自作MCPサーバーに「読み取り専用モード」を実装する — AIに何をさせないかを設計する

書き下ろし(当サイト初出)

はじめに

自作MCPサーバーに「ファイルを書く」「コマンドを実行する」ツールを足すと、Claudeは一気に便利になります。同時に、エージェントが手元の作業ディレクトリを壊せる状態にもなります。実際に私が踏んだのは「リファクタリングを頼んだら、確認用に作った一時ファイルを消すついでに設定ファイルまで消された」というものです。バックアップがあったので事なきを得ましたが、原因はモデルの暴走ではなくツール設計の側にありました。

この記事では、自作MCPサーバーに 読み取り専用モード を実装します。ポイントは「危険なツールを消す」のではなく、同じサーバーを権限だけ変えて起動できるようにすることです。

  • 前提: PythonのMCP SDK(mcp)でサーバーを1つ書いたことがある / Claude Desktop または Claude Code から接続できる
  • ゴール: 環境変数ひとつで書き込み系ツールが封じられ、AIがその制限を理解して自力で別案に切り替えられる状態

素朴な実装と、その危うさ

まず、よくある「ファイル操作ツールを4つ生やした」サーバーです。

from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("file-tools")
ROOT = Path.home() / "work"

@mcp.tool()
def read_file(path: str) -> str:
    """ワークスペース内のファイルを読む"""
    return (ROOT / path).read_text(encoding="utf-8")

@mcp.tool()
def write_file(path: str, content: str) -> str:
    """ワークスペース内のファイルへ書き込む"""
    p = ROOT / path
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content, encoding="utf-8")
    return f"wrote {p}"

@mcp.tool()
def run_command(command: str) -> str:
    """シェルコマンドを実行する"""
    import subprocess
    return subprocess.run(command, shell=True, capture_output=True, text=True).stdout

このままでも動きますが、調査だけさせたい場面でも書き込みと実行が常に有効です。「読むだけのつもりが、AIが親切心で直してしまう」は、この構成では止められません。

3層で守る

読み取り専用モードを、性質の違う3つの層で実装します。どれか1つでは足りません。

何をするか 何を防ぐか
① 起動時ポリシー 環境変数でモードを決め、危険ツールを登録しない そもそも呼べない状態にする
② ツール宣言 readOnlyHint などの注釈を付ける AIが「今は読めるだけ」と理解する
③ 実行時ガード パスと権限を呼び出し時に検証する 宣言を無視した/すり抜けた呼び出し

②は宣言であって強制力ではありません。クライアントが無視することもあります。だから③が要ります。①だけだと、モードを切り替え忘れたときに無防備になります。多層にする理由はここです。

① 起動時ポリシー

モードは環境変数で受けます。既定は安全側(読み取り専用)にします。書き込みを許すには明示的に MCP_MODE=write を渡す、という設計です。

import os
from dataclasses import dataclass

@dataclass(frozen=True)
class Policy:
    mode: str          # "readonly" | "write"
    roots: list        # 許可するルートディレクトリ(絶対パス)
    allow_exec: bool   # シェル実行を許すか

    @property
    def can_write(self) -> bool:
        return self.mode == "write"

def load_policy() -> Policy:
    mode = os.environ.get("MCP_MODE", "readonly").lower()
    if mode not in ("readonly", "write"):
        raise SystemExit(f"MCP_MODE の値が不正です: {mode}")
    roots = [Path(p).expanduser().resolve()
             for p in os.environ.get("MCP_ROOTS", "~/work").split(os.pathsep)]
    return Policy(
        mode=mode,
        roots=roots,
        # 実行許可は write モードでも別フラグ。既定は無効
        allow_exec=mode == "write" and os.environ.get("MCP_ALLOW_EXEC") == "1",
    )

POLICY = load_policy()

allow_exec をモードから独立させたのが要点です。「書き込みは許すがシェルは許さない」は実務でよくある要求で、1つのフラグにまとめると表現できません。

② ツール宣言(AIに意図を伝える)

MCPのツールには注釈(annotations)を付けられます。読み取り専用なら readOnlyHint、破壊的なら destructiveHint です。Claudeはこれを手がかりに「今どの操作が安全か」を判断します。

from mcp.types import ToolAnnotations

READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
DESTRUCTIVE = ToolAnnotations(
    readOnlyHint=False, destructiveHint=True, idempotentHint=False
)

@mcp.tool(annotations=READ_ONLY)
def read_file(path: str) -> str:
    """ワークスペース内のファイルを読む(副作用なし)"""

@mcp.tool(annotations=DESTRUCTIVE)
def write_file(path: str, content: str) -> str:
    """ワークスペース内のファイルへ書き込む(既存内容を上書きする)"""

注釈の文面も効きます。「ファイルへ書き込む」より「既存内容を上書きする」のほうが、AIは慎重に扱います。説明文は人間向けの説明ではなく、モデルへの制約条件だと思って書くのが正解です。

③ 実行時ガード — ポリシーの一元適用

デコレータで、ツール本体の手前に権限チェックを挟みます。

import functools

class PolicyViolation(Exception):
    """ポリシー違反。AIに“直せる形”で返すための専用例外"""

def requires_write(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if not POLICY.can_write:
            raise PolicyViolation(
                "このサーバーは現在読み取り専用モードです。書き込みは実行できません。"
                " 変更案はテキストで提示し、適用は利用者の承認後に別途行ってください。"
            )
        return func(*args, **kwargs)
    return wrapper

エラーメッセージを「拒否」ではなく「代替手順の提示」にしているのが重要です。"Permission denied" だけを返すと、AIは同じツールを別の引数で何度も呼び直します。読み取り専用モードでは、「提案はテキストで」「適用は承認後」という次の行動を明示すると、試行錯誤のループが消えます。

パスの検証は、書き込み系・読み取り系の両方に掛けます。

def resolve_safe(path: str) -> Path:
    """ルート外への脱出を防いだ絶対パスを返す"""
    p = Path(path)
    if not p.is_absolute():
        p = POLICY.roots[0] / p
    p = p.resolve()
    for root in POLICY.roots:
        if p == root or root in p.parents:
            return p
    raise PolicyViolation(
        f"アクセス不可: {p} は許可ルートの外にあります。"
        f" 許可ルート: {', '.join(str(r) for r in POLICY.roots)}"
    )

resolve() してから比べるのが肝です。~/work/../.ssh/id_rsa のような経路は、文字列の前方一致では弾けても、resolve() を通すと確実に落とせます。.. を文字列で消す実装は書かないでください。シンボリックリンクを挟まれると破られます。

ツール登録をモードで切り替える

①で「登録しない」と言った部分です。FastMCPでは、条件付きで登録できます。

if POLICY.can_write:
    @mcp.tool(annotations=DESTRUCTIVE)
    @requires_write
    def write_file(path: str, content: str) -> str:
        """ワークスペース内のファイルへ書き込む(既存内容を上書きする)"""
        p = resolve_safe(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")
        return f"wrote {p}"

    @mcp.tool(annotations=DESTRUCTIVE)
    @requires_write
    def delete_file(path: str) -> str:
        """ファイルを削除する(復元不可)"""
        p = resolve_safe(path)
        p.unlink(missing_ok=True)
        return f"deleted {p}"
else:
    # 読み取り専用モードでも「何が使えないか」をAIに知らせる
    @mcp.tool(annotations=READ_ONLY)
    def write_file(path: str, content: str) -> str:
        """(無効)このサーバーは読み取り専用モードのため書き込みできません"""
        raise PolicyViolation(
            "読み取り専用モードです。変更内容をテキストで提示してください。"
        )

読み取り専用モードでも同じ名前のツールを残して、明示的に断るのがコツです。ツールごと消すと、AIは「ツールが無い」と解釈して run_command("echo ... > file") のような迂回路を探し始めます。「無い」より「使えない」のほうが、モデルの行動は安定します。

エラーをAIが読める形で返す

FastMCPで例外を投げるとエラーとして返りますが、返し方を制御したい場合は戻り値で表現します。

@mcp.tool(annotations=READ_ONLY)
def inspect_workspace(subdir: str = ".") -> str:
    """許可ルート配下の構造を一覧する(読み取り専用)"""
    try:
        base = resolve_safe(subdir)
        lines = []
        for p in sorted(base.rglob("*")):
            kind = "dir " if p.is_dir() else "file"
            lines.append(f"{kind} {p.relative_to(base)}")
        return "\n".join(lines) or "(空)"
    except PolicyViolation as e:
        # 例外にせず、AIが次の一手を打てる文章として返す
        return f"ERROR: {e}"

判断基準は単純です。「利用者が直せるエラー」は例外、「AIが次の行動を変えられるエラー」はメッセージとして返します。上の inspect_workspace はルートを変えれば成功するので、文章で返して再試行させます。

動作確認

まずInspectorで、モードごとにツール一覧が変わることを確認します。

# 読み取り専用(既定)
MCP_MODE=readonly MCP_ROOTS=~/work npx @modelcontextprotocol/inspector python server.py

# 書き込みを許可(シェルは引き続き不可)
MCP_MODE=write MCP_ROOTS=~/work python server.py

Claude Code から使う場合は、登録時に環境変数を渡します。

claude mcp add file-tools --env MCP_MODE=readonly --env MCP_ROOTS=$HOME/work \
  -- python /path/to/server.py

claude mcp list で接続を確認し、読み取り専用モードで「このファイルを直して」と頼んでみてください。テキストでの修正案を返し、書き込みを試みないなら設計どおりです。

ハマりどころ

  • 注釈は強制ではない: readOnlyHint はヒントです。実行時に弾く③を省くと、注釈だけを信じた設計になります
  • 既定は安全側: MCP_MODE を必須にして readonly を既定にすると、設定漏れが被害になりません
  • run_command は読み取り専用にできない: lsrm も同じツールで実行できます。だから実行許可はモードから独立した別フラグにします
  • ルートは「入口」ではなく「境界」: resolve() 後の比較を、読み取り・書き込みの全ツールで共有してください

まとめ

  • 読み取り専用モードは、危険なツールを消すのではなく、権限だけを差し替える形で作る
  • 守りは3層: ①起動時ポリシー(登録可否)②注釈(AIへの宣言)③実行時ガード(パス検証と権限)
  • 既定値は安全側。MCP_MODE の既定は readonly、実行許可は独立フラグ
  • 拒否メッセージには代替手順を書く。AIの試行錯誤ループが消える
  • ツールは消さず「使えない」と返す。迂回路を探させないため

「AIに何をさせるか」を設計する作業は、実は「AIに何をさせないか」を決める作業とほぼ同じです。読み取り専用モードは、その境界をコードとして残す手段になります。

読者特典(無料)

本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。

🎁 読者特典を受け取る(無料・メール登録)

📗 MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・読み放題対象)

権限設計まわりは、書籍では「破壊的ツールの隔離」「承認フローの作り分け」「監査ログの残し方」まで踏み込んで解説しています。Kindle Unlimited会員は読み放題で読めます。


著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

営業×AI実践シリーズ 全3作

Kindle Unlimited 読み放題対象 — 追加料金なしで読めます

← 記事一覧へ

← 商談で読み上げた数字が、ひとつだけ古かった話…良い顧客の条件を書く前に、会わなくていい会社を決… →