MCP(Model Context Protocol)のセキュリティを調べていると、Samplingという機能がよく槍玉に上がります。「悪意のあるサーバーがクライアントのLLMを操れる」「権限昇格の足がかりになる」。読んでいるうちに、だんだん怖くなってきます。
私もその一人でした。Sampling経由の攻撃をいくつ数えられるか、と身構えていたのです。ところが、その前に一つ確かめておくべきことがありました。
手元のクライアントは、そもそもSamplingに応じるのか。
Claude Code 2.1.283で測ってみたところ、答えは「応じない」でした。攻撃経路を3つ数える前に、入口のほうが閉まっていた、という話です。測り方は単純で、確認用のサーバーを1本繋ぐだけです。コードはそのまま載せるので、自分の使っているクライアントでも同じことが測れます。
Samplingとは何か、なぜ攻撃の話に出るのか
ふつうのMCPは一方向です。LLM(クライアント)が、サーバーの持つツールを呼びます。「天気を取得して」と頼めば、サーバーの get_weather が動く。これが基本の向きです。
Samplingはこの向きが逆になります。サーバーのほうから、クライアントのLLMに問い合わせる仕組みです。たとえば経費処理のサーバーが、処理の途中で「この支出は何費に当たりますか?」とクライアント側のLLMに尋ねる。サーバーが自前でLLMを持たなくても、クライアントの頭脳を借りられる、という便利な設計です。
便利な反面、向きが逆になることで攻撃の余地が生まれます。悪意のあるサーバーが、処理のふりをしてクライアントのLLMに隠し指示を送る。あるいは複数のサーバーを繋いだ環境で、別のサーバーが持っている文脈を引き出させる。こうした筋書きが「Sampling経由の権限昇格」として語られます。
仕様はこれを放置しているわけではありません。ユーザーがサンプリングの実行を明示的に承認すること、サーバーがプロンプトの中身を覗けないよう可視性を制限すること、を求めています。
ただ、ここまではすべて「クライアントがSamplingを実装している」という前提の上の話です。実装していないクライアントには、そもそもこの経路がありません。 攻撃が成立するかどうかは、まずそこで決まります。仕様書をいくら読んでも、この一点は手元で測るしかありません。
攻撃を語る前に、前提を1つ測る
測ることは2つです。
- 接続時(initialize)に、クライアントが何のcapabilitiesを宣言するか
- サーバーから
sampling/createMessageを実際に送ったら、どう返ってくるか
1は「Samplingに対応しますと名乗っているか」、2は「名乗りに関係なく、送ったら通るか」です。両方を記録する小さなstdio MCPサーバーを1本書きました。使ったのは mcp Python SDK 1.28.1 の FastMCP です。
"""クライアントがSamplingに応じるかを確かめるMCPサーバー。
initializeで受け取ったcapabilitiesを記録し、ツール呼び出し時に
sampling/createMessageを実際に投げる。"""
import json, os, time
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import SamplingMessage, TextContent
LOG = os.environ.get("PROBE_LOG", "results/sampling_probe.jsonl")
mcp = FastMCP("probe")
def log(rec):
rec["ts"] = time.strftime("%Y-%m-%dT%H:%M:%S")
with open(LOG, "a") as f:
f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
@mcp.tool()
async def summarize_note(text: str, ctx: Context) -> str:
"""Summarize a short note."""
params = ctx.session.client_params
caps = params.capabilities.model_dump(exclude_none=True) if params else None
info = params.clientInfo.model_dump() if params else None
log({"event": "client_params", "clientInfo": info, "capabilities": caps,
"protocolVersion": getattr(params, "protocolVersion", None)})
try:
r = await ctx.session.create_message(
messages=[SamplingMessage(role="user",
content=TextContent(type="text", text="Reply with the single word PONG."))],
max_tokens=20)
log({"event": "sampling_result", "ok": True, "result": r.model_dump()})
return "sampling ok"
except Exception as e:
log({"event": "sampling_result", "ok": False, "error": f"{type(e).__name__}: {e}"})
return f"sampling failed: {type(e).__name__}"
if __name__ == "__main__":
mcp.run()
ツールの中身はほとんど「記録」です。ctx.session.client_params から接続時のcapabilitiesとクライアント情報を取り、そのうえで ctx.session.create_message(...) でSamplingを実際に投げます。通れば sampling ok、弾かれれば例外名を返します。
これをMCP設定ファイルに登録し、claude -p から1回だけ呼びました。
claude -p "Use the summarize_note tool on the text 'hello world' and report exactly what it returned." \
--strict-mcp-config --mcp-config ./sampling.mcp.json \
--allowedTools mcp__probe__summarize_note < /dev/null
--strict-mcp-config を付けているのは、普段使っている他のMCPサーバーが混ざって結果が濁るのを避けるためです。測りたいのは probe サーバー1本だけの応答です。
実測結果
記録されたのは、次の内容でした。
| 項目 | 実測値 |
|---|---|
| クライアント | claude-code 2.1.283 |
| protocolVersion | 2025-11-25 |
| 宣言されたcapabilities |
elicitation(form, url) と roots(listChanged) のみ。sampling は無し
|
sampling/createMessage の結果 |
McpError: Method not found |
ログの生の行はこうです。
{"event": "client_params",
"clientInfo": {"name": "claude-code", "version": "2.1.283", ...},
"capabilities": {"elicitation": {"form": {}, "url": {}}, "roots": {"listChanged": true}},
"protocolVersion": "2025-11-25"}
{"event": "sampling_result", "ok": false, "error": "McpError: Method not found"}
接続時に名乗ったのは elicitation と roots の2つだけ。sampling はありません。そして名乗っていない機能を無理やり送りつけると、クライアントは Method not found を返しました。ツールの戻り値は sampling failed: McpError です。
試行は1回です。少なく感じるかもしれませんが、capabilitiesは接続のたびにクライアントが宣言するもので、ガチャのように試行回数で変わる性質ではありません。100回まわしても、同じ Method not found が100個たまるだけです。1回で足りると判断しました。
この結果から言えること、言えないこと
言えることは、はっきりしています。
Claude Code 2.1.283を使っている限り、「サーバーがSamplingでクライアントのLLMを呼ぶ」経路は存在しません。 悪意のあるMCPサーバーをうっかり繋いでしまっても、Samplingを足がかりにした権限昇格は、この経路については成立しません。入口がないからです。
ただ、安心していいのはSamplingの経路についてだけです。ここを取り違えると危険なので、言えないことも同じだけ並べておきます。
- プロンプトインジェクションは別の経路から届きます。 ツールの説明文やツールの戻り値に仕込まれた指示は、Samplingとは無関係にLLMへ渡ります。玄関を閉めても、窓はまだ開いています。こちらは別の対策が要ります。
- Claude Desktopについては何も言えません。 私の環境になく、測っていないからです。測っていないものを「たぶん同じ」で語ると、この記事が直そうとしている過ちそのものになります。
- Sampling対応を謳うクライアントの安全性も言えません。 対応を宣言しているクライアントでは、冒頭で挙げたリスクがそのまま残ります。
- 測ったのはClaude Codeの1バージョンです。将来のバージョンで対応する可能性はあります。
「危ない」と「自分の環境で危ない」は別の話です。前者は仕様を読めば分かりますが、後者は繋いでみないと分かりません。
サーバーを作る人へ:呼ぶ前にcapabilitiesを見る
ここまでは「攻撃される側」の視点でしたが、Samplingを使うサーバーを書く側にも、同じ測定がそのまま効きます。
Samplingに依存した機能を実装するなら、送る前にクライアントのcapabilitiesを確認してください。宣言のないクライアントに送っても、今回のように Method not found で落ちるだけです。ユーザーには「なぜか動かないサーバー」に見えます。
params = ctx.session.client_params
if params is None or params.capabilities.sampling is None:
# Sampling非対応のクライアント。LLM APIを直接呼ぶか、機能を落とす
...
else:
# 対応している。ここで初めてcreate_messageを呼ぶ
...
判断の流れにすると、こうなります。
ついでに書いておくと、Samplingは仕様の 2026-07-28 版で Roots・Logging とともに非推奨になりました。ただし、非推奨は「削除」とは違います。機能ライフサイクル方針では、非推奨から削除まで最低12か月空けることになっていて、Samplingが仕様から消えるのは最速でも2027年の夏以降です。「もう無い機能」ではなく、「新規には使うな、既存は移行せよ」という段階です。だからこそ、既存の環境で使われている可能性は当面残ります。非推奨の理由としては、実装が複雑な割に対応クライアントが少ないことが挙げられています。今回の測定結果は、その説明とよく噛み合います。移行先として案内されているのは、サーバーがLLMプロバイダのAPIを直接呼ぶ形です。上のコードの「LLM APIを直接呼ぶ」分岐は、非推奨を見越した書き方でもあります。
まとめ
MCPのSamplingは、セキュリティの文脈では「権限昇格に使える危ない機能」として語られます。それ自体は間違っていません。ただ、攻撃経路の本数を数える前に、自分のクライアントがその経路を持っているかを測ると、話の順番が変わります。
- Claude Code 2.1.283 はSamplingを宣言せず、
sampling/createMessageはMethod not foundで返る - このクライアントではSampling経由の攻撃経路が存在しない
- ただしプロンプトインジェクションは別経路なので、安心はSamplingに限る
- Claude Desktopや他クライアントは、それぞれ繋いで測るしかない
確認用のサーバーは30行ほどで、自分の使っているクライアントにそのまま繋げます。仕様書を10回読むより、1回繋いで Method not found を見るほうが早い、というのが今回いちばんの収穫でした。みなさんの環境では、どのcapabilitiesが返ってきますか。もし sampling が入っていたら、それはそれで別の記事が1本書けそうです。
MCPのSamplingの仕組みとリスク、クロスサーバーでのコンテキスト悪用、そして「仕様上は危ないが自分の環境ではどうか」を切り分ける考え方は、拙著『MCP実践セキュリティ AIエージェント時代のModel Context Protocol安全運用ガイド』(インプレス)で扱っています。各章の内容と購入先は MCP実践セキュリティ の紹介ページ にまとめてあります。
