ローカルMCPサーバーの安全な運用
開発者のマシン上で動くローカル MCP サーバー (主に stdio) を、安全に導入・開発・運用するための指針を解説します。OAuth やトークンの扱いのようなプロトコルの脅威モデルは MCPセキュリティベストプラクティス で扱い、ここでは「自分のマシンで他人のコードを動かす」という別の脅威モデルを対象にします。
1. このドキュメントで扱うこと
- ローカルサーバーが持つ権限と、stdio の信頼モデル
- 想定する脅威
- そもそもローカルで動かす必要があるか
- 導入前の出所の確認
- ツール定義を信頼できない入力として扱う
- 起動コマンドの同意 (SEP-1024)
- 隔離された環境で実行する
- 認証情報の分離
- ファイルシステムへのアクセスの制限
- ネットワークの出口の制御
- localhost の HTTP サーバーの保護 (DNS rebinding と Origin 検証)
- サーバー開発者の注意 (stdout の扱い、パストラバーサル)
- 組織での管理
ローカルサーバーが持つ権限と、stdio の信頼モデル
ローカル MCP サーバーは、プロトコルが提供するサンドボックスの中で動くプラグインではありません。ホストアプリケーションが起動する、ふつうの OS のプロセスです。そのため次のものに触れます。
stdio では、クライアントがサーバーを子プロセスとして起動し、stdin と stdout で通信します。この設計は、クライアントと stdio サーバーが 1 つの信頼ドメインを共有することを前提にしています。両者は同じ権限で並んで動き、サーバーは起動された時点で、すでにマシン上でコードを実行できます。トランスポートが守るべき境界がないため、プロトコルも SDK も、一方を他方から守りません。軽減された権限でサーバーを動かすなら、その境界は、利用者自身がコンテナー、サンドボックス、VM で作ります。
実際の安全上の判断は、起動の前に行われます。何を入れるか (出所) と どう動かすか (隔離) です。さらに、設定したすべてのサーバーは同じモデルのコンテキストを共有します。悪意あるサーバーは、他のすべてのサーバーのツールの使われ方に影響を与え得るため、サーバーを 1 つ足すたびに、他のサーバーが受ける攻撃面も広がります。
想定する脅威
- 悪意あるサーバー: タイポスクワッティングされたパッケージ名、そっくりなリポジトリ、放棄されたプロジェクトを引き継いだ新しい公開者。宣伝どおりに動きつつ、環境変数やファイルを持ち出す
- 集めすぎるサーバー: 正規のサーバーが、仕事に必要な範囲を超えて環境変数、ファイル、通信を収集する。信頼できない内容へのプロンプトインジェクションで、正直なサーバーが与えられた権限を使って同じことをする場合もある
- 侵害された依存関係: サーバーの作者は誠実でも、依存パッケージのどれかがサプライチェーン攻撃で侵害されている
- 汚染されたツールカタログ: ツール名と description は、モデルが読む指示。サーバーは、そこに指示を隠し、他のサーバーのツールの使い方も含めてエージェントを誘導できる (ツール汚染、ツールシャドウイング)。承認後に定義を書き換える Rug pull もあるため、導入時の同意とパッケージの固定だけでは、この面を守れない (詳細は AIエージェント特有の脅威)
- 脆弱なサーバーやブリッジ: 正規で改変のないコンポーネントも入り口になる。stdio のクライアントとリモートサーバーをつなぐローカルのブリッジには、悪意あるエンドポイントへ接続しただけで発動する重大な欠陥が見つかった例がある。ポートで待ち受けるローカルの開発ツールが、Web ページから直接攻撃された例もある
対象外なのは、物理的にマシンへアクセスできる攻撃者、標的型の国家レベルの攻撃、プロトコル層の攻撃です。
最初に、すべての対策の前提となる事実があります。サーバーが何をするかを、事前に証明する検査は存在しません。読みやすいリポジトリ、署名、SBOM、キュレーションされた一覧は、誰を信頼しているかを教えてくれますが、その信頼が妥当かどうかは教えません。すべてのリリースを自分で監査してビルドしない限り、挙動は事前に検証できません。
そのため、対策は 1 つの原則に従います。サードパーティのローカルサーバーは、渡した権限だけを持つ信頼できないコードとして扱い、渡す権限を最小にする。審査は悪い信頼判断の確率を下げ、封じ込めは悪い判断の代償を決めます。
そもそもローカルで動かす必要があるか
被害の範囲を縮める最も安い方法は、被害の範囲を作らないことです。サーバーをローカルプロセスとして入れる前に、マシンから何が必要かを問います。作業ツリーの読み取り、ローカルのツールの操作、ハードウェアへのアクセスは、ローカルで動かす理由になります。リモートの API をラップするだけのサーバーは、ローカルで動かしても得るものがありません。
- リモートの版があるなら、そちらを使う。Streamable HTTP で提供されるサーバーは、自分のマシンでコードを動かさず、ローカルの設定ファイルにトークンを置く代わりに、プロトコルの認可フローを使い、修正が 1 か所で配られる。こちらには、プロトコルの脅威モデルが当てはまる
- ローカルのアクセスが不要なのに、stdio のプロセスとしてしか提供されないサードパーティのサーバーには、理由を問う。作者にとって楽な経路にすぎないこともあるが、悪意あるサーバー、集めすぎるサーバーが入ってくる経路でもある
- ローカルのプロセスとしてしか成立しない機能もある。その場合は、以降の対策をすべて適用する
迷ったら、ファイル、ローカルのツール、ローカルのネットワークのどれも必要としないサーバーは、リモートの版を使い、ローカルへのインストールをやめます。
導入前の出所の確認
レジストリに載っていることは、推奨を意味しません。Docker Hub、ghcr.io、npm、PyPI、NuGet は、ほとんど何でもホストします。人気や、もっともらしい名前は、安全性も、最新であることも保証しません。キュレーションされた MCP カタログでさえ、審査は最善努力だと説明しており、実行するものに対する責任までは負いません。
サーバーを入れる前に、次を確認します。
- 公開者を特定する: パッケージを、ソースリポジトリと、実在する保守者や組織に結びつけられるか。リポジトリに、最近の、もっともらしい活動があるか
- パッケージ名を正確に確認する: タイポスクワッティングは、最も安い攻撃。ドキュメントが実際に指しているパッケージか、そっくりな別物でないかを確かめる
- バージョンを固定する:
latestではなく特定のバージョンを入れ、将来の侵害されたリリースが自動更新で入ってこないようにする。固定は、計画的な更新の習慣と対にする。固定したものに脆弱性情報が出たとき、固定は脆弱なままにしてしまう - 固定が覆う範囲を知る:
npxやuvxのようなランチャーでパッケージのバージョンを固定しても、固定されるのは最上位のパッケージだけで、推移的な依存関係は通常インストール時に新しく解決される。侵害された依存関係は、まさにここから入る。コンテナーイメージをダイジェスト (image@sha256:...) で固定すると、依存ツリーの全体が固定される - 来歴 (provenance) のメタデータがあるものを選ぶ: 署名された成果物、ビルドの証明 (attestation)、SBOM は、誰がビルドし、何が入っているかを検証させてくれる。どの MCP SDK のどのバージョンを使っているかもわかり、SDK に脆弱性が公表されたときに役立つ
さらに踏み込むなら、署名や attestation の存在を確認するだけでなく、実際に検証します (Sigstore のツール、レジストリの検証機能)。SBOM は、公開されたものを信頼せず、成果物から自分で生成します (Syft や Trivy)。実行時に署名を検証できるレジストリやランタイムなら、来歴を、導入時だけの確認から継続的な確認に変えられます。
迷ったら、ソースコードを読めて、公開者を特定できるサーバーだけを入れ、バージョンを正確に固定します。
ツール定義を信頼できない入力として扱う
サーバーを入れることは、最後の信頼判断ではありません。実行時には、サーバーがツール名と description をクライアントへ送り、モデルがそれを指示として読みます。導入前の審査は、この注入の面を覆いません。
- サーバーに頼る前に、ツールの一覧を読む。多くのクライアントは各ツールの description を表示できる。MCP Inspector は、モデルを介さずに、サーバーが公開するすべてのツールを列挙する。ツールの役割と無関係な指示が description に含まれていたら、そのサーバーを削除する
- ツール定義を固定するクライアントを選ぶ。最初に承認したときに定義を記録し、変更されたら再び確認するクライアントがある。プロトコルは、これを要求しない。そのため、クライアントを選ぶときの性質として重要になる
- サーバーの数を少なく保つ。設定したすべてのサーバーがモデルのコンテキストを共有するため、足すたびに、他のサーバーが受ける攻撃面が広がる
迷ったら、サーバーを少数入れ、ツールの description を 1 度読み、定義が変わったら再確認するクライアントを選びます。
起動コマンドの同意 (SEP-1024)
クライアントがワンクリックでローカルサーバーを導入できるなら、コマンドを実行する前に、適切な同意の仕組みを実装することが必須です (SEP-1024)。クライアントは、次をします。
- 実行されるコマンドを、省略せずに (引数やパラメーターも含めて) そのまま表示する
- 利用者のシステム上でコードを実行する、危険な可能性のある操作であることを明示する
- 続行する前に、利用者の明示的な承認を求める
- 利用者が設定をキャンセルできるようにする
加えて、クライアントには、次の追加のチェックとガードレールが推奨されます (SHOULD)。
- 危険なコマンドのパターン (
sudo、rm -rf、ネットワーク操作、想定外のディレクトリへのファイルシステムアクセス) を強調する - 機微な場所 (ホームディレクトリ、SSH 鍵、システムディレクトリ) にアクセスするコマンドに、警告を表示する
- MCP サーバーが、クライアントと同じ権限で動くことを警告する
- サーバーのコマンドを、最小の既定の権限を持つサンドボックスで実行する
- サーバーによるファイルシステム、ネットワーク、他のシステムリソースへのアクセスを制限する
- 利用者が、必要に応じて追加の権限 (特定のディレクトリ、ネットワーク) を明示的に付与できるようにする
- コンテナー、chroot、アプリケーションのサンドボックスなど、プラットフォームに合った技術を使い、新たな脆弱性に備えてサンドボックスを最新に保つ
同意のダイアログは、見るべき点を知っている利用者にしか役立ちません。以降の節がその「見るべき点」です。
隔離された環境で実行する
プロセスの隔離は、このドキュメントで最も効果の大きい対策です。「このサーバーは私の SSH 鍵を読める」を「このサーバーは空のディレクトリを読める」に変え、出所の確認が失敗した場合でも、上のすべての脅威に対して同時に効きます。
- 基本: サードパーティのサーバーを、必要なもの以外にボリュームをマウントしないコンテナー (Docker や Podman) で実行する。stdio トランスポートはそのまま動き、クライアントは、素のプロセスと同じように、コンテナーの stdin と stdout と通信する
- 代替: 開発専用の軽量な仮想マシン、OS のプロセスのサンドボックス機能。MCP 向けのランタイムやクライアントの統合には、サーバーを自動でコンテナーに包むものもある。重要なのはパターンで、ツールではない
コンテナーは破れない境界ではありませんが、既定で環境、ファイル、キーチェーンにアクセスできる状態をなくします。よくある攻撃が頼っているのは、まさにそれです。
さらに踏み込むなら、seccomp プロファイルでシステムコールを制限し、AppArmor や SELinux で到達できる範囲を制限します (多くはディストリビューションの既定を有効にするだけです)。さらに、microVM や別のユーザーアカウントで動かし、次のネットワーク制御と組み合わせて、サンドボックスが、見える少量の情報すら持ち出せないようにします。
迷ったら、サードパーティのサーバーはすべて、作業対象のディレクトリ以外は何もマウントしないコンテナーで動かします。
認証情報の分離
ローカルで起動されたサーバーは、起動したプロセスの環境を引き継ぐことがあります。シェルのプロファイルでクラウドの認証情報や API トークンをグローバルに export していると、動かすすべてのサーバーがそれらを読めます。
- シークレットは、グローバルではなくサーバーごとに渡す。シェルのプロファイルで export する代わりに、クライアントの設定ファイルにある、サーバーごとの環境変数の設定を使い、各サーバーが自分の認証情報だけを見るようにする
- トークンのスコープを絞る。サーバーごとに、動く範囲で最小のスコープのトークンを発行する (アカウント全体のトークンではなく、1 つのリポジトリに限った細かい粒度のアクセストークンなど)。漏れても被害が限られ、失効できる
- 長期のシークレットを、平文の設定に置かない。クライアントの設定ファイルは、ディスク上の既知の予測しやすい場所にある。クライアントが対応していれば、OS のシークレットストア (macOS の Keychain、Windows の Credential Manager、Linux の freedesktop Secret Service) を使う
- ファイルも認証情報に含まれる。
~/.ssh、~/.awsのようなディレクトリは、ディスク上の認証情報。これらをサーバーから実際に遠ざけるのは、前節のプロセスの隔離
さらに踏み込むなら、静的なトークンの代わりに、シークレットマネージャーやブローカーが起動時に発行する短命の認証情報を使い、サーバーを外すと決めたら、そのサーバーが持っていたものをローテーションします。迷ったら、サーバーごとに範囲を絞ったトークンを、サーバーごとの設定で渡し、個人の広い権限のトークンを、複数のサーバーで使い回しません。
ファイルシステムへのアクセスの制限
多くの有用なサーバーは、正当にファイルへのアクセスを必要とします。問題は、どこまでかです。
- 動く範囲で最も狭いルートを与える。ファイル系のサーバーには、特定のプロジェクトのディレクトリを、サーバーの設定かツールのパラメーターで明示的に渡す。ホームディレクトリは渡さない
- 読み取り専用を優先する。コードやドキュメントを読むだけのサーバーなら、ディレクトリを読み取り専用でマウントまたは許可する
- 範囲の広さを疑う。狭い仕事のために広いルートディレクトリを要求するサーバーは、やりすぎ。狭いディレクトリを与えるか、別のサーバーを選ぶ
設定レベルの allowlist は、協調的な境界です。行儀のよいサーバーは守りますが、悪意あるサーバーや侵害されたサーバーが、プロセスが到達できるものを読むことを、プロトコルは止めません。誠実なサーバーのためのガードレールとして扱い、強制力は、許可されたディレクトリだけがファイルシステムとして存在する、サンドボックスやコンテナーの隔離から得ます。
なお、2026-07-28 版では Roots が非推奨になりました。許可するディレクトリは、設定やツールの引数で制御します。迷ったら、1 つのプロジェクトのディレクトリを、読み取り専用で許可します。
ネットワークの出口の制御
持ち出しには出口が必要です。ローカルのプロセスは、通常、外向きの通信が無制限で、集めすぎや侵害をそのままデータの流出に変えます。
- サーバーがどこと通信するはずかを把握する。1 つの API をラップするサーバーの宛先は、短く予測できる。それ以外の宛先への通信は、調べる価値のあるシグナル
- 不要ならネットワークを拒否する。ローカルのファイルやツールだけで動くサーバーは、ネットワークが不要。コンテナーなら 1 つのフラグ (
--network=none) で済む - 残りをフィルターする。出口が必要なサーバーには、外向きを絞るファイアウォールや、ドメインの allowlist を持つ egress プロキシで、想定する宛先に限り、範囲外の試行を記録する
出口だけがネットワークの問題ではありません。ポートで待ち受けるローカルの MCP の仕組み (リモートサーバーへのブリッジ、開発ツール、ローカルでホストされた HTTP サーバー) は、他のローカルプロセスから、さらに利用者のブラウザーを通じて Web サイトからも到達できます。127.0.0.1 に bind することは、認証の境界ではありません。これらには本物の認証と Origin の検証が必要で、脆弱性情報が出たら速やかに更新します。
さらに踏み込むなら、サーバーごとに、プロキシで egress の allowlist を持たせ、範囲外の宛先に警報を出します。予期しない出口は、このドキュメントのどこかが破られた最初の兆候になることが多いからです。迷ったら、必要性が示されない限りネットワークなしで動かし、残りのサーバーが到達する宛先を把握します。
localhost の HTTP サーバーの保護 (DNS rebinding と Origin 検証)
ローカルで動く MCP サーバーが HTTP (Streamable HTTP) で待ち受ける場合、悪意ある Web ページが、DNS rebinding を使い、利用者のブラウザーを足がかりにそのサーバーへリクエストできます。仕様は、Streamable HTTP のサーバーに、次を求めています。
- すべての接続で
Originヘッダーを検証する (MUST)。Originがあって不正なら、HTTP 403 Forbidden で応答する - ローカルで動かすときは、
0.0.0.0(すべてのインターフェース) ではなく、localhost (127.0.0.1) だけに bind する (SHOULD) - すべての接続に、適切な認証を実装する (SHOULD)
これらがないと、攻撃者は、リモートの Web サイトからローカルの MCP サーバーとやりとりできます。サーバー開発者は、ローカルの利用を意図するなら、不正なプロセスからの利用を、次のように防ぎます (SHOULD)。
- stdio トランスポートを使い、アクセスを MCP クライアントだけに限る
- HTTP トランスポートを使うなら、アクセスを制限する。たとえば、認可トークンを要求する、アクセスを制限した Unix ドメインソケットなどのプロセス間通信 (IPC) を使う
サーバー開発者の注意
ローカルサーバーを作る側が守ることもあります。
- stdout を汚さない: stdio のサーバーは、有効な MCP メッセージ以外を stdout に書いてはいけません (MUST NOT)。ログは stderr に出します (MAY)。デバッグ用の
printや、既定で stdout に出すロガーは、プロトコルを壊すだけでなく、内容が通信に混ざります。デバッグとログの詳細は MCPのテストとデバッグ を参照してください - 認証情報をログに出さない: 環境変数から受け取ったトークンや、ツールの引数に含まれる秘匿情報を、stderr やファイルへ書かない
- パストラバーサルを防ぐ: ファイル系のサーバーは、ツール引数のパスを、許可されたディレクトリの内側に閉じ込めます。
..やシンボリックリンクによる脱出を、文字列の検査ではなく、OS の機能で防ぐのが確実です (コード例を参照) - 必要最小限の権限で動く: root で動かさず、ファイルとネットワークの機能を絞ります
- 成果物の来歴を提供する: バージョン固定されたリリース、署名、SBOM を公開し、利用者が検証できるようにします
- ローカルのブリッジや開発ツールを、HTTP で公開しない、公開するなら認証を付ける
組織での管理
IT、セキュリティ、プラットフォームの管理者にとっては、問題が違います。多数のマシン、多数の利用者、管理者の関与なしに入るサーバーです。次の 2 つの事実が前提になります。
- ローカル MCP サーバーは、利用者がインストールする、ふつうのソフトウェア: プロトコルは、自分が管理していないマシン上のプロセスを、棚卸し、パッチ適用、封じ込めできない。これは、新しいパッケージの種類を持つ、既存のエンドポイントとソフトウェアの管理
- 事前に相談はされない: 利用者は、エディターの拡張機能と同じようにサーバーを入れると想定する
現実的な出発点は次のとおりです。
- 承認済みの出所を定める: 審査済みのサーバー (バージョン固定) の短い allowlist と、新しいサーバーが満たす基準 (特定できる公開者、読めるソース、来歴のメタデータ、SBOM) を公開する。承認された経路を、そうでない経路より簡単にする
- 既定の実行環境を決める: 「サードパーティのサーバーは、ホームディレクトリをマウントしないコンテナーで動かす」のように、隔離を各利用者の自主性に任せず、文書化する
- ローカルのアクセスが不要なものは、中央に置く: SaaS の API をラップするだけのサーバーは、全員のノート PC で動かす必要がない。中央で管理されたリモートのデプロイなら、数百の代わりに 1 つのインスタンスを、パッチ、監査、制御できる
- 継続的に棚卸しする: クライアントの設定ファイルは、プラットフォームごとに既知の場所にある。エンドポイント管理のツールで収集すれば、誰が、どのバージョンを、どの出所から動かしているかに、常に答えられる
- 監査証跡を残す: 管理対象のマシンで、どのサーバーの、どのツールを、いつ使ったかを記録する。侵害されたサーバーが実際に何をしたかを、権限からの推測ではなく再現できる
- 組織全体のインシデントに備える: 広く使われているローカルサーバー (またはその中の SDK) に脆弱性が見つかったときに、すべてのインスタンスの特定 (棚卸し)、プロキシやファイアウォールでの egress の遮断、設定が渡したすべての認証情報のローテーション、修正された固定バージョンの承認済み経路での配布をする。SBOM は、「影響を受ける SDK のバージョンで作られたものを動かしているマシンはどれか」に、数日ではなく数分で答えさせる
- 規制の文脈を意識する: EU の Cyber Resilience Act のようなソフトウェアのサプライチェーンの規制は、SBOM、更新の手順、脆弱性の取り扱いのような、まさにこれらの実践を求める傾向がある
迷ったら、組織全体のクライアント設定の棚卸しと、承認済みサーバーの短い allowlist から始め、その両方に入らないものを、未承認のソフトウェアとして扱います。
2. ローカルサーバーの防御レイヤー
| レイヤー | 対策 | 担当 |
|---|---|---|
| 配置 | リモートの版があればそちらを使う。ローカルが必要か問う | 利用者 / 管理者 |
| 導入時 | コマンドの全文表示と明示的な同意、出所と公開者の確認 | クライアント / 利用者 |
| 配布物 | バージョン固定 (コンテナーはダイジェスト)、署名、SBOM | サーバー開発者 / 利用者 |
| ツール定義 | 一覧の確認、変更時の再承認、サーバー数の最小化 | クライアント / 利用者 |
| 実行時 | コンテナー、サンドボックス、最小権限 | クライアント / 利用者 |
| 認証情報 | サーバーごとの環境変数、範囲を絞ったトークン、シークレットストア、ログに出さない | 利用者 / サーバー開発者 |
| ファイル | 狭いディレクトリの読み取り専用、強制は隔離で行う | 利用者 / サーバー開発者 |
| ネットワーク | 不要なら遮断、必要なら egress の allowlist | 利用者 / 管理者 |
| 通信 | stdio、または認証付きの localhost・IPC、Origin の検証 | サーバー開発者 |
| 組織 | 承認済み一覧、棚卸し、監査ログ、インシデント時の対応 | 管理者 |
3. 開発者がつまずきやすいポイント
同意ダイアログを承認したから安全だと考える
同意は、コマンドの全文を見て、実行してよいと判断する場です。コマンドを読んでも、パッケージの中身や依存関係の挙動はわかりません。同意は出所の確認と隔離の代わりにはなりません。
npx -y パッケージ名 をそのまま使う
バージョンを固定していないと、実行のたびに最新のものが入り、将来の侵害されたリリースが自動で入ってきます。バージョンを固定しても、推移的な依存関係は固定されません。依存ツリーまで固定したいなら、コンテナーイメージをダイジェストで固定します。
固定したまま更新しない
バージョンの固定は、脆弱性が公表されても、脆弱なバージョンを使い続けることにもなります。更新の習慣を決め、脆弱性情報を購読します。
シェルのプロファイルにトークンを export している
そのシェルから起動されるすべてのサーバーが、すべてのトークンを読めます。サーバーごとの環境変数の設定で、必要なものだけを渡します。
設定の allowlist でファイルを守れたと思う
設定のディレクトリ制限は、協調的な境界です。悪意あるサーバーは、それを無視できます。強制は、サンドボックスやコンテナーのマウントで行います。
127.0.0.1 に bind したから安全だと考える
同じマシン上の他のプロセスと、ブラウザーの Web ページから到達できます。認証と Origin の検証が必要です。
stdio のサーバーを print でデバッグする
stdout への出力は、MCP のメッセージとして解釈されます。クライアントが接続できない、応答が壊れる原因になります。ログは stderr に出します。
文字列の検査でパストラバーサルを防ぐ
.. を含むかどうかの検査は、シンボリックリンクや、エンコーディングによる抜け道で破られます。OS やライブラリが提供する、ディレクトリの内側に閉じ込める機能を使います。
使わなくなったサーバーを残す
使わないサーバーは、攻撃面だけを増やします。設定から外し、そのサーバーに渡していた認証情報をローテーションします。
4. コード例
バージョンを固定して導入する
# npm: 最上位のパッケージのバージョンを固定する (推移的な依存は固定されない)
npx -y @example/[email protected]
# Python: uvx でバージョンを固定する
uvx example-mcp-server==1.4.2
# コンテナー: ダイジェストで固定すると、依存ツリーの全体が固定される
docker pull ghcr.io/example/mcp-server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
来歴と SBOM を確認する
# npm のレジストリ署名と、来歴の証明を検証する (プロジェクトに入れたパッケージの対象)
npm audit signatures
# 公開された SBOM を信頼せず、成果物から自分で生成する
syft ghcr.io/example/mcp-server@sha256:0123... -o spdx-json > sbom.spdx.json
# 脆弱性をスキャンする
trivy image ghcr.io/example/mcp-server@sha256:0123...
ツールの一覧を、モデルを介さずに確認する
# MCP Inspector で、サーバーが公開するツールの description と引数を読む
npx @modelcontextprotocol/inspector
コンテナーで隔離して起動する (stdio)
ネットワークなし、読み取り専用のルートファイルシステム、1 つのプロジェクトのディレクトリだけを読み取り専用でマウントします。-i で stdin を開いたままにすると、stdio のままクライアントと通信できます。
docker run --rm -i \
--network=none \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--user 65532:65532 \
--memory=512m --pids-limit=128 \
-v "$PWD/docs:/workspace:ro" \
ghcr.io/example/mcp-fs-server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
/workspace
クライアントの設定でサーバーごとに認証情報を渡す
クライアントの設定ファイル (mcpServers) で、コンテナーを起動するコマンドと、サーバーごとの環境変数を指定します。-e GITHUB_TOKEN のように値を書かずに名前だけを渡すと、値は env の設定から引き継がれ、設定ファイルや引数に平文で書き込まれません。ネットワークが必要なサーバーは、ここでは --network=none を外し、egress を別途制限します。
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"-e", "GITHUB_TOKEN",
"ghcr.io/example/github-mcp-server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
],
"env": {
"GITHUB_TOKEN": "${GITHUB_MCP_TOKEN}"
}
}
}
}
${GITHUB_MCP_TOKEN} のような環境変数の展開に対応するかどうかは、クライアントによります。対応していなければ、OS のシークレットストアを参照する仕組みを使うか、設定ファイルの権限を利用者だけに絞ります。この例のトークンは、1 つのリポジトリに限った、細かい粒度のトークンにします。
localhost の HTTP サーバーを保護する (Go)
Host と Origin を検証し、ベアラートークンを要求し、127.0.0.1 だけに bind します。Origin を持たないブラウザー以外のクライアントは、トークンで認証します。
package main
import (
"crypto/subtle"
"log"
"net"
"net/http"
"os"
)
func protect(allowedHosts, allowedOrigins map[string]bool, token string, next http.Handler) http.Handler {
want := []byte("Bearer " + token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// DNS rebinding では、攻撃者のドメインが Host と Origin に現れる。
if !allowedHosts[r.Host] {
http.Error(w, "forbidden host", http.StatusForbidden)
return
}
// Origin があって許可されていなければ 403 (仕様の MUST)。
if o := r.Header.Get("Origin"); o != "" && !allowedOrigins[o] {
http.Error(w, "forbidden origin", http.StatusForbidden)
return
}
got := []byte(r.Header.Get("Authorization"))
if subtle.ConstantTimeCompare(got, want) != 1 {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
token := os.Getenv("MCP_LOCAL_TOKEN") // 起動のたびに生成して、クライアントへ渡す
if token == "" {
log.Fatal("MCP_LOCAL_TOKEN is required")
}
mcpHandler := http.NewServeMux() // MCP の処理 (省略)
// 0.0.0.0 ではなく、ループバックだけに bind する。
ln, err := net.Listen("tcp", "127.0.0.1:8931")
if err != nil {
log.Fatal(err)
}
hosts := map[string]bool{"127.0.0.1:8931": true, "localhost:8931": true}
origins := map[string]bool{} // ブラウザーからの利用を想定しないなら、空にする
log.Fatal(http.Serve(ln, protect(hosts, origins, token, mcpHandler)))
}
stdio のサーバーでログを stderr に出す (Go)
package main
import (
"log"
"os"
)
func init() {
// 標準の log は、既定で stderr に出る。念のため明示し、
// fmt.Println など stdout へ書く処理を、サーバーに残さない。
log.SetOutput(os.Stderr)
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("mcp-fs: ")
}
パストラバーサルを防ぐ (Go 1.24 以降)
os.Root は、ディレクトリの内側に閉じ込めて操作します。.. や、外へ向かうシンボリックリンクによる脱出は、OS レベルで拒否されます。
package main
import (
"fmt"
"io"
"os"
)
// readInside は、許可されたディレクトリ root の内側のファイルだけを読む。
func readInside(root *os.Root, name string) ([]byte, error) {
f, err := root.Open(name) // "../../etc/passwd" や、外を指すリンクはエラーになる
if err != nil {
return nil, fmt.Errorf("open %q: %w", name, err)
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, 1<<20)) // 読み取るサイズも制限する
}
func main() {
root, err := os.OpenRoot("/workspace") // 設定で渡された、許可ディレクトリ
if err != nil {
panic(err)
}
defer root.Close()
if _, err := readInside(root, "../etc/passwd"); err != nil {
fmt.Fprintln(os.Stderr, err) // 拒否される
}
}
5. まとめ
- ローカル MCP サーバーは、利用者と同じ権限で動くふつうのプロセスで、stdio はサンドボックスではない。環境変数、ファイル、ネットワーク、依存ツリーのすべてに触れる
- 事前にサーバーの挙動を証明する方法はない。サードパーティのローカルサーバーは、渡した権限だけを持つ信頼できないコードとして扱い、権限を最小にする
- まず、ローカルで動かす必要があるかを問う。リモートの版があるなら、そちらを使う
- 導入前に、公開者とパッケージ名を確認し、バージョンを固定する。
npxやuvxでの固定は最上位のパッケージだけなので、依存ツリー全体を固定するなら、コンテナーイメージをダイジェストで固定する。署名、attestation、SBOM を検証する - ツールの定義は、信頼できない入力。一覧を読み、変更時に再確認するクライアントを選び、サーバーの数を少なくする
- 隔離が最も効果が大きい。コンテナーで、必要なディレクトリだけを読み取り専用で渡し、ネットワークは不要なら遮断し、必要なら allowlist で絞る
- 認証情報はサーバーごとに、範囲を絞って渡す。シェルでグローバルに export しない
- ローカルの HTTP サーバーは、
Originの検証、127.0.0.1 への bind、認証が必要。stdio のサーバーは stdout を MCP のメッセージ専用にし、パスは OS の機能で閉じ込める - 組織は、承認済みサーバーの一覧、クライアント設定の棚卸し、既定の隔離、監査ログから始める
チェックリスト
自分のマシンでは、次を確認します。
- クライアントの設定にあるすべてのサーバーを把握し、使わないものを削除した
- マシンから何も必要としないサーバーは、ローカルではなくリモートで動かしている
- 各サーバーは、特定できる公開者から入れ、バージョンを固定している
- サードパーティのサーバーは、素のプロセスではなく、隔離された環境 (コンテナー、サンドボックス、VM) で動かしている
- 各サーバーには、サーバーごとの設定で、範囲を絞った自分専用の認証情報だけを渡している (シェルから引き継がず、使い回さない)
- ファイルシステムのアクセスは、特定のディレクトリに限り、可能なら読み取り専用にしている
- ネットワークが不要なサーバーは、出口を無効にして動かし、残りは宛先の一覧を把握している
- 各サーバーのツールの description を読み、ツール定義が変わったら再確認するクライアントを使っている
組織では、次を確認します。
- 承認済みサーバーの allowlist と、公開した受け入れ基準がある
- 管理対象のマシンで、MCP クライアントの設定を継続的に棚卸ししている
- ローカルサーバーの既定の隔離の方針を文書化している
- 管理対象のマシンでの MCP の活動の監査ログを残している
- 特定のサーバーの egress を遮断し、その認証情報を組織全体でローテーションできる
関連ドキュメント
- MCPセキュリティベストプラクティス - 攻撃と対策
- AIエージェント特有の脅威 - プロンプトインジェクションとツール汚染
- SBOMと脆弱性診断
- ゼロトラストのためのDevOps環境のセキュリティ保護