記事一覧へ

MCPツールの利用者取り違えを防ぐHTTPヘッダーと認証文脈の分け方

生のHTTP入力を署名検証し検証済みの利用者IDと権限を認可へ使う境界
HTTPヘッダーは呼び出し側が書き換えられる入力です。認可には、署名検証後の利用者IDと権限を使います。

社内の複数利用者が同じMCPサーバーとツールを使っても、見てよいデータは人ごとに違います。ローカルMCPと共有HTTP MCPの違いから始め、Platformatic MCP v1.2.1とv2.4.0で偽装ヘッダー、JWT由来の認証文脈、権限なしの一覧・直接実行、プロキシ設定を実測しました。requestを通信の手掛かり、authContextを認可の材料として分ける設計を説明します。

まずMCPサーバーとは何か

MCPは、AIアプリと外部の道具をつなぐ共通の約束です。AIが会話だけで答えられないとき、顧客データを検索する、社内文書を読む、経費申請を作る、といった処理をMCPの「ツール」として呼び出せます。1214

登場人物は三つに分けると分かりやすくなります。

役割具体例担当すること
HostデスクトップAI、社内チャット利用者と会話する
ClientHost内のMCP接続部分サーバーへ要求を送る
ServerCRMや社内DBの前に置くプログラムツールを公開し、外部システムを操作する

たとえば利用者が「A社の直近の商談をまとめて」と頼むと、AIは customer_search を選び、company: "A社" のような引数を送ります。MCPサーバーはCRMを検索し、結果をAIへ返します。MCPサーバー自体がAIとは限りません。AIから安全に呼べるよう、既存のAPIやデータベースを包む入口だと考えると理解しやすいでしょう。

一人で使うMCPと共有するMCPは何が違うか

最初にMCPを試すときは、AIアプリが同じ端末でMCPサーバーを子プロセスとして起動する stdio 構成がよく使われます。利用者ごとに別のプロセスが動き、その人の端末にある設定や認証情報を使います。接続の範囲が狭いため、「誰の処理か」は端末やプロセスの境界に隠れがちです。1214

社内へ広げると事情が変わります。全員のPCへ同じサーバーを入れて更新するより、会社のネットワーク上に一つの遠隔MCPサーバーを置き、共通のURLへ接続させた方が管理しやすいからです。現在の標準的な遠隔通信はStreamable HTTPで、一つの独立したサーバープロセスが複数クライアントの要求を扱えます。1215

共有が起きるのは、たとえば次のような場面です。

共有するツール利用者人ごとに変えるもの
CRM検索営業担当担当顧客、所属部門
請求書検索経理担当法人、閲覧権限
障害チケット更新サポート担当担当案件、書き込み権限
デプロイ操作開発者対象環境、承認済み操作

ここで共有するのは、URL、サーバーのコード、ツール定義です。利用者の権限や、見せてよいデータまで共有するわけではありません。同じ改札を通っても、持っている切符によって入れる場所が違うのと同じです。

一人の端末内で専用MCPを起動するstdio構成と複数の営業担当が共通のMCP URLとツール群を使う共有HTTP構成の比較
図を拡大
共有するのはMCPの入口とツール群です。利用者の権限は要求ごとに分けます。

同じURLで取り違えが起きる理由

一つの /mcp には、営業Aの要求の直後に営業Bの要求が届きます。サーバーが現在の利用者をグローバル変数へ保存したり、前回のテナントIDを使い回したりすれば、次の要求へ情報が漏れます。利用者の特定は接続時に一度だけ済ませるのではなく、原則として要求ごとに行う必要があります。

もう一つの落とし穴が、呼び出し側の自己申告を信じることです。次のような値はいずれも要求へ書けます。

入力例text
x-tenant-id: customer-b
MCP arguments: { "tenantId": "customer-b" }

これらは「customer-bを見たい」という希望には使えます。しかし、「この人はcustomer-bを見てよい」という証明にはなりません。AIが誤った値を組み立てる場合も、利用者が開発者ツールから直接書き換える場合もあります。信頼境界を越えてきた値を、そのまま権限へ昇格させるところで取り違えが起きます。

遠隔MCPの認証仕様はHTTPの認可をOAuth系の仕組みで扱い、アクセストークンの署名だけでなく、有効期限と対象サーバーを検証するよう求めています。別サービス向けのトークンを受け流す「トークン・パススルー」も禁止事項です。13 サーバー側で確認した身元と権限は、呼び出し側が自由に書ける値から分けます。

MCPの引数とHTTPの情報は別の層にある

一回のツール呼び出しは、入れ子の三層として届きます。

層例役割信頼の仕方
MCPの引数query、limit、tenantIdAIがツールへ頼む仕事業務入力として検証する
HTTP要求URL、ヘッダー、IP、Originその仕事を運んだ通信原則は未検証の入力
認証文脈利用者ID、権限、client IDトークン検証後に確定した事実認可の材料にする

Platformatic MCPは、Node.jsのWebフレームワークFastify上でMCPサーバーを作るためのプラグインです。Platformatic MCP v1.2.0は2025年8月26日、Fastifyの request と reply をツール、リソース、プロンプトのハンドラへ渡す機能を加えました。翌27日のv1.2.1はビルド修正版です。12

request からは要求URL、ヘッダー、接続元などを読めます。reply は応答ヘッダーや状態を返すためのものです。この機能で、要求IDをログへつないだり、利用した処理版を応答へ載せたりできます。ただし、HTTPの情報へアクセスできることと、その値が本人確認済みであることは別です。910

まず固定版の全265テストを通した

機能が追加された時点を確認するため、公式リポジトリをv1.2.1、コミット 6d012761 へ固定。対象機能だけのテストは7スイート11件で、すべて成功しました。4

確認したこと結果
URL、任意ヘッダー、クエリハンドラから取得できた
応答ヘッダーreply から設定できた
従来の1引数ハンドラ変更せず成功した
通信経路通常のPOSTとSSE互換設定で成功した
対象ツール、リソース、プロンプトで成功した

前回はRedisを起動せず、全体265件のうち10件が失敗。そこでRedis 8.2.1を一時ディレクトリへソースから構築し、127.0.0.1:6379 だけで再実行しました。結果は69スイート、265テスト、失敗0件。テスト後のRedisは停止済みです。

ここでいうSSEのテストは、外部クライアントが長時間のGETストリームを維持する相互運用試験ではありません。Fastifyの app.inject でSSE互換設定のMCPエンドポイントへPOSTした付属テストです。確認できた範囲を広げすぎないよう、この点は分けておきます。

偽の組織ヘッダーを入れてみた

次に、署名済みJWTの利用者を verified-user-42 とし、同じ要求へ偽装ヘッダーを追加しました。

入力例text
Authorization: Bearer <署名済みJWT>
x-tenant-id: forged-admin-tenant

v1.2.1では、生の x-tenant-id をそのまま読めます。一方、検証済みJWTの sub は request.tokenPayload.sub に verified-user-42 として残りました。この単純なHTTP経路では authContext が作られません。古い版で認可を書くなら「文脈オブジェクトがあるはず」と思い込まず、実際に使う認証経路で検証結果の格納先を確かめる必要があります。

同じ実験をv2.4.0、コミット 75937d3c で行うと、結果は次のようになりました。

コード例json
{
  "rawTenantHeader": "forged-admin-tenant",
  "verifiedUserId": "verified-user-42",
  "verifiedScopes": ["tenant:read"]
}

生の文字列は消えません。ただし、署名検証から作られた authContext.userId と authContext.scopes は別に保たれました。ツールへ「どの組織を表示したいか」という入力を渡すことと、「その利用者がどの組織を読めるか」を確定することは、別の仕事です。

現行版は一覧と実行の両方を絞れる

v2.4.0には canAccessTool があり、ツール一覧の取得と実行の両方で、要求ごとに利用可否を判定できます。6 権限のないツールは一覧から省かれ、直接名前を指定して呼ばれても、存在しないツールと同じ形で応答します。

今回は独自テストをもう一段増やしました。最初の要求には tenant:read 権限を持つJWTと偽装ヘッダーを送り、ツール実行まで確認。次の要求では同じ偽装ヘッダーのまま、権限を profile だけに変更しました。

要求x-tenant-id検証済み権限一覧直接実行ハンドラ実行回数
1forged-admin-tenanttenant:read表示成功1
2forged-admin-tenantprofile非表示-326011のまま

二つ目は Tool 'tenant-report' not found となり、ハンドラは呼ばれませんでした。偽装ヘッダーが同じでも、許可を決めたのは検証済み権限です。独自テスト1件はv2.4.0上で成功しました。

実装の芯は次のようになります。

コード例ts
await app.register(mcpPlugin, {
  authorization: authorizationConfig,
  allowedOrigins: ["https://ai.example.jp"],
  canAccessTool: (toolName, { authContext }) => {
    if (toolName !== "tenant-report") return true
    return authContext?.scopes.includes("tenant:read") === true
  }
})

実際の業務では、権限名だけで終わらせず、トークンの利用者IDからサーバー側で所属組織を引き、MCP引数で指定された組織がその範囲に入るかを照合します。ヘッダーの組織IDは表示先の候補にはなっても、許可の根拠にはしません。

Originの許可リストも別に必要です。v2.4.0の付属テストでは、不許可のOriginは403、許可したOriginは成功しました。7 認証が「誰か」を確かめ、認可が「何をしてよいか」を決め、Origin検証が「どのWeb画面から来たか」を絞ります。三つは似て見えても役割が違います。

MCP引数やテナントヘッダーなどの未検証入力とトークン検証から作る認証文脈を分けcanAccessToolで許可または非表示を決める経路
図を拡大
呼び出し側から届く値は入力のまま残し、検証済みの利用者IDと権限からツール利用可否を決めます。

プロキシを信じる範囲も狭くする

HTTPの request.ip、host、protocol なら信頼できる、とも限りません。Fastifyは trustProxy を有効にすると、X-Forwarded-For、X-Forwarded-Host、X-Forwarded-Proto を解釈します。公式文書も、転送ヘッダーはプロキシが確実に上書きする構成で使うよう注意しています。911

Fastify 5.6.1へ偽の転送ヘッダーを送り、設定を変えて比べました。

trustProxyrequest.iphostprotocol
false127.0.0.1localhost:80http
true203.0.113.9evil.examplehttps
"127.0.0.1"203.0.113.9evil.examplehttps

今回は接続元が許可した 127.0.0.1 なので、文字列指定でも転送値が採用されました。この結果から分かるのは、true が危険で文字列なら常に安全、ということではありません。最後に接続するプロキシのIPやCIDRを限定し、そのプロキシが外部から来た転送ヘッダーを削除して付け直すところまでが一組です。IPやhostを認可の主根拠にしない方が安全です。

全体テストで一件だけずれた理由

v2.4.0の全体テストは2回実行し、どちらも512件中511件成功、1件失敗でした。失敗したのは権限を拒否したツールの実行完了イベントです。通信上は存在しないツールと同じ応答になりますが、監視用イベントの内部結果が not-found ではなく access-denied になり、タグ時点のテスト期待値と一致しませんでした。

これは、拒否したツールが実行されたという失敗ではありません。v2.4.0より後のmain、コミット f4ed3dba では、外部応答に合わせて監視イベントも not-found へ正規化する変更が入っています。8 固定タグのテスト不一致と、認可をすり抜けたかどうかは別問題です。

ツールへ渡す値を三種類に分ける

実装レビューで使った色分けは、次の三種類です。

種類例扱い
生の入力ヘッダー、クエリ、MCP引数形式と意味を検証する
通信上の手掛かり要求ID、接続元IP、Origin追跡や入口の制限に使う
検証済みの事実利用者ID、権限、所属認証・認可層だけで確定する

request と reply が使える利点は大きい。要求IDをログへつなぎ、処理版を応答ヘッダーで返し、呼び出し元ごとの監査記録を残せます。910 ただし、各ツールがCookie、キャッシュ、転送ヘッダーを好きに操作し始めると、HTTP層の方針が分散しかねません。利用者情報をグローバル変数に置かない、認証文脈を要求単位で作る、ツールから返せるヘッダー名を追跡用途へ限定する、といった小さな制約が有効です。

共有MCPの設計で筆者が最初に書くようになった問いは、「何を共有するのか」。共有対象はサーバーとツールであり、利用者の権限ではありません。この一文が決まると、HTTPヘッダーは入力、検証済みトークンは身元、canAccessTool は許可、という役割が自然に分かれます。便利な request を隠すのではなく、生の入力だと分かる名前のまま残す。その上で、誰のデータを触ってよいかを一か所で見直せる認証文脈を置くことが、複数利用者で安全に共有するための土台になります。

参照リンク15件
  1. Platformatic@platformatic/mcp v1.2.0 release
  2. Platformatic@platformatic/mcp v1.2.1 release
  3. Platformatictool-context-access.test.ts at v1.2.1
  4. Platformatic@platformatic/mcp v2.4.0
  5. Platformaticauth-context-propagation.test.ts at v2.4.0
  6. Platformatictool-authorization.test.ts at v2.4.0
  7. Platformaticprotocol-negotiation.test.ts at v2.4.0
  8. Platformatichandlers.ts after v2.4.0
  9. FastifyRequest reference
  10. FastifyReply reference
  11. Microsoft LearnMCPツールの使用
  12. FastifyServer reference trustProxy
  13. Model Context ProtocolTransports
  14. Model Context ProtocolAuthorization
  15. Microsoft LearnAzure API ManagementでMCPサーバーを公開する

2026年8月29日時点の公式資料を確認しています。仕様は更新される可能性があります。

この記事はここまで036

次の記事

手元PCで動くローカルAIを選ぶ llmfitの推定とM5 Max実測の差

関連記事

AI処理を画面とAPIへ再利用する Gradio Workflowで分岐の待ち時間を測る

AIエージェントをなぜ隔離するのか Hugging Face侵害から分かる事故の連鎖

AIエージェントの待ち時間を減らす 仕事を三分岐して56%短縮した方法