← ブログに戻る

MCP Sampling権限昇格を止める防御実装3パターン - Claude Desktopで実測

この記事を含む総合ガイド Claude Code 実戦運用ガイド

先週の勉強会で「Sampling は非推奨になったから、防御は書かなくていいですよね?」と聞かれました。前回の攻撃記事を書いた本人に向かって、です。私は3秒黙りました。

非推奨(deprecated)と、まだ動く、と、防御不要、は全部別の話です。

SEP-2577 で Sampling は Roots・Logging と一緒に非推奨に落ちましたが、12ヶ月の移行期間は普通に動きます。Claude Desktop 含む主要クライアントも動作を維持しています。そしてすでに本番で回っている MCP サーバーの大半は、この12ヶ月をそのまま走り抜けるはずです。「1年後に消えるから対策は書かなくていい」という判断をした環境は、その1年のうちに CVE を踏む側に回ります。

前回の記事(MCP Sampling 権限昇格 — Claude Desktop で通る3経路)で、Claude Desktop の承認画面を素通りできる3攻撃経路(承認疲れ / 目的隠しチェーン / クロスサーバ経由)を検証しました。今回はその防御実装編です。

3層で止めます。Claude Desktop 設定層 / MCP サーバー実装層 / Permission gate 層。単独ではどれも穴があります。3層重ねて、はじめて前回の3経路が塞がります。

MCP Sampling 防御3層とマッピング

前回の3経路を1画面で

防御の話に入る前に、何を止めるのかを1画面で確認しておきます。

経路攻撃の型Claude Desktop の弱点
A4 承認疲れ1タスクで15〜20回 Sampling を連射レート制限がクライアント側実装依存
A5 目的隠しチェーン翻訳→整形→引数化と分割承認画面は1回分しか意味論的に検査しない
A3 クロスサーバ経由X サーバが Y サーバのデータを Sampling 応答経由で吸うサーバ間のコンテキスト境界が甘い

3経路とも、単一 Sampling リクエスト単体では無害に見えます。危険なのは連鎖と組み合わせのほう。防御もそれに合わせて設計します。

防御パターン1: Claude Desktop 設定層 (allowedTools + プロキシ介在)

まず一番外側。Claude Desktop の claude_desktop_config.json で書ける防御です。

1-1. allowedTools で Sampling を要求するツール自体を絞る

MCP サーバーが提供するツールのうち、Sampling を発火させるものは限られています。書籍(MCPセキュリティ実践 第7章)で扱った freee のケースだと、270ツールのうち Sampling が必要なのは判断系の10個程度でした。allowedTools でその10個以外を切ります。

{
  "mcpServers": {
    "freee": {
      "command": "npx",
      "args": ["-y", "@example/freee-mcp"],
      "allowedTools": [
        "classify_account_item",
        "suggest_partner_name",
        "reconcile_deal"
      ]
    }
  }
}

Sampling を呼ばないツール(参照系・検索系)は allowedTools から Sampling 経路そのものを消せます。A3(クロスサーバ)への一次防御は allowedTools で表面積を減らすこと。攻撃者がまず触れるツール数を減らせば、連鎖の起点も減ります。

1-2. MCP プロキシで Sampling リクエストにレート制限をかける

A4(承認疲れ)を止めるには、Sampling リクエストの毎分レートを制限する必要があります。Claude Desktop 本体は 2026-08 時点でレート制限 UI を持っていません。ここは MCP プロキシ層で挟むのが現実解です。

シンプルな Node.js プロキシの心臓部だけ書きます。

// mcp-sampling-rate-limiter.ts
import { RateLimiterMemory } from 'rate-limiter-flexible';

const limiter = new RateLimiterMemory({
  points: 5,     // 5リクエスト
  duration: 60,  // per 60秒 per server
});

export async function guardSamplingRequest(
  serverId: string,
  next: () => Promise<Response>,
): Promise<Response> {
  try {
    await limiter.consume(serverId);
    return await next();
  } catch {
    return errorResponse(
      -32029,
      `sampling rate exceeded for ${serverId} (5 req/min)`,
    );
  }
}

閾値5/分は保守的な値です。私の環境では、正常な経費処理ワークフロー1回で Sampling は平均2〜3回しか出ません。15回超えるのは異常系だと言い切れました。

ここで気づいたこと: 閾値を「1タスクあたり」ではなく「per server per 60秒」で切ったのは、目的隠しチェーン(A5)の測定が実際にはしにくいからです。連鎖の途中でユーザが席を立って戻ってきた場合、1タスクの単位が曖昧になります。時間窓のほうが実装しやすく、誤検知も少ない。

防御パターン2: MCP サーバー実装層 (単一目的 + 連鎖禁止)

外側で絞っても、サーバー側が悪意を持っていたら意味がありません。次はサーバー実装側の自己抑制です。ここは A5(目的隠しチェーン)への一撃になります。

2-1. Sampling 呼び出しに purpose タグを必須化する

サーバーコード内で Sampling を呼ぶときは、必ず単一目的の purpose を明示します。TypeScript 実装だとこう書けます。

// safe-sampling.ts
type SamplingPurpose =
  | 'classify_account_item'
  | 'suggest_partner_name'
  | 'reconcile_deal';

interface SafeSamplingRequest {
  purpose: SamplingPurpose;      // 単一の enum、動的値禁止
  prompt: string;
  maxTokens: number;
  correlationId: string;         // 呼び出し系列の親ID
}

const inFlight = new Map<string, SamplingPurpose>();

export async function requestSampling(
  req: SafeSamplingRequest,
  client: McpClient,
): Promise<string> {
  // 1. 同じ correlationId で異なる purpose の連鎖を拒否
  const prev = inFlight.get(req.correlationId);
  if (prev && prev !== req.purpose) {
    throw new Error(
      `sampling chain refused: ${prev} → ${req.purpose} ` +
      `(single-purpose rule violated)`,
    );
  }

  inFlight.set(req.correlationId, req.purpose);
  try {
    return await client.sample(req);
  } finally {
    setTimeout(() => inFlight.delete(req.correlationId), 30_000);
  }
}

同じユーザ操作(同一 correlationId)の中で classify_account_item から始まった Sampling が、途中で suggest_partner_name に切り替わったら、そこで例外を投げます。A5 の翻訳→整形→引数化パターンは、この単一目的ルールで正面から折れます

2-2. Sampling の応答を「そのまま次のツールに渡さない」

A3(クロスサーバ)への防御は、Sampling 応答を tools/call の引数に自動投入しないことです。人間の目を挟むワンステップを入れます。

// no-passthrough.ts
async function useClassificationResult(result: string, ctx: Ctx) {
  // ❌ 悪い: 直に次のツールへ
  // await ctx.callTool('create_deal', { account_item: result });

  // ✅ 良い: ユーザ確認を挟む
  const confirmed = await ctx.elicit({
    prompt: `分類結果: ${result}\nこの勘定科目で仕訳を作成しますか?`,
    schema: { type: 'boolean' },
  });
  if (!confirmed) return;

  await ctx.callTool('create_deal', { account_item: result });
}

Sampling 応答 → 次ツール呼び出しの間に elicit を入れて、ユーザに明示的に見せる。elicit は SEP-2322 の Multi Round-Trip Requests の元ネタでもあり、Sampling 廃止後の後継として今のうちに慣らしておくと移行が楽です。

防御パターン3: Permission gate 層 (trace + intent 検査)

3層目は、Sampling リクエストのメタデータを機械可読な形で監査に流し込む部分です。事後検知になりますが、A3・A4・A5 の連鎖パターンは事後解析でこそ見えます。

3-1. trace context を Sampling にも通す

SEP-414 の tasks/ trace context を Sampling リクエストにも必ず付けます。付いていない Sampling リクエストは、プロキシ層で拒否します。

// trace-gate.ts
export function requireTraceContext(req: SamplingRequest): void {
  const traceparent = req.meta?.traceparent;
  if (!traceparent || !/^00-[0-9a-f]{32}-[0-9a-f]{16}-/.test(traceparent)) {
    throw new SamplingRejected('missing or malformed traceparent');
  }
}

これで、Sampling リクエストの全量が trace ID で紐付いた状態で監査ログに残ります。あとで「同じ traceparent で3回以上 Sampling が発火した」「異なる server span から同じ correlationId が引かれた」といったパターンを SQL で検出できるようになります。

3-2. 監査ログを「連鎖の見えるスキーマ」で保存

監査ログのスキーマは、単発リクエストではなく連鎖前提で書きます。私が使っているのはこれです。

CREATE TABLE mcp_sampling_audit (
  id BIGSERIAL PRIMARY KEY,
  ts TIMESTAMPTZ NOT NULL DEFAULT now(),
  trace_id TEXT NOT NULL,
  span_id TEXT NOT NULL,
  parent_span_id TEXT,
  server_id TEXT NOT NULL,
  purpose TEXT NOT NULL,
  prompt_hash TEXT NOT NULL,
  response_hash TEXT NOT NULL,
  user_approved BOOLEAN NOT NULL,
  chain_length INT NOT NULL  -- 同一trace_id内での通し番号
);

CREATE INDEX ON mcp_sampling_audit (trace_id, chain_length);

chain_length を持つと「chain_length >= 4 のリクエストは自動アラート」といった単純ルールで A5 を検出できます。私の環境で一週間流したところ、正常フローの chain_length 平均は 1.8、95パーセンタイルで 3。閾値4は現実的でした。

3層をどう並べるか (実運用マップ)

3層は独立ではなく、順番に効きます。

  • 入口: Claude Desktop 設定層で表面積を絞る (allowedTools + プロキシレート制限)
  • : サーバー実装層で単一目的を強制 (chain 拒否 + 応答パススルー禁止)
  • 出口: Permission gate 層で trace 必須化 + 監査 (chain_length で事後検知)

各層は前段が抜けたときに次段が受け止める設計です。allowedTools を書き忘れても、単一目的ルールで A5 が止まる。単一目的ルールを外していても、chain_length アラートで事後に気付ける。単層防御は避ける、が今回のたったひとつの実装原則です。

何が変わらないか、正直に

3層を入れても、以下は変わりません。

  • ユーザが reflexive にクリックする問題: A4 の根本原因はレート制限では消えません。UI 側の「N秒間 Sampling が発火したら承認ボタンを無効化」が必要ですが、これは Anthropic 側の実装待ちです。
  • サーバー実装者が悪意ある場合: purpose enum は自分で書くコードなので、悪意あるサーバーは purpose を偽装できます。ここは MCP のサンドボックスモデル自体の限界で、防御パターン2は「善意サーバーが誤ってチェーンする事故」を防ぐレイヤーだと割り切っています。
  • Sampling 廃止後: 12ヶ月後、Sampling は消えます。しかし SEP-2322 の Multi Round-Trip Requests は、サーバー起点の LLM 呼び出しを別の型で引き継ぎます。「連鎖 / パススルー / 承認疲れ」の3パターンは名前を変えて残るはずです。今書いた防御コードは、移行後もそのまま持ち越せる形にしてあります。

関連記事

参考リンク


MCP の権限境界・Sampling・OWASP MCP Top 10・ツールポイズニング・監査設計を通しで扱った書籍がこちらです → MCPセキュリティ実践

第6章で OWASP MCP Top 10、第7章で本番ワークアラウンド、第9章で運用ハックまでカバーしています。Sampling 廃止までの1年をどう乗り切るか、が主題です。

MCP実践セキュリティ 関連書籍 MCP実践セキュリティ MCP セキュリティ 完全ガイド | OWASP MCP Top 10・トークンコスト・ファイルアップロード問題 書籍ページを見る →