はじめに
Claude Code で MCP を使うとき、API key などの認証情報の管理ってどうしてますか?
.mcp.json や ~/.claude.json や .env に認証情報を直書きするのは怖いですよね。1Password を使っている人なら、op コマンドで包んで設定ファイルから秘密情報を消すのが定番です。
ローカル MCP ならこの方法できれいに解決できます。問題は、ヘッダーに認証情報を渡す必要があるリモート MCP サーバー(type: "http" で Authorization: Bearer ... を要求するもの)です。
認証が OAuth で済むリモート MCP なら、そもそも Claude Code が OAuth をネイティブ対応していて、静的トークンを持たずに済みます(後述)。でも世の中には、OAuth を提供しておらず Bearer トークン直渡ししか手段がないリモート MCP が存在します。社内向けに自前ホストしているものや、一部のサードパーティです。この記事は、そういう「ヘッダーで静的トークンを渡すしかないリモート MCP」を 1Password で安全に使う方法を試行錯誤した記録です。
同じところで悩んでいる人の参考になればと思います。結論だけ知りたい人は最後の「まとめ」へどうぞ。
この記事は「いくつかの方法を試して比べてみた記録」で、「これが唯一の正解」と断定するものではありません。それぞれトレードオフがあり、どれを選ぶかは扱う秘密の重要度や許容できるリスク次第です。採用する場合は内容を理解した上で自己責任でお願いします。間違いやより良い方法があればコメントで指摘してもらえると嬉しいです。
前提:ローカル MCP は op run で包めば隔離できている
まず出発点の確認です。Claude Code の MCP には大きく 2 種類あります。
ローカル MCP(自分のマシンでサーバープロセスを立てる)
{
"mcpServers": {
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {
"CONTEXT7_API_KEY": "ctx7sk-xxxxxxxx"
}
}
}
}
リモート MCP(外部にホストされた MCP に HTTP で繋ぐ)
{
"mcpServers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "ctx7sk-xxxxxxxx"
}
}
}
}
どちらも認証情報が平文で書かれているのが気になります。
ローカル MCP は起動コマンドを自由に指定できるので、op run でラップすれば設定ファイルから秘密を消せます。
{
"mcpServers": {
"context7": {
"command": "op",
"args": ["run", "--", "npx", "-y", "@upstash/context7-mcp"],
"env": {
"CONTEXT7_API_KEY": "op://Private/context7/credential"
}
}
}
}
ポイントは、context7 が自分のプロセスの環境変数(process.env.CONTEXT7_API_KEY)を自分で読むという点です。op run が op://... を解決して、本物の値を子プロセス(npx)の環境変数として渡してくれます。
しかもこの方式は MCP ごとに op run が独立しているので、ある MCP に渡した秘密が他の MCP から見えることはありません。隔離が効いています。
問題はリモート MCP です。
対策A:環境変数に展開してから claude を起動する
リモート MCP は Claude Code 本体が HTTP リクエストを飛ばすので、op run で包むべき子プロセスが存在しません。headers の値に op://... と書いても、ただの文字列としてヘッダーに乗るだけです。
そこで発想を変えて、Claude Code の起動時に環境変数を解決しておくことにします。シェルの設定ファイル(zsh なら ~/.zshrc、bash なら ~/.bashrc など)に claude 関数を定義します。
function claude() {
CONTEXT7_API_KEY=$(op read "op://Private/context7/credential") \
GITHUB_PERSONAL_ACCESS_TOKEN=$(op read "op://Private/github-pat/credential") \
/path/to/claude "$@"
}
fish など関数定義の構文が違うシェルを使っている場合は、それぞれの書き方に読み替えてください。「claude 起動時に op read の結果を環境変数に入れて本体を呼ぶ」という考え方は同じです。なお Windows ネイティブ環境でも、現状 Claude Code の起動には Git Bash が必要なので、~/.bashrc に bash 関数を定義する形でそのまま使えます。
設定ファイル側は ${...} で環境変数を参照します。
{
"mcpServers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp",
"headers": {
"Authorization": "Bearer ${CONTEXT7_API_KEY}"
}
}
}
}
Claude Code は headers の ${...} を起動時の環境変数で展開してくれるので、これでリモート MCP の認証ヘッダーに 1Password の値が流れます。ローカル MCP の秘密も同じ env に入れておけば op run すら不要になり、設定はスッキリします。
対策A の問題:全ローカル MCP に秘密が漏れる
ここに落とし穴があります。
この方式はすべての秘密を claude プロセスの環境変数に注入します。そして Claude Code が起動するローカル MCP(stdio)は、すべて claude の子プロセスなので親の環境変数を継承します。
つまり、
- MCP-A 用に入れたトークンを MCP-B も
process.envで読める - 悪意ある/侵害されたローカル MCP が、env 内の全シークレットをまるごと吸い出せる
「ローカル MCP も env にまとめてスッキリ」は、せっかく op run で効いていた隔離を捨てていることになります。これは OS のプロセスモデル上の話で、op 固有の穴ではなく「環境変数で秘密を渡す方式」全般の性質です。
リモート対応と引き換えに隔離を失ってしまいました。次はこれを取り戻します。
対策B:ローカル中継 MCP(mcp-remote)を op run で包む
Claude Code とリモート MCP の間に、中継するだけのローカル stdio MCP を挟むのはどうでしょう。
claude ──stdio──▶ op run(mcp-remote)──HTTP+Bearer──▶ リモート MCP
└─ ここだけに秘密が注入される
中継プロセスは普通の command 起動なので、ローカル MCP と同じように op run で包めます。すると秘密は中継プロセスの環境変数だけに閉じ込められ、claude 本体の env はクリーンなまま。対策A で失った隔離が復活します。
中継には mcp-remote という、まさに「ローカル stdio ⇔ リモート MCP を橋渡しする」ツールを使います。
{
"mcpServers": {
"context7": {
"command": "op",
"args": [
"run", "--",
"npx", "mcp-remote",
"https://mcp.context7.com/mcp",
"--header", "Authorization:Bearer ${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "op://Private/context7/credential"
}
}
}
}
なぜこれで動くのか(展開の順序が肝)
この構成で気になるのは、${AUTH_HEADER} を誰が展開するのか、という点です。
Claude Code は command / args / env / url / headers のすべてで ${VAR} を自分のプロセスの環境変数で展開します。だとすると、args に書いた ${AUTH_HEADER} も claude の env で展開されてしまい、op run が中継プロセスに値を渡すより先に奪われて壊れるはずです。
実際に Claude Code で挙動を確認したところ、こうなっていました。
- 定義済みの
${VAR}→ 展開される - 未定義の
${VAR}→ 展開されず、そのままリテラルで子プロセスに渡る(空文字にもならず、エラーにもならない)
検証メモ:argv をダンプするだけの stdio サーバーを .mcp.json に登録して claude mcp get で spawn させ、受け取った引数を観察しました。DEF:${HOME} は DEF:/Users/... に展開され、UNDEF:${UNDEFINED} は UNDEF:${UNDEFINED} のまま渡っていました。
そして mcp-remote 側のソースを見ると、--header の値の ${VAR} を自分の process.env で展開するロジックがありました。
headers[key] = value.replace(/\$\{([^}]+)}/g, (match, envVarName) => {
const envVarValue = process.env[envVarName]
return envVarValue !== undefined ? envVarValue : '' // 未定義なら空
})
整理すると、次の流れで解決されます。
- Claude Code が
argsの${AUTH_HEADER}を展開しようとするが、claude 本体の env には無いのでリテラルのまま素通し -
envのop://...(純粋な参照)はそのままop runに渡る -
op runがop://を解決し、mcp-remoteの env にAUTH_HEADER=本物のトークンをセット -
mcp-remoteが自分の env で${AUTH_HEADER}を展開し、ヘッダーを組み立ててリモートへ送信
秘密が乗るのは mcp-remote のプロセス env だけで、claude 本体の env はクリーンなままなので、他のローカル MCP からは見えない、という狙いです。ローカル MCP(context7)が「自分の env を自分で読む」のと同じことを、mcp-remote がリモート MCP の代理としてやってくれている、という構図です。
この方法が成立する根拠(未定義 ${VAR} のリテラル素通し / mcp-remote 側の ${VAR} 展開 / op run の op:// 解決)は個別に確認しましたが、実際のリモート MCP で認証が通るところまでのエンドツーエンドは未検証です。導入前に自分の環境で接続を確認してください(mcp-remote のバージョンで挙動が変わる可能性もあります)。
注意点
- 環境変数名を claude 本体の env と被らせないこと。同名の変数を
claude関数(対策A)などで claude の env にも export していると、手順1で Claude Code が先に展開してしまい、秘密が claude の env に乗って隔離が崩れます。 -
--headerの値にスペースが含まれると、一部のクライアントがnpxを呼ぶときにスペースをうまくエスケープできず値が壊れる問題が、mcp-remoteの README で触れられています。気になる場合は--header "Authorization:${AUTH_HEADER}"と書き、env側を"AUTH_HEADER": "Bearer ..."にしてスペースを env 値に逃がす回避策があります(ただしこの形だと env 値にop://を埋め込む必要があり、別の考慮が要ります)。
対策B の問題:mcp-remote が侵害されたら?
隔離はできました。が、新しいリスクが増えます。
mcp-remote は「トークンを保持する」かつ「外向き HTTP を正当に出す」を両立した、攻撃者にとって理想的な情報持ち出しポイントです。バージョンを指定しない npx mcp-remote は毎回その時点の最新版を取得するので、アカウント乗っ取りや悪意あるバージョンが公開されると、それをそのまま取得してしまいます。
これは mcp-remote 固有の話ではなく、「静的トークンをローカルのコードに持たせる方式」すべての宿命です。対策A の op run -- npx context7 でも、context7 のパッケージが侵害されればトークンは抜けます。ただし mcp-remote は中継のためだけに信頼チェーンを 1 つ増やすぶん、的が増えるのは事実です。
緩和策として、バージョン固定は有効です。
"args": ["run", "--", "npx", "mcp-remote@0.1.38", "https://..."]
npx mcp-remote のようにバージョンを指定しないと毎回 npm から最新版を取得してしまうので、自分で動作確認したバージョンに固定すれば、悪性の新バージョンが自動で混入することはなくなります。さらに堅くするならローカルに固定インストールして lockfile で integrity hash を固定し、絶対パスで参照します。
ただし固定はあくまで自動混入を防ぐだけで、固定したそのバージョン自体が秘密を持つ事実は変わりません。脆弱性パッチが自動で来なくなるので、定期的な監査・手動更新の運用とセットになります。
そもそも静的なトークンが存在すること自体が根本のリスク源です。だからこそ、もし選べるなら次の選択肢が一番です。
(補足)OAuth が使えるなら OAuth が一番
ここまで「Bearer 直渡ししかない」前提で話してきましたが、もし対象のリモート MCP が OAuth を提供しているなら、迷わずそちらを選んでください。Claude Code は OAuth 認証フローをネイティブ対応しています。
# OAuth 対応サーバーを追加(トークン不要)
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp
あとは Claude Code 内で /mcp を実行すると、ブラウザが開いて OAuth フローが走ります。
- サーバーが
401/403を返すと Claude Code が「認証が必要」と判定 -
WWW-Authenticateヘッダーが指す認可サーバーへ自動でフロー開始 - 取得したトークンは OS の資格情報ストア(macOS は Keychain、Windows は資格情報マネージャー、Linux は libsecret など)に保存され、設定ファイルには書かれない・自動更新される
静的トークンが存在しないので、そもそも盗む対象がありません(盗まれても短命)。中継プロセスも op も不要で、サプライチェーンリスクも消えます。
ただ、冒頭で書いたとおり OAuth を出せないリモート MCP は普通に存在します。自前ホストの MCP に OAuth を実装するのはそれなりに大仕事ですし、サードパーティが対応していなければどうしようもありません。そういうときに、ここまでの対策B(mcp-remote を op run で包む)が効いてきます。
(補足)公式で楽に・安全に扱えるようになるかも
この記事の方法は、いわば「Claude Code / 1Password が今のところ用意していない部分を、自分で組み合わせて埋める」やり方です。将来的に公式側で対応される可能性もあるので、最新状況は確認するのをおすすめします。
調べた範囲では(2026年5月時点)こんな状況でした。
- Claude Code 側:設定ファイルの env セクションで
op://...のような 1Password シークレット参照をネイティブに解決してほしい、という Feature Request が上がっています(Issue #23642、執筆時点で open)。これが実装されて、さらにheadersにも効くようになれば、ラッパー関数も中継 MCP も要らずにop://を直接書けるようになるかもしれません。 - 過去にあった「
.mcp.jsonのheadersの${VAR}が展開されない」というバグ(#6204 / #51581)はすでに修正済みで、現在はヘッダーの${VAR}展開は動きます(対策A の前提)。 - 1Password 側:MCP サーバーの認証情報管理について公式ブログがありますが、推奨されているのは
op runで環境変数として注入する汎用パターンで、ヘッダー認証のリモート MCP に特化した専用機能は今のところ見当たりませんでした。
公式の正攻法はまだ整備途中で、この記事の方法は現時点の回避策という位置づけです。
まとめ
「ヘッダーで静的トークンを渡すしかないリモート MCP」を 1Password で安全に使う方法を、段階的に詰めていった記録でした。
| 方法 | 隔離 | 残るリスク | |
|---|---|---|---|
| 対策A | env に展開して claude 起動 | ✕ 全ローカル MCP に漏れる | 隔離ゼロ |
| 対策B | mcp-remote を op run で包む |
〇 claude env はクリーン | 中継のサプライチェーン(固定で緩和) |
| (補足)OAuth | サーバーが対応していれば | 〇 静的トークン自体が無い | ほぼなし |
- ローカル MCP は元から
op runで包めて隔離できている - リモート MCP を env 注入(対策A)で対応すると、隔離を失う
- 中継 MCP(対策B)なら、ヘッダー認証のリモート MCP でも隔離を保ったまま 1Password で秘密を管理できる
- 対策B はバージョン固定と最小権限トークンとセットで運用する
- もし対象が OAuth を出せるなら、そちらのほうが根本的に安全(でも出せないサーバーは普通にある)
静的トークンを claude に使わせる以上、それを保持するローカルコードには必ず晒されます。この構造を理解した上で、mcp-remote という 1 点に絞って op で隔離する、というのが現実的な落とし所だと思います。