記事一覧
MCPセキュリティ

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のブラックボックステストなら数秒で見つかる種類のものでした。プロンプトインジェクションのテストは、それらに合格してから行うもので、代わりにはなりません。

目次

  1. MCP Securityにおけるセキュリティテストとは
  2. 何をテストするか:仕様と対応づけた7分野
  3. テスト計画:34項目と期待される応答
  4. 検証:16項目のブラックボックス・プローブ
  5. ツール比較:それぞれ何を見つけられるか
  6. CIでの実行
  7. 実際の事例と、それを見つけられたテスト
  8. チェックリスト(12項目)
  9. よくある失敗(症状→原因→対処)
  10. NodeFlareならこう解決できる
  11. よくある質問

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グループはツールのロジックの問題です。どの下書きが誰のものかは、プロキシには分かりません。

最小の始め方:

  1. サーバーをデプロイし、tools:call:<読み取り系ツール>だけを持つmcp_*キーと、全ツールを呼べるキーを1つずつ発行して、プローブの読み取り専用トークンと全権限トークンとして使う。
  2. NodeFlareのURLに対してプローブを実行する。Z1はプロキシで拒否され、リクエストログにoutcomeがscope_deniedの行が出るはずです。
  3. 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サーバー実装ガイドで解説しています。

参考資料(一次情報)

NodeFlare

MCP専用ホスティングサービス

GitHubにあるMCPを簡単デプロイ・簡単運用で最適化
無料で始める

他の記事

MCPトークン2026年10月27日

どのMCPサーバーがトークンを食っている?MCP Token使用量をサーバー・ツール別に計測する方法【2026年版】

どのMCPサーバー・ツールがトークンを使っているかを突き止める方法。Claude Codeの/context・/usage・OpenTelemetryで分かること、Claude APIのusageとツール呼び出しの突き合わせ方、再送分まで数えるツール別コスト式、実測したバイト→トークン換算、キャッシュで順位が逆転する40リクエストの計算例をまとめました。

MCPホスティング2026年10月27日

MCPサーバーのヘルスチェックと死活監視:pingが廃止された2026-07-28仕様で何を確認すべきか【MCP Hosting】

MCPサーバーのヘルスチェックは何を叩けばいいのか。2026-07-28仕様でpingは廃止され、GETは400を返すため、一般的な死活監視はそのままでは誤検知します。正常なサーバーと上流APIキーが切れたサーバーに10種類のプローブを実際に送った結果、3層のチェック設計、Docker・Kubernetes・Cloud Runの設定例、アラート設計、スケールゼロとの費用計算までまとめました。

MCPトークン2026年10月26日

MCP Tokenを減らす「ツールにしない」設計:ResourcesとPromptsの使い分けと費用計算【2026年版】

MCPサーバーのデータや手順は、ツール・リソース・プロンプトのどれで出すとトークンが少ないのか。省ける1往復、定義のコスト、プロンプトとサーバー説明文の比較をClaude Sonnet 5.5の料金で計算し、Claude Code・VS Code・Cursor・Claude APIの対応状況と2026-07-28版仕様の変更点をまとめました。

MCPセキュリティテストの実践:自社MCPサーバーを検証する34項目のテスト計画とプローブ【MCP Security 2026-07-28版】 | Blog | Nodeflare