0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPでAPIキーはどこに置く? AIに鍵を見せない3つの置き場所

0
Posted at

MCPでAPIキーはどこに置く? AIに鍵を見せない3つの置き場所

はじめに:「誰が呼ぶか」の次は「誰が鍵を持つか」

前回の記事「MCPとAPI、どっちを使う? 既存APIをMCPにする意味」では、APIとMCPを分ける軸は**「誰が呼ぶか」**だと書きました。決まった処理をプログラムが呼ぶならAPIを直接、AIが状況を見て呼ぶならMCP、という整理です。

公開後、「呼ぶ側」より**「鍵を持つ側」**で考えるべきでは、という反応をいただきました。API直叩きではアプリやエージェントが鍵を持ちます。MCPなら鍵をMCPサーバーに寄せられます。そうすると、AIのモデルに鍵が見えるかどうか、漏れたときにどこまで被害が広がるか、監査ログを誰が残すかが変わります。

実はこの2つの軸は対立しません。「誰が呼ぶか」を決めると、鍵の置き場所もほぼ決まるからです。ただ、「ほぼ」の部分に落とし穴があります。MCPを使っていても、作り方しだいでは鍵がAIの会話にそのまま流れます。

そこでこの記事では、MCPの公式仕様とClaude Code・Codexの公式ドキュメントを読み、手元で3つの置き場所を実際に試しました。そのうえで「どこに置けばよいか」の設計指針をまとめます。

APIキーを置ける3つの場所を順に強調するアニメーション。①AIの会話の中は✕、②手元のMCPサーバーの環境変数は△、③リモートのMCPサーバーは○

図1 鍵の置き場所は大きく3つ。「誰に見えるか」と「漏れたときの被害」がそれぞれ違う(静止画: fig01-three-places.png)

この記事で分かることは次のとおりです。

  • API直叩きとMCPで、鍵の持ち主がどう変わるか
  • MCP仕様が鍵とトークンについて「必ずこうする(MUST)」と決めていること
  • 鍵をツールの引数で渡したときと、サーバー側に置いたときの違い(実験)
  • 手元のサーバーに置いた鍵が、AIのシェルから届いてしまう条件(実験)
  • Claude Code・Codex・claude.ai での具体的な設定方法と、サーバーを作る側のチェックリスト

想定している読者は、MCPサーバーをつないだことがある人、これから自社APIをMCPにしようとしている人です。OAuthの細かい手順を知らなくても読めるように、専門用語には短い説明を付けます。

この記事の確認範囲

  • 確認日: 2026年9月25日
  • MCP仕様: 最新版 2026-07-28(前の版は 2025-11-25)
  • 実験環境: macOS、Node.js 22、Claude Code 2.1.280(モデル Opus 5.5)、Codex CLI 0.156.1
  • 実験で使った鍵・トークンはすべてダミー値です。サンプルコードは依存パッケージなしの学習用で、本番の認可には公式SDKと実績あるライブラリを使ってください

結論を先に:鍵の置き場所 早見表

先に結論を表にまとめます。細かい理由は後の章で、仕様と実験の両方から説明します。

置き場所 鍵がある場所 モデルに見えるか AIのシェルから届くか 漏れたときの被害 向いている場面
① 会話の中 プロンプト・ツールの引数 見える 届く 鍵の権限すべて 使わない
② 手元のMCPサーバー(stdio) サーバーの環境変数 見えない 条件しだいで届く 鍵の権限すべて 自分だけが手元で使う
③ リモートのMCPサーバー(HTTP) PCの外のサーバー 見えない 届くのは宛先限定のトークンだけ そのサーバー・その権限・その期限まで チームで使う、配布する

表の「AIのシェルから届くか」は見落とされがちな列です。Claude CodeやCodexのようにAIがシェルコマンドを実行できるアプリでは、「モデルに見えない」ことと「AIが触れない」ことは別の話になります。これは実験2で確かめます。

鍵を持つのは「上流のAPIを実際に呼ぶプログラム」

最初に、API直叩きとMCPで鍵の持ち主がどう変わるかを整理します。ここで上流のAPI(upstream API)とは、MCPサーバーの奥にある本来のサービスのことです。GitHubやSlack、社内の在庫システムなどがこれにあたります。

図2は、APIを実際に呼ぶプログラムを2つの構成で比べたものです。強調される枠を順に追ってください。

API直叩きとMCPで、鍵を持つプログラムを比べるアニメーション。API直叩きでは自分のプログラムが鍵を持ち、MCPではMCPサーバーが鍵を持って、AIアプリはサーバー宛ての入館証だけを持つ

図2 鍵は「上流APIにHTTPを送るプログラム」が持つ。MCPではそれがMCPサーバーになる(静止画: fig02-who-holds.png)

API直叩き:あなたのプログラムが鍵を持つ

API直叩きでは、上流のAPIにHTTPリクエストを送るのはあなたのプログラムです。Claude APIのツール機能(tool use)を使い、AIに「どのAPIをどんな引数で呼ぶか」を決めさせる構成でも同じです。AIが返すのは「このツールをこの引数で呼びたい」という希望だけで、実際にHTTPを送るのはあなたのコードです。

そのため、鍵はプログラムの実行環境(サーバーの環境変数やシークレット管理)に置き、AIに渡すプロンプトには入れないのが基本です。これはMCPを使わない場合でも変わりません。

MCP:MCPサーバーが鍵を持つ

MCPでは、上流のAPIを呼ぶのはMCPサーバーです。AIアプリ(MCPクライアント)はMCPサーバーにツール呼び出しを頼むだけです。したがって本物の鍵はMCPサーバーに置き、AIアプリ側は次のどちらかになります。

  • 手元のサーバー(stdio)なら: AIアプリは鍵を持たない。サーバーを起動するときに環境変数として渡すだけ
  • リモートのサーバー(HTTP)なら: AIアプリはそのサーバー宛てのアクセストークンだけを持つ。アクセストークンとは、ここでは「このサーバーに、この範囲の操作を、この期限まで頼んでよい」という入館証のようなものです

「誰が呼ぶか」を決めると鍵の置き場所もほぼ決まる、というのはこの意味です。

落とし穴:MCPでも鍵が会話に流れる作り方がある

ただし、MCPサーバーが鍵をツールの引数で受け取る作りになっていると、話が変わります。AIが鍵を引数に入れるには、鍵の値をAI自身が知っている必要があります。つまりプロンプトなどで鍵を教えることになり、図1の①になってしまいます。

実はMCPのTools仕様には、「秘密をツールの引数に入れてはならない」という一般的な禁止文言はありません。しかし次の章で見るように、仕様の複数の箇所が**「秘密はLLMの会話を通さない」**という同じ方向を向いています。

公式仕様が決めていること(MCP 2026-07-28)

ここからは、MCP公式仕様とドキュメントに書かれている鍵・トークンまわりのルールを整理します。仕様では、MUST(必ずそうする)、MUST NOT(してはならない)、SHOULD(強く推奨)が区別されています。

stdioとHTTPで、資格情報の渡し方が違う

MCPには、サーバーとの通信方法(トランスポート)が2つあります。stdioはAIアプリがサーバーを子プロセスとして起動し、標準入出力でやり取りする方式です。Streamable HTTPはネットワーク越しにHTTPでやり取りする方式です。

認可仕様(Authorization)は、この2つで資格情報の扱いをはっきり分けています。

  • stdio: 認可仕様には従わず、資格情報を環境から取得する(SHOULD)。原文は "retrieve credentials from the environment" です
  • HTTP: OAuth 2.1 をベースにした認可仕様に従う(SHOULD)。MCPサーバーはリソースサーバー(トークンを確かめて守られたものを出す側)、AIアプリはOAuthクライアントになる

認可そのものは仕様上「任意(OPTIONAL)」です。ただし公式ドキュメント「Understanding Authorization in MCP」は、ユーザー固有のデータを扱う場合、監査が必要な場合、企業で使う場合などには認可を強く推奨しています。

リモートサーバーの認可の流れ

リモートのMCPサーバーにつなぐときの流れを、図3のシーケンス図で示します。番号の順に矢印が強調されます。

リモートMCPサーバーの認可の流れのアニメーション。401応答からメタデータ取得、ブラウザでのログインと同意、宛先付きトークンの発行、Bearerでの呼び出し、サーバー自身の鍵での上流呼び出しまで

図3 AIアプリが受け取るのは「このMCPサーバー宛て」のトークンだけ。上流APIの鍵はサーバーの中に留まる(静止画: fig03-remote-auth-flow.png)

それぞれの段階で仕様が決めていることを、表にまとめます。

段階 仕様の要点 強さ
1. 401で案内 401応答の WWW-Authenticate ヘッダーで、保護リソースメタデータの場所を示す(または well-known URL で公開) MUST(どちらか)
2. メタデータ サーバーは RFC 9728(Protected Resource Metadata)を実装し、認可サーバーの場所を書く MUST
3. ログイン・同意 PKCE(S256)を使う。resource パラメータ(RFC 8707)にMCPサーバーのURLを入れる MUST
4. トークン発行 認可サーバーは短命のアクセストークンを出す SHOULD
5. 呼び出し Authorization: Bearer ヘッダーで毎回送る。URLのクエリ文字列に入れてはならない MUST / MUST NOT
5. 検証 サーバーはそのトークンが自分宛てに発行されたものかを検証する MUST
6. 上流の呼び出し 受け取ったトークンを上流に中継してはならない。上流用には別のトークンを使う MUST NOT

ここで出てくる**宛先(audience、aud)**は、トークンに書かれた「このトークンを使ってよいサーバー」のことです。resource パラメータで宛先を指定して発行してもらい、サーバー側で照合します。こうすると、あるサーバー用のトークンが盗まれても、別のサーバーでは使えません。

トークンの「中継」は禁止

鍵の置き場所を考えるうえで、いちばん重要な規定がトークン・パススルー(token passthrough)の禁止です。認可仕様は、MCPサーバーについて次のように定めています。

  • 自分宛てでないトークンを受け付けない、中継もしない("MUST NOT accept or transit any other tokens")
  • MCPクライアントから受け取ったトークンを、上流に渡してはならない

公式の「Security Best Practices」は、禁止する理由を4つ挙げています。

  1. セキュリティ制御のすり抜け: MCPサーバーでかけている流量制限や権限チェックを迂回される
  2. 監査と責任の所在: 上流のログには誰の操作か残らない。盗まれたトークンでデータを持ち出す踏み台にもなる
  3. 信頼の境界: 1つのサービスが破られると、同じトークンを受け付ける連携先すべてに広がる
  4. 将来の互換性: 後からセキュリティ対策を足しにくくなる

図4は、中継する構成と、宛先ごとに鍵を分ける構成を比べたものです。

トークンを中継する構成と、宛先ごとに別の鍵を使う構成を比べるアニメーション。中継では同じ鍵が上流まで流れ、分ける構成ではMCPサーバーが検証してから自分の鍵に持ち替える

図4 MCPサーバーは受け取ったトークンを上流へ流さず、自分の鍵に持ち替える(静止画: fig04-no-passthrough.png)

上流もOAuthで守られている場合、MCPサーバーは上流に対してはOAuthクライアントになります。上流の認可サーバーでユーザーの同意を取り、上流用のトークンを別に受け取って保管します。つまりリモートのMCPサーバーは、「クライアントから見たリソースサーバー」と「上流から見たOAuthクライアント」の2つの顔を持つことになります。

秘密をAIの会話に通さないための規定

仕様には、「秘密はAIの会話やクライアントを通さない」方向の規定がほかにもあります。鍵の置き場所を考えるときの根拠になるので、3つ紹介します。

  • Elicitation(サーバーからユーザーへの入力依頼): サーバーはフォーム形式でパスワード・APIキー・アクセストークン・支払い情報を求めてはならない(MUST NOT)。機密情報はURLモードを使い、ユーザーがブラウザで直接サーバーに入力する。この方式なら秘密はLLMの会話もMCPクライアントも通らない(Elicitation仕様)
  • x-mcp-header(2026-07-28で追加): ツールの引数をHTTPヘッダーに写す仕組みで、パスワード・APIキー・トークン・個人情報を対象にすべきでない(SHOULD NOT)。途中の中継機器から見えるため(Tools仕様)
  • ログ: 公式ドキュメントは、Authorization ヘッダーやトークン、秘密情報をログに出さないよう注意しています

ここまでをまとめると、**「秘密はLLMのコンテキストに載せない」**という原則が仕様の複数の箇所から一貫して読み取れます。ただし、これは筆者の整理であり、Tools仕様に「引数に秘密を入れるな」と明記されているわけではない点には注意してください。

2026-07-28版で変わった認可まわりの点

2025-11-25版から、認可まわりでは次の点が変わっています(変更履歴)。

変更 内容
iss の検証を追加 認可応答の iss を記録した発行者と照合する(RFC 9207)。悪意ある認可サーバーを混ぜる攻撃(mix-up 攻撃)への対策
動的クライアント登録(DCR)が非推奨 代わりに Client ID Metadata Documents(CIMD)を推奨
クライアント資格情報の結びつけ 事前登録やDCRで得た資格情報は、発行元の認可サーバーに結びつけて保存し、別の認可サーバーに使い回さない
セッションの廃止 プロトコル上のセッションがなくなり、資格情報は「接続の状態ではなく、リクエストごとの入力」として扱われる

なお「Security Best Practices」は、2026-07-28版では仕様(specification)ではなくドキュメント(docs)側に移っています。旧URLは転送されます。

実験で確かめる:3つの置き場所を試す

仕様の読み比べだけでは実感が湧きにくいので、手元で3つの置き場所を試しました。題材は前回の記事と同じ、架空の文房具店の在庫・注文APIです。注文には Authorization: Bearer <鍵> が必要です。

図5が実験の構成です。

実験の構成図のアニメーション。Claude Codeから3種類のMCPサーバーを経由して同じ店舗APIを呼び、会話ログに鍵が何か所残ったかを比べる

図5 同じ注文APIを、鍵の置き場所だけ変えて呼ぶ。呼んだあとに会話ログを鍵の文字列で検索する(静止画: fig05-experiment.png)

実験の準備

サンプルコードは記事と同じフォルダーの sample-code/ にあります。すべて Node.js 22 の標準機能だけで動き、npm install は不要です。

sample-code/
├── shop-api/server.mjs        # 上流の店舗API(注文には Bearer 鍵が必要)
├── leaky-mcp/server.mjs       # 実験1: 鍵をツールの引数で受け取る(悪い例)
├── shop-mcp/server.mjs        # 実験2: 鍵を環境変数で受け取る(stdio)
├── remote-mcp/
│   ├── server.mjs             # 実験3: 宛先・権限つきトークンを検証する(HTTP)
│   ├── jwt.mjs                # 学習用の最小JWT(HS256)
│   ├── mint-token.mjs         # 認可サーバーの代役(トークンを発行する)
│   └── check-tokens.sh        # curl で拒否のされ方を確かめる
├── mcp-leaky.json / mcp-env.json / mcp-keychain.json / mcp-remote.json

まず、上流の店舗APIとリモートMCPサーバーを起動します。どちらもダミーの値を使います。

cd sample-code
# 上流APIの鍵(ダミー)と、トークン署名用の鍵(ダミー)
export SHOP_API_TOKEN=shop-upstream-DEMO-7f3a9c
export AUTH_SIGNING_KEY=signing-key-DEMO-only

node shop-api/server.mjs &       # http://localhost:8787
node remote-mcp/server.mjs &     # http://localhost:8788/mcp

Claude Code はヘッドレスモード(claude -p)で実行しました。個人の設定が混ざらないように --setting-sources project を付け、MCPサーバーは --mcp-config と --strict-mcp-config で指定したものだけを使います。

実験1(悪い例):鍵をツールの引数で渡す

leaky-mcp は、注文ツールが api_key という引数で鍵を受け取るMCPサーバーです。ツール定義の該当部分は次のとおりです。

// leaky-mcp/server.mjs(抜粋)。鍵を引数で受け取るので、AIが鍵を知っている必要がある
inputSchema: {
  type: "object",
  properties: {
    api_key: { type: "string", description: "店舗APIのキー" }, // ← これが問題
    product_id: { type: "string" },
    quantity: { type: "integer", minimum: 1 },
  },
  required: ["api_key", "product_id", "quantity"],
},

このサーバーを使うには、AIに鍵を教える必要があります。プロンプトに鍵を書いて注文させ、そのあと Claude Code の会話ログ(~/.claude/projects/ 以下の JSONL ファイル)を鍵の文字列で検索しました。

実験1の端末出力。プロンプトに鍵を書いて注文すると、モデルが api_key 引数に鍵を入れてツールを呼び、会話ログから鍵の文字列が6件見つかった

画面1 注文は成功するが、会話ログに鍵が平文で6か所残った(鍵はダミー値)

注文は成功しました。しかし会話ログには鍵が6か所残っていました。最初のプロンプト、モデルが組み立てたツール呼び出しの引数、入力履歴などです。本物の鍵なら、この時点で次の2か所に載っていることになります。

  • モデルへの入力: プロンプトもツール呼び出しも、モデルを動かすサービスに送られる
  • PC上のログ: 会話ログは平文のファイルとして残り、バックアップや共有で広がることがある

さらに、鍵がモデルのコンテキストにある限り、プロンプトインジェクション(ツールの結果やWebページに紛れ込ませた指示でAIを操る攻撃)で鍵を別の場所へ送らせる余地が生まれます。今回はそこまで試していませんが、「モデルが鍵を知っている」こと自体がリスクの入口になります。

実験2:手元のMCPサーバーの環境変数で渡す

次に、鍵を環境変数で受け取る shop-mcp を使います。サーバー側のコードは、起動時に環境変数を読むだけです。

// shop-mcp/server.mjs(抜粋)。鍵はMCPの設定(env)から受け取り、AIには見せない
const TOKEN = process.env.SHOP_API_TOKEN;

設定ファイル mcp-env.json には、鍵の値ではなく変数名だけを書きます。Claude Code は ${VAR} の形を、起動したシェルの環境変数の値に置き換えます。

{
  "mcpServers": {
    "shop": {
      "command": "node",
      "args": ["<サンプルのフォルダー>/shop-mcp/server.mjs"],
      "env": {
        "SHOP_API_URL": "http://localhost:8787",
        "SHOP_API_TOKEN": "${SHOP_API_TOKEN}"
      }
    }
  }
}

この設定で注文させ、あわせて「注文に使われたAPIキーの値を教えて」と頼みました。

実験2の端末出力。注文は成功し、AIは鍵の値は見えないので答えられないと回答。会話ログから鍵の文字列は0件

画面2 AIは鍵の値を答えられず、会話ログにも鍵は残らなかった

注文は成功し、AIは「キーの値はお伝えできません。私からは見えないためです」と答えました。会話ログを検索しても、鍵は0件でした。鍵はMCPサーバーのプロセスの中にだけあり、モデルには一度も渡っていません。

実験2の続き:AIのシェルからは届くのか

ここで見落としやすい点があります。Claude Code や Codex は、AIにシェルコマンドを実行させられます。同じPC・同じユーザーで動いているなら、AIのシェルからも鍵に届くのでは、という疑問です。

そこで、Bashツールを許可して、環境変数が見えるかどうかだけを確かめるコマンドを実行させました。値そのものは表示させず、見えるかどうかと文字数だけを出します。

test -n "$SHOP_API_TOKEN" && echo "SHOP_API_TOKEN が見える(${#SHOP_API_TOKEN}文字)" || echo "SHOP_API_TOKEN は見えない"

比べたのは次の2つの起動方法です。

  • A: SHOP_API_TOKEN=... claude のように、鍵を環境変数に入れて Claude Code を起動する(${SHOP_API_TOKEN} の展開にはこれが必要)
  • B: Claude Code には鍵を渡さない。MCPサーバーを起動するコマンドの中で、macOS のキーチェーンから鍵を取り出す

Bの設定ファイルは次のとおりです。キーチェーンには事前に security add-generic-password -a "$USER" -s mcp-key-lab-shop-api -w <鍵> で登録しておきます。

{
  "mcpServers": {
    "shop": {
      "command": "sh",
      "args": [
        "-c",
        "SHOP_API_TOKEN=$(security find-generic-password -s mcp-key-lab-shop-api -w) exec node <サンプルのフォルダー>/shop-mcp/server.mjs"
      ],
      "env": { "SHOP_API_URL": "http://localhost:8787" }
    }
  }
}

結果は次のとおりです。

AのときはBashツールから SHOP_API_TOKEN が見え(25文字)、Bのときは注文は成功しつつBashツールからは見えなかった端末出力

画面3 Aでは Bash ツールから鍵が見え、Bでは見えなかった

図6で、なぜこうなるかを説明します。

手元の鍵がAIのシェルから届く仕組みのアニメーション。Aではシェルの環境変数がMCPサーバーにもBashツールにも引き継がれ、Bではキーチェーンから取得するのでBashツールの環境には無い

図6 AIアプリが持つ環境変数は、起動する MCPサーバーにも Bash ツールにも引き継がれる(静止画: fig06-shell-reach.png)

Aでは、Claude Code のプロセスが鍵の環境変数を持っています。子プロセスは親の環境変数を引き継ぐので、MCPサーバーだけでなく Bash ツールのシェルにも鍵が渡ります。25文字という長さは、ダミーの鍵 shop-upstream-DEMO-7f3a9c と一致します。

Bでは、鍵を取り出すのはMCPサーバーを起動する sh -c の中だけです。Claude Code 本体も Bash ツールも鍵の変数を持ちません。

ただし、Bでも完全な壁にはなりません。Bash ツールに security find-generic-password を実行する許可があれば、同じ方法で鍵を取り出せます。同じユーザーで動くプログラムは、基本的に同じものに手が届くからです。

実は実験中、AIに「このPCの中から鍵を調べて」と頼んでみたところ、AIはファイルを調べたうえで「値を表示すると方針を崩すので、ここでやめておきます」と自分で止まりました。ありがたい振る舞いですが、これはモデルの判断であって、仕組みで守られているわけではありません。手元の鍵をしっかり守りたいなら、次のどちらかが必要です。

  • シェルの権限を絞る: Bash ツールの許可リストを最小限にする、サンドボックスで動かす、秘密のファイルへの読み取りを拒否する設定を入れる
  • 鍵をPCの外に出す: リモートのMCPサーバーに鍵を置き、PCには宛先と期限を限ったトークンだけを置く(実験3)

実験3:リモートMCPサーバーと宛先付きトークン

最後に、鍵をPCの外に置く構成です。remote-mcp は Streamable HTTP で動くMCPサーバーで、次の3つを行います。

  1. 受け取ったトークンの署名・宛先(aud)・期限を検証する
  2. ツールごとに必要な**権限(scope)**を確かめる。find_products は shop:read、create_order は shop:order
  3. 上流の店舗APIには、サーバー自身が持つ別の鍵で接続する。受け取ったトークンは中継しない

本物の構成ではトークンを発行するのはOAuth 2.1の認可サーバーですが、実験では代役の mint-token.mjs で署名付きトークンを作りました。検証部分のコードは次のとおりです。

// remote-mcp/server.mjs(抜粋)
const RESOURCE = `http://localhost:${PORT}/mcp`;  // このサーバーの識別子(トークンの aud と照合)
const UPSTREAM_TOKEN = process.env.SHOP_API_TOKEN; // 上流APIの鍵。サーバーの外には出さない

function authenticate(req) {
  const m = /^Bearer (.+)$/.exec(req.headers.authorization || "");
  if (!m) return { error: "invalid_request" };
  try {
    const claims = verify(m[1], SIGNING_KEY);        // 署名と期限を確認
    if (claims.aud !== RESOURCE) return { error: "invalid_token", reason: `aud mismatch (${claims.aud})` };
    return { claims };
  } catch (e) {
    return { error: "invalid_token", reason: e.message };
  }
}

ツールを呼ぶ前には、トークンの scope を確かめます。足りなければ、仕様どおり 403 と insufficient_scope を返します。

// remote-mcp/server.mjs(抜粋)。scope が足りなければ、ツールを実行せずに 403 を返す
const granted = (auth.claims.scope || "").split(" ");
if (!granted.includes(tool.scope)) {
  audit({ event: "forbidden", sub: auth.claims.sub, tool: tool.name, need: tool.scope });
  return challenge(res, 403, "insufficient_scope", `, scope="${tool.scope}"`);
}

まず curl だけで、いろいろなトークンを渡したときの反応を確かめました(check-tokens.sh)。

curlでの検証結果。トークンなしは401、上流APIの鍵は401、宛先違いは401、読み取りのみのトークンで注文すると403、注文権限つきで成功、MCP用トークンで上流APIを直接呼ぶと unauthorized

画面4 宛先違い・権限不足のトークンはサーバーが拒否し、MCP用のトークンは上流APIでは使えない

結果を表にまとめます。

試したこと 結果 意味
① トークンなし 401 invalid_request WWW-Authenticate で認可の案内を返す
② 上流APIの鍵をそのまま渡す 401 invalid_token 上流の鍵はこのサーバーの入館証にならない
③ 別のサーバー宛てのトークン 401 invalid_token 宛先(aud)の照合で拒否
④ 読み取りのみのトークンで一覧 成功 ツール一覧は取れる
⑤ 読み取りのみのトークンで注文 403 insufficient_scope 必要な scope shop:order を返す
⑥ 注文の権限つきトークンで注文 成功 サーバーが自分の鍵で上流に注文
⑦ MCP用のトークンで上流APIを直接呼ぶ unauthorized 中継の真似をしても上流は受け付けない

②と⑦が、トークン・パススルーをしない構成の確認です。PC側にあるMCP用のトークンが漏れても、上流APIを直接たたくことはできません。

次に、Claude Code からこのサーバーにつなぎました。設定ファイルでは、headers に ${MCP_ACCESS_TOKEN} を書いてトークンを渡します。

{
  "mcpServers": {
    "shop-remote": {
      "type": "http",
      "url": "http://localhost:8788/mcp",
      "headers": { "Authorization": "Bearer ${MCP_ACCESS_TOKEN}" }
    }
  }
}

読み取りのみのトークンと、注文の権限つきトークンで、それぞれ「P004を2本注文して」と頼みました。

Claude Codeからの実行結果。読み取りのみのトークンでは Insufficient scope で注文されず、監査ログにforbiddenが残る。権限つきでは注文番号O0006で成功し、監査ログにsubと引数が残る。会話ログに上流の鍵は0件

画面5 権限が足りないと注文は実行されず、誰が何をしたかはサーバーの監査ログに残る

読み取りのみのトークンでは、Claude Code は Insufficient scope: required "shop:order" を受け取り、「権限が足りず注文は確定していません」と説明しました。サーバーの監査ログには、sub(誰の操作か)が takuya で、create_order が拒否されたことが残っています。権限つきのトークンでは注文が成功し、監査ログに引数と結果が残りました。

会話ログを検索すると、上流APIの鍵もMCP用トークンの文字列も0件でした。トークンは設定の headers を通してHTTPヘッダーに載るだけで、モデルのコンテキストには入りません。

同じサーバーは Codex CLI からも使えました。Codex では bearer_token_env_var に環境変数名を書くと、その値が Authorization: Bearer として送られます。

Codex CLIからリモートMCPサーバーを使った結果。bearer_token_env_var でトークンを渡し、find_products が完了してノートの在庫一覧が表示された

画面6 Codex CLI 0.156.1 でも、設定ファイルを書き換えずにコマンドの -c 指定だけで接続できた

漏れたときの被害範囲を比べる

実験3の構成でも、PCにあるMCP用トークンは AI のシェルから届きます。環境変数で渡している以上、実験2のAと同じ理屈です。それでもリモートに鍵を置く意味は、漏れたときに何ができるかを小さく区切れる点にあります。図7で比べます。

上流APIの鍵とMCPサーバー宛てのトークンを、使える場所・できること・期限・操作者の区別の4つの観点で順に比べるアニメーション

図7 PCに置くのが「上流APIの長期の鍵」か「宛先限定の短いトークン」かで、被害の広さが変わる(静止画: fig07-blast-radius.png)

実験のトークンは有効期限を10分にしました。宛先はこのMCPサーバーだけ、権限は scope の範囲だけです。さらに sub でユーザーを区別できるので、誰の操作だったかを後から追えます。上流APIの鍵を全員で共有する構成では、このどれもできません。

鍵の置き場所を決める判断フロー

ここまでの内容を、判断フローにまとめます。問いは3つだけです。

鍵の置き場所を決める判断フローのアニメーション。自分だけ手元で使うか、AIにシェルを広く許可するか、人がログインして使うかで、A stdio+環境変数、B 鍵をPCの外へ、C リモート+OAuth、D 人のいない自動処理 に分かれる

図8 使う人数・人が操作するか・AIにシェルを使わせるか、の3つで置き場所が決まる(静止画: fig08-decision.png)

それぞれの行き先を補足します。

  • A. stdio + 環境変数: 自分だけが手元で使い、AIのシェル権限も絞っている場合です。鍵の値は設定ファイルに書かず、${VAR} で参照するか、起動時にキーチェーンやパスワード管理ツールから注入します
  • B. 鍵をPCの外へ: AIに広くシェルを使わせる場合です。手元に長期の鍵を置くと、AIのシェルから届く範囲に入ります。リモートのMCPサーバーに移すか、手元に残すならシェルの許可リストやサンドボックスで絞ります
  • C. リモート + OAuth: チームで使う、社外に配布する場合です。ユーザーごとにトークンを発行し、scope で権限を分けます。社内のSSO(シングルサインオン)がある場合は、公式拡張の Enterprise-Managed Authorization で、企業のIDプロバイダーがアクセスを一元管理できます
  • D. 人のいない自動処理: CIやバッチのように人がログインしない場合です。公式拡張の OAuth Client Credentials を使うか、クラウド側の秘密管理(例: Claude Managed Agents の vault)に鍵を預けます

どの行き先でも共通するのは、鍵をプロンプト・ツールの引数・設定ファイルの本文に書かないことです。

よくある場面での置き場所

判断フローを、よくある場面に当てはめると次のようになります。

場面 おすすめの置き場所 ポイント
個人で GitHub の MCP サーバーを使う A(ローカル+PATを環境変数)か C(リモート+OAuth) PATは必要な権限だけに絞り、プロジェクトごとに分ける
チームのリポジトリに .mcp.json を置く A または C ファイルには ${VAR} だけ。値は各自の環境に
社内APIをMCPにして全社員に配る C ユーザーごとのトークンと scope、監査ログ
AIに自由にコマンドを実行させる開発環境 B 長期の鍵を手元に置かない
夜間バッチでエージェントを動かす D 人の同意を前提にしない仕組みで
自作アプリから Claude API で MCP を使う C MCP connector にはアクセストークンを渡す。取得と更新はアプリ側の責任

クライアント別:鍵とトークンの渡し方

ここからは、主なAIアプリでの具体的な設定方法を、公式ドキュメントに沿って紹介します。

Claude Code

Claude Code では、stdio のサーバーには --env(-e)、HTTPのサーバーには --header(-H)で値を渡せます(Claude Code MCP ドキュメント)。

# stdio: 環境変数を渡す(--env の直後にサーバー名を書くと、名前も KEY=value と解釈されるので、別のオプションを挟む)
claude mcp add --env SHOP_API_TOKEN="$SHOP_API_TOKEN" --transport stdio shop -- node ./shop-mcp/server.mjs

# HTTP: ヘッダーを渡す
claude mcp add --transport http shop-remote http://localhost:8788/mcp \
  --header "Authorization: Bearer $MCP_ACCESS_TOKEN"

ただし、このようにコマンドで値を渡すと、シェルが先に値を展開するので、設定ファイルに鍵の値そのものが書き込まれると考えられます(公式ドキュメントに直接の記述はなく、筆者の推測です)。値を残したくない場合は、.mcp.json などに ${VAR} の形で書くのが確実です。

${VAR} と ${VAR:-既定値} が展開されるのは、command・args・env・url・headers の5か所です。安全のため、次のような仕様もあります。

  • リモートサーバーの url と headers では、ANTHROPIC_API_KEY や ANTHROPIC_AUTH_TOKEN などの資格情報の変数は空として扱われる。リポジトリに置かれた .mcp.json が、あなたの鍵を外部のサーバーへ送るのを防ぐため
  • 変数が未設定だと、${VAR} が文字のまま残り、警告が出る(実験では、この状態でサーバーに接続できず failed になりました)
  • /mcp の画面やCLIの出力では、値ではなく ${VAR} の変数名が表示される

リモートサーバーがOAuthに対応していれば、/mcp の画面か claude mcp login <名前> でブラウザからログインできます。トークンは Claude Code が保存し、自動で更新します。事前に登録したOAuthクライアントを使う場合は、--client-id と --client-secret を指定します。クライアントシークレットは、設定ファイルではなく macOS のキーチェーン(または資格情報ファイル)に保存されます。

# 事前登録したOAuthクライアントで追加する(--client-secret は画面で伏せ字入力)
claude mcp add --transport http \
  --client-id your-client-id --client-secret --callback-port 8080 \
  my-server https://mcp.example.com/mcp

OAuth以外の方式(短命トークン、社内SSOなど)には headersHelper があります。JSON形式でヘッダーを出力するコマンドを指定すると、接続のたびに実行されます。

{
  "mcpServers": {
    "internal-api": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headersHelper": "/opt/bin/get-mcp-auth-headers.sh"
    }
  }
}

headersHelper の決まりは次のとおりです。

  • タイムアウトは10秒。401や403が返ると、1回だけ再実行して再試行する
  • プロジェクトの .mcp.json やプラグインで指定したヘルパーには、名前に TOKEN・SECRET・PASSWORD・KEY・AUTH を含む環境変数が渡されない。ファイルや資格情報ストアから読むように書く
  • プロジェクトで指定したヘルパーは、フォルダーを信頼するダイアログを承認するまで実行されない

組織で管理する managed-mcp.json については、公式ドキュメントに「マシンのすべてのユーザーが読めるので、env にAPIキーを書かないこと」という注意があります。

Codex CLI

Codex CLI では、~/.codex/config.toml(または信頼したプロジェクトの .codex/config.toml)の [mcp_servers.<名前>] に書きます(Codex の MCP ドキュメント)。

# stdio: 親の環境から引き継ぐ変数を許可リストで指定する(値を設定ファイルに書かない)
[mcp_servers.shop]
command = "node"
args = ["/path/to/shop-mcp/server.mjs"]
env_vars = ["SHOP_API_TOKEN"]

# HTTP: 環境変数の値を Authorization: Bearer として送る
[mcp_servers.shop_remote]
url = "http://localhost:8788/mcp"
bearer_token_env_var = "MCP_ACCESS_TOKEN"

キーの使い分けは次のとおりです。

キー 役割 鍵を設定ファイルに書くか
env サーバーに渡す値をそのまま書く 書く(避けたい)
env_vars 親の環境から引き継ぐ変数名の許可リスト 書かない
bearer_token_env_var 指定した環境変数の値を Bearer として送る 書かない
env_http_headers ヘッダー名と環境変数名の対応 書かない
http_headers ヘッダーの値を直接書く 書く(避けたい)
http_headers_helper ヘッダーをJSONで出すコマンド 書かない

OAuthに対応したサーバーには codex mcp login <名前> でログインします。トークンの保存先は mcp_oauth_credentials_store(auto・file・keyring)で選べます。

claude.ai のカスタムコネクタ

claude.ai(Claude のWeb・デスクトップ・モバイル)でリモートMCPサーバーを「カスタムコネクタ」として追加する場合、接続はAnthropicのクラウドから行われます(カスタムコネクタの案内)。そのため、サーバーはインターネットから届く場所にある必要があります。

認証方式はOAuth(動的登録・CIMD)が基本です。静的なAPIキーをヘッダーで送る方式もベータで用意されていますが、その場合の資格情報は組織で共有され、ユーザーごとにはなりません。また、?token= や ?apiKey= のようにURLに鍵を入れる形は推奨されていません。

Claude API の MCP connector と Managed Agents

自分のアプリから Messages API でリモートMCPサーバーを使う MCP connector では、mcp_servers の authorization_token にアクセストークンを入れます(MCP connector)。ドキュメントには、OAuthの流れを実行してトークンを取得し、必要に応じて更新するのはAPI利用者の責任だと書かれています。

{
  "type": "url",
  "url": "https://mcp.example.com/mcp",
  "name": "example-mcp",
  "authorization_token": "<ユーザーのアクセストークン>"
}

Claude Managed Agents では、資格情報を vault に登録し、セッション作成時に参照します(vault のドキュメント)。登録した値はAPIの応答に返らず、environment_variable 型ではサンドボックスの中にはプレースホルダーだけが置かれます。通信が外へ出る時点で本物の値に置き換わるので、エージェントは本物の鍵を見ません。これは、実験2で見た「AIのシェルから届く」問題への、クラウド側の答えの一つと言えます。

クライアント別の比較表

ここまでの内容を表にまとめます。

クライアント stdio の鍵 リモートの静的な鍵 OAuth トークン 設定ファイルに値を書かない方法
Claude Code --env、env の ${VAR} --header、headers の ${VAR}、headersHelper /mcp でログイン、自動更新 ${VAR}、headersHelper
Codex CLI env、env_vars bearer_token_env_var、env_http_headers、http_headers_helper codex mcp login、保存先を選べる env_vars、bearer_token_env_var
claude.ai コネクタ 対象外(リモートのみ) ヘッダー方式はベータ・組織で共有 Anthropic 側で保存・更新 管理画面で入力(再表示されない)
Claude API MCP connector 対象外 authorization_token 取得・更新はアプリ側 アプリのサーバー側で管理
Managed Agents environment_variable(外向き通信で置換) vault の static_bearer vault の mcp_oauth(更新も任せられる) vault に登録して ID で参照

MCPサーバーを作る側のチェックリスト

自社APIをMCPサーバーにするときに確認したい項目を、仕様とドキュメントからまとめます。まずは鍵の置き方です。

項目 確認すること 根拠
鍵を引数で受け取らない ツールの inputSchema に api_key のような項目がない 実験1、Elicitation・x-mcp-header の規定
stdio は環境から 鍵は環境変数や OS の資格情報ストアから読む 認可仕様(SHOULD)
秘密をコードに埋め込まない 環境変数かシークレット管理を使い、リポジトリに入れない 認可のチュートリアル
ログに出さない Authorization ヘッダー、トークン、鍵をログに書かない 認可のチュートリアル

リモートのサーバーでは、トークンの扱いも確認します。

項目 確認すること 根拠
宛先の検証 トークンの aud が自分のURLか。api のような汎用の宛先を受け付けない 認可仕様(MUST)
中継しない 上流へは自分の鍵か、上流で別に発行したトークンを使う 認可仕様(MUST NOT)
ヘッダーで受け取る クエリ文字列のトークンを受け付けない 認可仕様(MUST NOT)
最小の scope 最初は読み取りなど最小限。書き込みは必要になったときに追加で求める Security Best Practices
サーバー側で判断 トークンの scope だけに頼らず、ツールごとに権限を確かめる Security Best Practices
短い期限 アクセストークンは短命に。リフレッシュトークンはローテーション 認可仕様
監査ログ 誰が(sub)・どのツールを・どんな引数で呼んだかを残す 実験3、Tools仕様(クライアント側の SHOULD)
検証は実績あるライブラリで トークン検証を自作しない 認可のチュートリアル

サンプルの remote-mcp は仕組みを見せるための最小実装です。共通鍵(HS256)で署名していますが、本番では認可サーバーの公開鍵(JWKS)で検証するライブラリを使ってください。また、仕様の最新版(2026-07-28)ではプロトコル上のセッションや initialize がなくなりましたが、サンプルは旧来の initialize 方式のままです。Claude Code 2.1.280 と Codex CLI 0.156.1 では動作しましたが、本番では公式SDKにバージョンの交渉を任せてください。

よくある誤解

鍵の置き場所について、よく見かける誤解を整理します。

誤解 実際
MCPを使えば、鍵は自動的に安全になる 鍵を引数で受け取る作りなら、会話に鍵が流れる(実験1)
stdio の環境変数なら、AIから完全に隠れる モデルからは見えないが、AIのシェルに同じ変数を渡すと届く(実験2のA)
クライアントのトークンを上流に中継すれば楽 仕様で禁止。監査・権限チェックが効かなくなる
非公開リポジトリなら .mcp.json に鍵を直書きしてよい 共有される前提のファイル。${VAR} で参照し、値は各自の環境に置く
ツールの readOnlyHint を付ければ書き込みを防げる 目印(ヒント)にすぎない。止めるのはAIアプリの許可設定と、サーバー側の権限チェック
OAuthにすれば、あとは何もしなくてよい 宛先の検証、scope の最小化、中継しない、ログに出さない、はサーバー側の仕事

トラブルシューティング

リモートのMCPサーバーにつながらないとき、原因が鍵やトークンにあるかどうかは、返ってきたエラーから見分けられます。

つながらないときの見分け方を順に強調するアニメーション。401 invalid_request、401 invalid_token、403 insufficient_scope、上流が401の4パターン

図9 エラーの種類から、原因と確認する場所を絞り込む(静止画: fig09-troubleshoot.png)

図9と同じ内容を、表でも示します。

症状 よくある原因 確認すること
401 invalid_request Authorization ヘッダーが付いていない。headers の設定漏れ、OAuth のログイン前 /mcp でサーバーの状態を見る。必要ならログイン
401 invalid_token 期限切れ、宛先違い、上流APIの鍵を渡している、${VAR} が未設定で文字のまま AIアプリを起動したシェルに変数があるか。MCP用の新しいトークンか
403 insufficient_scope トークンの権限が足りない WWW-Authenticate の scope を見て再認可。最初から広い scope を取らない
ツールは動くが上流が401 MCPサーバーが上流へ送る鍵が未設定・失効 サーバー側の秘密管理を確認。クライアントのトークンを流して直さない
Claude Code で failed と表示される 設定の ${VAR} の変数が未設定 claude を起動する前に変数を設定したか

ヘッダーは curl -i で確かめられます。ただし確認のときも、トークンの値そのものをチャットやログに貼らないようにしましょう。

まとめ

MCPで鍵をどこに置くかは、「誰が上流のAPIを実際に呼ぶか」でほぼ決まります。MCPではそれがMCPサーバーなので、本物の鍵はサーバーに置き、AIの会話には入れません。この記事のポイントを振り返ります。

  • 鍵を会話に入れない: プロンプトやツールの引数に鍵を書くと、モデルへの入力と会話ログの両方に残る(実験では6か所)
  • 手元なら環境から: stdio のサーバーは環境変数などから鍵を読む。設定ファイルには ${VAR} だけを書く
  • AIのシェルにも注意: 同じPC・同じユーザーで動く限り、AIのシェルから鍵に届くことがある。シェルの権限を絞るか、鍵をPCの外に出す
  • リモートなら宛先付きトークン: PCには「このサーバー宛て・この権限・この期限」のトークンだけを置き、サーバーは宛先と scope を検証する
  • 中継しない: MCPサーバーは受け取ったトークンを上流に流さず、自分の鍵に持ち替える。監査ログは sub 単位で残す

次のステップとしては、自分が使っているMCPサーバーの設定ファイルを開き、鍵の値が直接書かれていないか確かめるところから始めるのがおすすめです。自作のサーバーなら、inputSchema に鍵の項目がないか、ログに Authorization ヘッダーを出していないかを見直してみてください。

参考リソース

MCP 公式

Anthropic / Claude

その他

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?