MCPセキュリティテストの実践:自社MCPサーバーを検証する34項目のテスト計画とプローブ【MCP Security 2026-07-28版】
自社のMCPサーバーをリリース前とCIで検証するためのMCPセキュリティテスト。2026-07-28版仕様にもとづく期待応答(401、insufficient_scopeの403、Originの403、-32020)付きの34項目、仕込んだ4つのバグを誤検出なしで見つけたプローブ、ツール比較、CI設定、実際の事例。
最終更新:2026年10月27日 / 対象:MCP仕様 2026-07-28版(Authorization、Streamable HTTP、Security Best Practices)、RFC 6750、RFC 8707、RFC 9728
結論(30秒で読める要約)
MCP Security(MCPセキュリティ)の実務でいう「セキュリティテスト」とは、自分たちが運用するMCPサーバーが「仕様上拒否しなければならないもの」を本当に拒否しているかを、繰り返し実行できるテストで確かめることです。対象は、トークンなしや他サービス向けのトークン、スコープ外の呼び出し、別サイトからのリクエスト、ヘッダーと本文の食い違い、他ユーザーの状態、危険なツール引数などです。チェックリストは「何を設定するか」を教えてくれますが、デプロイのたびにその設定が効いているかを確かめるのはテストの役割です。
この記事で手に入るもの:
- 7分野・34項目のテスト計画。各項目に、2026-07-28版仕様にもとづく期待される応答を付けています(3章)。
- そのうち16項目を自動で確認する、標準ライブラリだけのプローブ(検証済み)。わざと4つのバグを入れたサーバーでは12/16となり、その4つだけを検出しました。修正版では16/16です(4章)。
- MCP Inspector、公式のconformanceスイート、Promptfoo、自作プローブがそれぞれどの層をカバーするかの比較。どれか1つで全部はカバーできません(5章)。
- CIに組み込むジョブ例と、再テストが必要になる変更の一覧(6章)。
目安は「まずモデルではなくサーバーをテストする」ことです。7章の事例の多くは、普通の認証・認可のバグで、HTTPのブラックボックステストなら数秒で見つかる種類のものでした。プロンプトインジェクションのテストは、それらに合格してから行うもので、代わりにはなりません。
目次
- MCP Securityにおけるセキュリティテストとは
- 何をテストするか:仕様と対応づけた7分野
- テスト計画:34項目と期待される応答
- 検証:16項目のブラックボックス・プローブ
- ツール比較:それぞれ何を見つけられるか
- CIでの実行
- 実際の事例と、それを見つけられたテスト
- チェックリスト(12項目)
- よくある失敗(症状→原因→対処)
- NodeFlareならこう解決できる
- よくある質問
1. MCP Securityにおけるセキュリティテストとは
MCPのセキュリティテストとは、自分が所有するMCPサーバーに、細工はしてあるが無害なリクエストを送り、その応答をMCP仕様が求める動作と比べる作業です。AIのレッドチーミングよりも、APIのセキュリティテストに近いものです。PromptfooのMCPガイドも、MCPサーバーを直接テストすることを「本質的にはAPIセキュリティテスト」と説明し、認証・認可・入力検証を中心に据えています。
テストには3つの層があり、それぞれ方法が違います。
| 層 | 確かめたいこと | 方法 | モデルが必要か |
|---|---|---|---|
| プロトコル・HTTP | エンドポイントがトークン、スコープ、Origin、ヘッダーのルールを守っているか | HTTPのブラックボックステスト(4章)、conformanceスイート | 不要 |
| ツールのロジック | 各ツールが引数を検証し、データの持ち主を確認しているか | ツールごとの単体テスト・結合テスト | 不要 |
| エージェントの振る舞い | ツールの結果や説明文に含まれる文章で、エージェントが誤った操作に誘導されないか | 実際のクライアント経由のレッドチーミング(Promptfooなど) | 必要 |
この記事は、自分たちが運用しているサーバー(たとえば、社内SaaSをAPIトークン付きでMCPとして公開しているケース)を対象にしています。他社のサーバーを信頼してよいかを判断する場合は、別途サードパーティ製サーバーの審査手順を使ってください。ここではサーバーのコードを読んで直せることを前提にしています。
2. 何をテストするか:仕様と対応づけた7分野
テスト項目はすべて仕様の規定にさかのぼれるようにしておきます。「期待される応答」が担当者の好みで決まらないようにするためです。
| 分野 | 仕様の規定(2026-07-28版) | 出典 |
|---|---|---|
| 認証 | サーバーはアクセストークンを検証し、自分宛て(audience)に発行されたものかを確認しなければならない(MUST)。無効または期限切れのトークンにはHTTP 401を返す(MUST)。トークンをURLのクエリ文字列に入れてはならない(MUST NOT) | Authorization |
| ディスカバリ | MCPサーバーはOAuth 2.0 Protected Resource Metadata(RFC 9728)を実装しなければならない(MUST) | Authorization |
| 認可 | スコープ不足の場合は403と、error="insufficient_scope"、必要なscope、resource_metadataを返すべき(SHOULD)。サーバーはスコープの階層(広いスコープが狭いスコープを含む)を考慮しなければならない(MUST) |
Authorization:Scope Challenge Handling |
| トランスポート | サーバーはOriginヘッダーを検証し、存在して不正な場合は403を返す(MUST)。ヘッダーと本文が一致しない場合は400とエラーコード-32020(HeaderMismatch)を返す(MUST) |
Streamable HTTP |
| 状態 | 状態ハンドルを持っていることを認証とみなしてはならない(MUST NOT)。ハンドルは検証済みトークンのユーザーに結びつけるべき(SHOULD) | Security Best Practices:State Handle Hijacking |
| トークンの扱い | サーバーは他のトークンを受け入れたり、中継したりしてはならない(MUST NOT) | Authorization、Security Best Practices:Token Passthrough |
| スコープ設計 | ワイルドカードのスコープや、トークン上のスコープだけを見てサーバー側の認可ロジックを省くことを避ける | Security Best Practices:Scope Minimization |
2026-07-28版では、テスト内容に関わる変更が2つあります。1つは、プロトコルレベルのセッションが廃止されたことです。サーバーはMcp-Session-Idヘッダーを無視し、新たに発行もせず、MCPエンドポイントへのGETやDELETEには405を返すのが正しい動作です。もう1つは、すべてのPOSTにMCP-Protocol-VersionとMcp-Methodが付き、tools/call、resources/read、prompts/getではさらにMcp-Nameが付くようになったことです。サーバーはこれらを本文と照合しなければなりません。2025年版の「セッション乗っ取り」のテストはもう当てはまらず、代わりにヘッダーと本文の不一致のテストが必要になります。
3. テスト計画:34項目と期待される応答
自分で管理しているテスト用アカウントを使い、ステージング環境に対して実行します。必要な資格情報は5つです:ユーザーAの全スコープ付きトークン、ユーザーAの読み取り専用トークン、ユーザーBの読み取り専用トークン(できれば別テナント)、期限切れトークン、そして別のリソース向けに発行された有効なトークン。IDに「*」が付いた項目は、4章のプローブで自動化しています。
認証(A)
| ID | テスト | 期待される応答 |
|---|---|---|
| A1* | Authorizationヘッダーなしでリクエスト |
401とWWW-Authenticate: Bearer resource_metadata="..." |
| A2* | 形式が崩れたトークン、でたらめなトークン | 401 |
| A3* | 期限切れのトークン | 401 |
| A4* | 別のaudience向けに発行された有効なトークン | 401。最も抜けやすいテスト |
| A5* | ヘッダーではなく?access_token=で渡した有効なトークン |
401。クエリ文字列は読まない |
| A6* | Protected Resource MetadataのURLにGET | 200。resourceがMCPエンドポイントと一致し、authorization_serversがある |
| A7 | トークンやAPIキーを失効させてから再利用 | 自社で定めた失効までの時間内に拒否される |
認可・スコープ(Z)
| ID | テスト | 期待される応答 |
|---|---|---|
| Z1* | 読み取り専用トークンで書き込み系ツールを呼ぶ | 403、error="insufficient_scope"、scope="<必要なスコープ>" |
| Z2* | 読み取り専用トークンでtools/list |
スコープでリストを絞り込んでいる場合、書き込み系ツールが表示されない |
| Z3* | ユーザーBが、ユーザーAの状態ハンドル(下書きID、ジョブIDなど)を渡す | 見つからない、または拒否。Aのデータは返さない |
| Z4 | ユーザーBが、読み取り系ツールでユーザーAのオブジェクトをIDで読む(オブジェクト単位の認可) | 拒否。同じテナントの2人ではなく、2つのテナントで試す |
| Z5 | 対応するスコープのないトークンでresources/readやprompts/get |
tools/callと同じく拒否 |
| Z6 | 階層上の上位スコープ(例:tickets:admin)を持つトークン |
仕様どおり、下位の操作は許可される |
| Z7 | 2つのスコープが必要な操作 | 403のチャレンジ1回で両方を返す。1つずつ小出しにしない |
トランスポート(T)
| ID | テスト | 期待される応答 |
|---|---|---|
| T1* | 有効なトークンにOrigin: https://evil.exampleを付ける |
403 |
| T2* | Mcp-Nameヘッダーがparams.nameと異なる |
400とJSON-RPCエラー-32020 |
| T3* | MCP-Protocol-Versionヘッダーがない |
400(2025-06-18より前のクライアントを意図的にサポートしている場合を除く) |
| T4* | MCPエンドポイントへのGET | 405 |
| T5* | Mcp-Session-Id付きのリクエスト |
ヘッダーは無視され、応答にも返らない |
| T6 | 本番ホスト名へ平文のhttp://で接続 |
平文ではMCPの応答を返さない。リダイレクトするか拒否する |
ツールの入力(I)
このグループは、ツールごとの単体テストに入れます。どの引数が危険かは、そのツールを書いた人にしか分からないからです。テスト名にはカテゴリを書き、具体的な入力値は社内のテストコードにだけ置いてください。
| ID | テスト | 期待される応答 |
|---|---|---|
| I1 | 型の違う引数、必須項目の欠落、未定義のプロパティ | 副作用が起きる前に検証エラー |
| I2 | 許可したルートの外を指すパス引数(相対パス、シンボリックリンク、絶対パス) | パスを正規化したうえで拒否 |
| I3 | シェルの特殊文字を含む引数 | データとして扱われる。コマンドは文字列ではなく引数の配列で組み立てる |
| I4 | プライベートアドレス、ループバック、169.254.169.254を指すURL引数 |
リダイレクト後も含めて拒否 |
| I5 | 巨大な引数(例:10 MBの文字列) | きちんとエラーを返し、クラッシュやタイムアウトの連鎖を起こさない |
エラーとログ(E)
| ID | テスト | 期待される応答 |
|---|---|---|
| E1* | 内部エラーを起こす(引数の欠落など) | 一般的なメッセージだけを返す。スタックトレース、キー、トークンを本文に含めない |
| E2* | 存在しないツールを呼ぶ | HTTP 500ではなくJSON-RPCのエラー |
| E3 | AとZのテストの後で監査ログを見る | 拒否された呼び出しごとに、呼び出し元、ツール、結果が1行ずつ記録されている |
| E4 | 送ったテストトークンや引数の値でログを検索 | トークンは出てこない。引数の値は意図した場所にしか残らない |
外向き通信(N)
| ID | テスト | 期待される応答 |
|---|---|---|
| N1 | サーバーの実行環境の中から、許可リストにないホストへ接続 | ブロックされる。少なくとも記録される |
| N2 | 実行環境の中から、クラウドのメタデータアドレスへアクセス | ブロックされる |
モデル側(M)
| ID | テスト | 期待される応答 |
|---|---|---|
| M1 | tools/listのツール名・説明文・スキーマのハッシュを、承認済みのスナップショットと比べる |
未レビューの変更はリリースされない |
| M2 | 実際のクライアント経由で、書き込み系ツールを呼ぶよう指示する文章を含むツール結果を返す | ユーザーの承認なしにエージェントがそのツールを呼ばない |
| M3 | 利用者が使う各クライアントで、破壊的なツールの挙動を確認 | クライアントが確認を求める。サーバー側はdestructiveHintだけに頼らない |
M2とM3はサーバーではなく、クライアントとモデルを測るテストです。合格しても「そうだった」という証拠であり、安全の証明ではありません。MCPのToolsのページでも、人間が介在する確認(human in the loop)はSHOULD(推奨)にとどまっています。これらの対策の設定面は、MCPセキュリティのベストプラクティスとチェックリストにまとめています。
4. 検証:16項目のブラックボックス・プローブ
次のスクリプトは「*」の付いた項目を自動化したものです。必要なのはPython 3と、自社の認可サーバーで発行した5つのトークンだけです。2026-07-28版のリクエスト形式(リクエストごとの_meta、MCP-Protocol-Version・Mcp-Method・Mcp-Nameヘッダー)で通信し、1つでも失敗すると終了コード1を返すので、そのままCIに組み込めます。
#!/usr/bin/env python3
"""mcp_sec_probe.py - black-box security checks for YOUR OWN Streamable HTTP MCP server
(MCP 2026-07-28). Standard library only. Run against staging, never against servers you do not own.
usage: python3 mcp_sec_probe.py https://staging.example.com/mcp tokens.json
tokens.json: {"full_a": "...", "read_a": "...", "read_b": "...", "expired": "...", "other_aud": "..."}
full_a user A, all scopes read_a user A, read-only scope
read_b user B, read-only scope expired an expired token from your AS
other_aud a valid token issued for a DIFFERENT resource (audience)
Edit WRITE_TOOL / READ_TOOL / HANDLE_* below to match your server."""
import json, re, sys, urllib.error, urllib.parse, urllib.request
PV = "2026-07-28"
WRITE_TOOL, READ_TOOL = "close_ticket", "list_tickets"
HANDLE_CREATE, HANDLE_USE, HANDLE_ARG = "create_draft", "get_draft", "handle"
LEAK = re.compile(r"Traceback|File \".*\", line|at [\w.$]+\(.*:\d+\)|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|eyJ[\w-]{10,}\.")
def rpc(method, params=None, rid=1):
p = dict(params or {})
p["_meta"] = {"io.modelcontextprotocol/protocolVersion": PV,
"io.modelcontextprotocol/clientInfo": {"name": "sec-probe", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {}}
return {"jsonrpc": "2.0", "id": rid, "method": method, "params": p}
def send(url, msg=None, token=None, method="POST", headers=None, drop=()):
h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if msg:
h.update({"MCP-Protocol-Version": PV, "Mcp-Method": msg["method"]})
if msg["method"] == "tools/call":
h["Mcp-Name"] = msg["params"]["name"]
if token:
h["Authorization"] = f"Bearer {token}"
h.update(headers or {})
for k in drop:
h.pop(k, None)
data = json.dumps(msg).encode() if msg else None
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, dict(r.headers), r.read().decode(errors="replace")
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read().decode(errors="replace")
def call(tool, args=None):
return rpc("tools/call", {"name": tool, "arguments": args or {}})
def text(body):
try:
res = json.loads(body).get("result", {})
return res.get("isError", False), " ".join(c.get("text", "") for c in res.get("content", []))
except Exception:
return True, ""
def run(url, t):
base = urllib.parse.urlsplit(url)
prm = f"{base.scheme}://{base.netloc}/.well-known/oauth-protected-resource"
checks = []
def check(cid, name, ok, detail=""):
checks.append((cid, name, ok, detail))
s, h, _ = send(url, rpc("tools/list"))
www = {k.lower(): v for k, v in h.items()}.get("www-authenticate", "")
check("A1", "no token -> 401 with resource_metadata", s == 401 and "resource_metadata=" in www, f"{s}")
s, *_ = send(url, rpc("tools/list"), token="not-a-token")
check("A2", "garbage token -> 401", s == 401, f"{s}")
s, *_ = send(url, rpc("tools/list"), token=t["expired"])
check("A3", "expired token -> 401", s == 401, f"{s}")
s, *_ = send(url, rpc("tools/list"), token=t["other_aud"])
check("A4", "token for another audience -> 401", s == 401, f"{s}")
s, *_ = send(url + "?access_token=" + t["full_a"], rpc("tools/list"))
check("A5", "token in query string is ignored -> 401", s == 401, f"{s}")
s, _, b = send(prm, method="GET")
try:
ok = s == 200 and json.loads(b).get("resource", "").rstrip("/") == url.rstrip("/")
except ValueError:
ok = False
check("A6", "Protected Resource Metadata names this server", ok, f"{s}")
s, h, b = send(url, call(WRITE_TOOL), token=t["read_a"])
www = {k.lower(): v for k, v in h.items()}.get("www-authenticate", "")
check("Z1", "read-only token on write tool -> 403 insufficient_scope", s == 403 and "insufficient_scope" in www, f"{s}")
s, _, b = send(url, rpc("tools/list"), token=t["read_a"])
names = [x["name"] for x in json.loads(b).get("result", {}).get("tools", [])] if s == 200 else []
check("Z2", "tools/list hides tools the token cannot call", WRITE_TOOL not in names, f"{names}")
s, _, b = send(url, call(HANDLE_CREATE), token=t["read_a"])
handle = text(b)[1].strip()
s, _, b = send(url, call(HANDLE_USE, {HANDLE_ARG: handle}), token=t["read_b"])
err, out = text(b)
check("Z3", "user B cannot use user A's state handle", s >= 400 or err, out[:40])
s, *_ = send(url, rpc("tools/list"), token=t["read_a"], headers={"Origin": "https://evil.example"})
check("T1", "foreign Origin -> 403", s == 403, f"{s}")
s, _, b = send(url, call(READ_TOOL), token=t["read_a"], headers={"Mcp-Name": WRITE_TOOL})
check("T2", "Mcp-Name header != body -> 400 / -32020", s == 400 and "-32020" in b, f"{s}")
s, *_ = send(url, rpc("tools/list"), token=t["read_a"], drop=("MCP-Protocol-Version",))
check("T3", "missing MCP-Protocol-Version -> 400", s == 400, f"{s}")
s, *_ = send(url, method="GET", token=t["read_a"])
check("T4", "GET on MCP endpoint -> 405", s == 405, f"{s}")
s, h, _ = send(url, rpc("tools/list"), token=t["read_a"], headers={"Mcp-Session-Id": "probe-123"})
check("T5", "Mcp-Session-Id is not echoed", "mcp-session-id" not in {k.lower() for k in h}, "")
s, _, b = send(url, call(HANDLE_USE, {}), token=t["read_a"])
check("E1", "errors leak no stack trace, key or token", not LEAK.search(b), b[:60].replace("\n", " "))
s, _, b = send(url, call("no_such_tool_x"), token=t["read_a"])
check("E2", "unknown tool -> error, not 500", s != 500 and ("error" in b.lower()), f"{s}")
for cid, name, ok, detail in checks:
print(f"{'PASS' if ok else 'FAIL'} {cid:3} {name:58} {detail}")
print(f"\n{sum(c[2] for c in checks)}/{len(checks)} passed")
return all(c[2] for c in checks)
if __name__ == "__main__":
sys.exit(0 if run(sys.argv[1], json.load(open(sys.argv[2]))) else 1)
2026年10月27日に、Python 3.13で、2026-07-28版のリクエスト規則を実装した検証用サーバーに対して実行しました。検証用サーバーには、4つのバグ(audienceを確認しない、Originを確認しない、状態ハンドルをユーザーに結びつけない、エラーにスタックトレースを含める)をわざと入れたnaive版と、それらを直したhardened版があります。
| 確認項目 | naive版 | hardened版 |
|---|---|---|
| A1〜A3、A5、A6 | 合格 | 合格 |
| A4 別のaudience向けのトークン | 不合格(200) | 合格(401) |
| Z1、Z2 | 合格 | 合格 |
| Z3 ユーザーBがAのハンドルを使う | 不合格(Aの下書きを返した) | 合格(見つからない) |
| T1 外部のOrigin | 不合格(200) | 合格(403) |
| T2〜T5 | 合格 | 合格 |
| E1 エラーからの情報漏れ | 不合格(本文にPythonのトレースバック) | 合格 |
| E2 | 合格 | 合格 |
| 合計 | 12/16、終了コード1 | 16/16、終了コード0 |
結果から分かったこと:
- 仕込んだバグはすべて検出し、それ以外は1つも誤検出しませんでした。このサーバーでの誤検出はゼロで、だからこそCIで「失敗したら止める」ステップにできます。
- audienceのバグは、ほかの認証テストをすべてすり抜けました。署名と有効期限を確認しているサーバーは、別のリソース向けに発行された本物のトークンを出すまで健全に見えます。そのトークンは、2つ目のテスト用リソースを認可サーバーに登録して発行してください。手作りのトークンでは、自分のパーサーをテストしているだけになります。
- ハンドルのバグは、正常な機能のように見えました。naive版はBobに対してHTTP 200で「draft of alice」を返しました。この種のバグは、2つの身元を使うテストでしか見えません。
限界:合格は「この16の動作が正しい」という意味で、サーバーが安全だという意味ではありません。ツールの入力(I)、外向き通信(N)、モデルの振る舞い(M)は確かめません。Z2は、スコープでtools/listを絞り込んでいる場合にだけ意味があります。ゲートウェイがスコープ不足を403ではなくJSON-RPCエラーで返す場合は、Z1の判定をその形に合わせてください。
5. ツール比較:それぞれ何を見つけられるか
| ツール | 層 | できること | できないこと |
|---|---|---|---|
MCP Inspector(@modelcontextprotocol/inspector、2026年10月時点のnpm最新は2.12.0) |
プロトコル(手動) | すべてのツール、説明文、スキーマを一覧し、手でツールを呼べる | 自動化はできない。最新版を使う(CVE-2025-49596は0.14.1未満が対象、CVSS 3.1で9.8) |
MCP conformanceスイート(@modelcontextprotocol/conformance、0.1.16) |
プロトコル(自動) | server --url <url>で仕様のシナリオをサーバーに対して実行。既知の失敗を記録するベースラインとGitHub Actionがある |
仕様への準拠を見るもので、自社のスコープ、テナント、ツールのロジックは分からない。リポジトリの説明では、認可サーバーのシナリオは対象外 |
| Promptfoo(MCPを直接呼ぶプロバイダー) | ツールのロジック | モデルを介さずツールを呼び、bola、bfla、sql-injection、piiなどのプラグインで認可と入力を調べる |
ツールごとの設定が必要。結果は自社のポリシー次第 |
| Promptfoo(クライアント経由のレッドチーム) | エージェントの振る舞い | サーバーにつないだエージェントに、マルチターンやジェイルブレイクの手法を試す | モデルとクライアントを測るので、モデルのバージョンで結果が変わる |
| 自作のプローブ(4章) | プロトコルと認可 | 2つの身元、別audienceのトークン、Originなど、自社環境に合わせたテスト | 書いたことしか確かめない |
| ツールごとの単体テスト | ツールのロジック | パス、URL、サイズ、型の検証(I1〜I5) | HTTP層は何も見ない |
現実的な組み合わせは、プルリクエストごとにconformanceスイートと自作プローブ、同じパイプラインでツールの単体テスト、Promptfooのレッドチームは定期実行か大きなリリースの前、ツール定義が変わったときはInspectorで目視確認、です。
6. CIでの実行
ステージングへのデプロイが終わるたびに、新しいバージョンへトラフィックを移す前にプローブを実行します。最小構成のGitHub Actionsは次のとおりです。
name: mcp-security-tests
on:
workflow_run:
workflows: ["deploy-staging"]
types: [completed]
jobs:
probe:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Write test tokens from secrets
run: echo '${{ secrets.MCP_TEST_TOKENS_JSON }}' > tokens.json
- name: Black-box probe
run: python3 tests/mcp_sec_probe.py "${{ vars.STAGING_MCP_URL }}" tokens.json
- name: Remove tokens
if: always()
run: rm -f tokens.json
テスト用トークンは有効期限を短くし、テスト用テナントだけに限定し、ほかのCI用資格情報と同じ周期でローテーションします。無停止デプロイをしているなら、このジョブをカナリアから全体展開へ進む前の関門にします。関門を置く位置はMCPサーバーの無停止デプロイの記事で説明しています。
次の変更があったときは、プローブだけでなく計画全体をやり直します。
| きっかけ | やり直すテスト |
|---|---|
| ツールの追加、ツールのスコープ変更 | Zグループ、そのツールのIグループ、M1 |
| 認可サーバー、IdP、トークン形式の変更 | Aグループ、Z1、Z6 |
| ゲートウェイ、プロキシ、CDN、ロードバランサーの変更 | Tグループ、A5、E1 |
| MCP SDKやプロトコルのバージョンアップ | Tグループ、conformanceスイート |
| テナントの構造やデータの分け方の変更 | Z3、Z4 |
7. 実際の事例と、それを見つけられたテスト
| 事例 | 何が起きたか(出典による) | 対応するテスト |
|---|---|---|
| AsanaのMCPサーバー(2025年) | ロジックの欠陥により、一部のデータが他の組織のユーザーから見える状態になった。BleepingComputerは、影響を受けた顧客を約1,000社とするAsanaの見積もりを報じている | 2つのテナントでのZ3、Z4 |
| MCP Inspector(CVE-2025-49596) | 0.14.1未満では、Inspectorのクライアントとプロキシの間に認証がないためにリモートでコードを実行される状態だった(CVSS 3.1:9.8) | 開発用ツールも含め、ローカルで待ち受けるすべてのHTTPサーバーに対するA1とT1 |
| mcp-remote(CVE-2025-6514) | 信頼できないサーバーに接続したとき、細工されたauthorization_endpointによってOSコマンドを実行される脆弱性(CVSS 3.1:9.6) |
クライアント側:Security Best Practicesが求めるとおり、HTTPS以外や危険なURLスキームを拒否するかをテストする |
3つに共通するのは、バグがAI特有の部分ではなく、ごく普通の認証、認可、URLの扱いにあったことです。この計画でA、Z、Tのグループを最初に置いているのはそのためです。
8. チェックリスト(12項目)
リリース前
- [ ] 別audience向けのトークンと2つ目のテナントを含む、5つのテスト用資格情報がある
- [ ] ステージングでA1〜A7に合格した
- [ ] Z1〜Z7に合格し、Z3とZ4は2つのテナントで実行した
- [ ] 本番と同じプロキシとCDNを通してT1〜T6に合格した
- [ ] 各ツールに、その引数に当てはまるI1〜I5の単体テストがある
- [ ] E3とE4を確認した:拒否は記録され、トークンは記録されない
CI
- [ ] ステージングへのデプロイのたびにプローブが実行され、失敗すると展開が止まる
- [ ] conformanceスイートを、既知の失敗リストとともに実行している。リストは増やすだけでなく見直している
- [ ] ツール定義のハッシュを承認済みのスナップショットと比べている(M1)
定期実行
- [ ] 実行環境の中から外向き通信のテスト(N1、N2)を行っている
- [ ] 利用者が実際に使う各クライアントでレッドチーム(M2)を行っている
- [ ] 6章のきっかけがあるたびに計画全体をやり直している
9. よくある失敗(症状→原因→対処)
| 症状 | 原因 | 対処 |
|---|---|---|
| 認証テストはすべて合格なのに、別アプリのトークンが通る | 別audience向けに発行された本物のトークンでテストしていない | 2つ目のテスト用リソースを作り、認可サーバーで発行する(A4) |
| 認可テストは合格だったのに、テナントをまたぐ漏えいが起きた | 2人のテストユーザーが同じテナントにいた | 別々のテナントのユーザーでZ3、Z4を実行する |
| Originのテストがローカルでは合格、本番では不合格 | CDNやプロキシがOriginを削除・書き換えている |
本番と同じ経路でTグループを実行する |
| プローブのリクエストがすべて400になる | MCP-Protocol-VersionやMcp-Methodを送っていない |
2026-07-28版のヘッダーを送るか、プローブのPVを提供中のバージョンに合わせる |
| テストは合格なのに、実ユーザーが403を繰り返す | スコープのチャレンジが1つずつ小出しになっている | 必要なスコープをすべて1回のチャレンジで返す(Z7) |
| レッドチームの結果が毎週変わる | 結果がモデルのバージョンに左右される | Mのテストは監視として扱い、リリースの判定はA、Z、T、I、Eで行う |
| CIのログにテストトークンが出ている | トークンのJSONをechoで表示したり、引数で渡したりしている | シークレットからファイルに書き出し、マスクし、always()のステップで削除する |
10. NodeFlareならこう解決できる
NodeFlareは、MCPサーバーをプロキシの後ろでホスティングし、リクエストがあなたのコードに届く前に認証・認可・レート制限・記録を行います。テストを書いたり実行したりはしませんし、プロンプトインジェクションも防ぎません。変わるのは、上で挙げた失敗のうち、自分のコードで防がなければならないものの数です。NodeFlareのドキュメントに記載されている機能と、各テストグループの対応は次のとおりです。
| この記事のテスト | NodeFlareの機能 | 内容 |
|---|---|---|
| A2、A7、総当たり | ロックアウト付きのmcp_* APIキー |
キーはハッシュ化して保存。同じIPから誤ったキーが繰り返されると、そのIPを10分間ロック |
| A1、A6(OAuthのディスカバリ) | ディスカバリ用メタデータ付きのOAuth | プロキシがRFC 8414とRFC 9728のメタデータを提供し、標準的なMCPクライアントが認可フローを見つけられる |
| Z1、Z2、Z5 | ツール単位のスコープと一覧の絞り込み | tools:call:<ツール名>、resources:read:<uri>、prompts:get:<名前>などのスコープをプロキシで確認。拒否した呼び出しにはJSON-RPCエラーを返し、サーバーには届かない。tool_list_filter_by_scopeで、tools/listを呼び出せるツールだけに絞る |
| 想定外のネットワークからの呼び出し | 送信元IPの許可リスト | restrictedのネットワークポリシーで、IPv4/IPv6のCIDRを指定。認証より前、VMの起動より前にプロキシで適用し、リストが空ならすべて拒否。ネットワーク制限はProプラン以上に含まれる |
| I5、大量のリクエスト | レート制限と上限 | IP単位・1分単位のレート制限と、ワークスペース単位の月間上限。月間の利用上限に達するとHTTP 402を返す |
| E3、E4 | リクエストログ | リクエストごとにmcp_method、tool_name、arg_keys(値ではなく引数名だけ)、http_status、outcome(scope_denied、rate_limitedなど)、caller_kind、client_ipを記録。保存期間はFreeで7日、ProとTeamで30日 |
| E1(出力へのシークレット混入) | 暗号化されたシークレット | 環境変数は保存時に暗号化され、VMの起動時にだけ注入される。ビルドログでは既知の形式のトークンを伏せ字にする |
| N1、N2 | Egress制御 | モードはoff(初期値)、observe、enforce。最大100件(完全なホスト名、先頭1つだけのワイルドカード(例:*.example.com)、IPアドレス)を、ホスト側のDNSとnftablesで強制 |
| 侵害の横展開 | サーバーごとのFirecracker microVM | 各サーバーが専用のmicroVMで動く |
注意点が3つあります。1つ目に、NodeFlareのドキュメントにはOriginの検証やヘッダーと本文の照合についての記載がないため、T1〜T5は自分のサーバーで実装し、NodeFlareのURL経由でテストしてください。2つ目に、auth_enabled = falseにすると呼び出し元の資格情報がそのままサーバーに渡り、NodeFlareのスコープ確認はその呼び出し元には適用されなくなります。その場合、AとZのテストは再び自分のサーバーの責任になります。3つ目に、Z3、Z4、Iグループはツールのロジックの問題です。どの下書きが誰のものかは、プロキシには分かりません。
最小の始め方:
- サーバーをデプロイし、
tools:call:<読み取り系ツール>だけを持つmcp_*キーと、全ツールを呼べるキーを1つずつ発行して、プローブの読み取り専用トークンと全権限トークンとして使う。 - NodeFlareのURLに対してプローブを実行する。Z1はプロキシで拒否され、リクエストログに
outcomeがscope_deniedの行が出るはずです。 - Egressを
observeにしてツールのテストを実行し、見えた接続先を許可リストに入れてからenforceに切り替える。
1サーバーまで無料で試せます:NodeFlareを始める
11. よくある質問
MCPサーバーのセキュリティはどうやってテストすればいいですか?
自分で管理するトークンを使い、ステージング環境にブラックボックステストを行います。トークンなし、期限切れ、別audience向け、読み取り専用トークンでの書き込み、他ユーザーの状態ハンドル、外部のOrigin、ヘッダーの不一致を試し、応答を仕様(401、403、-32020付きの400)と比べます。そのうえで、ツールごとの引数の単体テストと、実際のクライアント経由のレッドチームを加えます。
無効なトークンにはどのステータスコードを返すべきですか?
401です。2026-07-28版のAuthorization仕様は、無効または期限切れのトークンにHTTP 401を返すことをMUSTとし、Protected Resource Metadataを示すWWW-Authenticateヘッダーを付けます。トークンは有効でもスコープが足りない場合は、error="insufficient_scope"付きの403を返します。
MCPのセキュリティテストとレッドチーミングの違いは?
セキュリティテストは、トークンの検証、スコープ、ヘッダーの規則など、HTTP上で決まった動きをするサーバーの振る舞いを確かめるもので、モデルは不要です。レッドチーミングは、クライアントとモデルに攻撃的なプロンプトを送り、エージェントが誤った操作に誘導されるかを見ます。結果が安定していてリリースを止められるセキュリティテストを先に行います。
MCP Inspectorはセキュリティテストに使えますか?
手動のレビューには使えます。すべてのツール、説明文、スキーマを一覧でき、手でツールを呼べます。ただし自動化はできないので、スクリプトのプローブやconformanceスイートと組み合わせます。CVE-2025-49596は0.14.1未満のInspectorが対象だったので、常に最新版を使ってください。
公式のMCP conformanceスイートはセキュリティをテストしますか?
一部だけです。@modelcontextprotocol/conformanceはサーバーやクライアントが仕様に従っているかを確かめるもので、リソースサーバーとクライアントの役割について認証のシナリオがあります。自社のスコープ、テナント、ツールのロジックは知らないため、テナントをまたぐ漏えいや持ち主の確認漏れは見つけられません。
MCPの認可スコープはどうテストしますか?
スコープの組み合わせごとにトークンを1つずつ発行し、すべてのツール、リソース、プロンプトを各トークンで呼び、許可された呼び出しだけが成功することを確かめます。拒否がinsufficient_scope付きの403で、必要なスコープが1回のチャレンジにすべて入っていること、スコープで絞り込んでいる場合は呼べないツールがtools/listに出ないことも確認します。
MCPサーバーのDNSリバインディング対策はどうテストしますか?
それ以外は正しいリクエストに、サーバーが許可していないOriginヘッダーを付けて送ります。Streamable HTTPの仕様では、サーバーはOriginを検証しなければならず、存在して不正な場合は403を返します。途中の機器がヘッダーを削除することがあるため、本番と同じプロキシやCDNを通して試してください。
MCPのセキュリティテストはCIで実行すべきですか?
はい。ブラックボックスのプローブとconformanceスイートは速く、結果も毎回同じなので、ステージングへのデプロイのたびに実行し、失敗したら展開を止めます。モデルを使うレッドチームは、結果がモデルのバージョンで変わるため、定期実行にします。
MCPの-32020エラーとは何ですか?
-32020はHeaderMismatchです。2026-07-28版のStreamable HTTPでは、MCP-Protocol-Version、Mcp-Method、Mcp-Name、Mcp-Param-*のヘッダーが欠けている、形式が崩れている、または本文と一致しない場合、サーバーは400とこのJSON-RPCエラーを返さなければなりません。
MCPでセッション乗っ取りのテストはまだ必要ですか?
2025年版と同じ形では不要です。2026-07-28版でプロトコルレベルのセッションが廃止されたため、サーバーはMcp-Session-Idを無視します。代わりに状態ハンドルをテストします。Security Best Practicesは、ハンドルを持っていることを認証とみなしてはならず(MUST NOT)、各ハンドルを検証済みのユーザーに結びつけるべき(SHOULD)としています。
まとめ
MCPのセキュリティテストの大半は、期待値がMCP仕様で決まっている普通のAPIテストです。まずモデルの要らない項目から始めます。認証(トークンなし、期限切れ、別audienceのトークンには401)、認可(insufficient_scope付きの403、2つのテナントでの持ち主の確認)、トランスポート(外部のOriginには403、ヘッダーの不一致には-32020付きの400、GETには405)です。これらを4章のようなプローブで自動化し、ステージングへのデプロイごとに「失敗したら止める」ステップにします。今回のプローブは、仕込んだ4つのバグだけを検出しました。そのうえで、ツールごとの入力テスト、拒否が監査ログに残るかの確認、実行環境の中からの外向き通信のテストを行い、最後に利用者が実際に使うクライアントでレッドチームを行います。テストが失敗したときのトークンとスコープの確認の直し方は、MCPのOAuthサーバー実装ガイドで解説しています。
参考資料(一次情報)
- MCP仕様 2026-07-28:Authorization
- MCP仕様 2026-07-28:Streamable HTTP
- MCP Security Best Practices(2026-07-28)
- RFC 6750:Bearer Token Usage
- RFC 8707:Resource Indicators for OAuth 2.0
- RFC 9728:OAuth 2.0 Protected Resource Metadata
- MCP conformance test framework(GitHub)
- Promptfoo:MCP Security Testing Guide
- OWASP GenAI Security Project:A Practical Guide for Secure MCP Server Development
- Tenable:CVE-2025-49596
- JFrog:CVE-2025-6514(mcp-remoteのコマンドインジェクション)
- BleepingComputer:Asana warns MCP AI feature exposed customer data to other orgs
- NodeFlare Docs
