Skip to main content
Glama
thekk1
by thekk1

ssh-mcp

MCPサーバーで、LLMがSSH経由でシェルコマンドを実行できるようにするものです。認証には、単一の共有サービスアカウントではなく、各ユーザー自身の個人SSHキーを使用します。サーバーは共有されるが、リクエストごとのSSHアイデンティティは共有されるべきではないマルチユーザーチャットプラットフォーム(例:LibreChat)向けに構築されています。

ツールは1つだけ:ssh_exec(host, port, username, command)。ホストの許可リストも、コマンドのホワイトリストもありません -- その理由と、これをデプロイする人にとっての意味については、下の「セキュリティモデル」を参照してください。

なぜこれが存在するのか

これを書く前に、いくつかの既存のオープンソースSSH MCPサーバーを調査しました(vignitin/multi-ssh-mcp、giuliolibrando/ssh-mcp-server、tufantunc/ssh-mcp)。どれもリクエストごとの認証情報には対応していません。すべて起動時に単一のホスト/ユーザー/認証情報を環境変数または設定ファイルに埋め込む方式で、シングルユーザーデプロイメントか共有サービスアカウントでしか機能しません。それぞれが自分のSSHキーを持つ多くの異なる人々が、1つの実行中のMCPサーバーを共有するという構成には、どれも適合しません。

そこで、既存ツールのラッパーではなく、asyncsshをベースにした小規模で専用のサーバーとして作りました -- ラップするのに適したものが存在しなかったからです。

Related MCP server: terminal-mcp-server

仕組み

MCP client --(streamable-http, /mcp, per-request headers)--> ssh-mcp
                                                                  |
                                                                  | asyncssh,
                                                                  | one connection
                                                                  | per tool call
                                                                  v
                                                            arbitrary target host

認証情報はサーバー設定ではなく、リクエストごとのHTTPヘッダーとして送信されます:

  • x-ssh-private-key -- 認証に使用する秘密鍵。base64エンコード済み(生の複数行PEMブロックはHTTPヘッダー値として保持できないため)

  • x-ssh-key-passphrase -- 任意。その鍵がパスフレーズで保護されている場合に使用

両方ともツール呼び出しのたびに新しく読み取られ、デコードされ、asyncsshに直接渡され、その後破棄されます -- ディスクに書き込まれるものはなく、リクエスト間でキャッシュされるものもありません。正しいユーザーに正しいヘッダーを付与するのは呼び出し側クライアントの役割です。その方法の1つについては、下の「LibreChatでこれを使う」を参照してください。

ホストキーは本物のTOFU(初回接続時信頼)を使用しており、「何でも常に受け入れる」方式ではありません。特定のhost:portへの最初の接続では、そのキーのフィンガープリントがディスク上のJSONファイル(hostkeys.py)に固定(ピン)されます。以降のすべての接続はそのピンと完全に一致する必要があり、一致しない場合はhost_key_mismatchで拒否されます。これではホストへの最初の接触時における中間者攻撃を防ぐことはできませんが、その後の予告なしのキー変更 -- ローテーションであれ実際の攻撃であれ -- を、静かな穴ではなく、明確で明示的な失敗に変えます。

MCP接続自体にはAPIキーやベアラートークンのゲートはありません。これは特定のデプロイ形態のための意図的なシンプルさの選択です:信頼できる内部ネットワークからのみ到達可能なサーバーであり、クライアントがユーザーごとのSSH認証情報を自分で付与し(下記参照)、ネットワーク上の配置が実際のアクセス境界となる構成です。これをあまり信頼できない場所に公開する場合は、前面にゲートを設置してください -- このプロジェクトにはゲートは含まれていません。

セキュリティモデル

ssh_execは、どのホスト、コマンド、ユーザーを許可するかをフィルタリングしません。呼び出し側が渡したhost/port/username/commandは何であれ、実行が試みられます。それだけです。これは見落としではなく、意図的なトレードオフです:MCPサーバー内でホストやコマンドによるフィルタリングを行うのはセキュリティの見せかけに過ぎません。有効なキーを持つ呼び出し側は、このツールを介さずに直接SSHでそこに接続できるからです。リクエストと実際のシェルの間を実際に隔てるものは、次の2つです:

  1. このサーバーに到達でき、認証情報ヘッダーを設定できる人 -- これは完全にこのコードの制御外です。これをマルチテナントクライアントの背後にデプロイする場合、どのユーザーがこのツールを閲覧/使用できるかを制限するのはそのクライアントの役割です(具体的な方法の1つについては「LibreChatでこれを使う」を参照)。

  2. 使用されるキーに付随する実際のUnix権限。 ssh_execは、そのキーの対象アカウントが持つ権限とまったく同じ権限で実行されます -- それ以上でもそれ以下でもありません。

特定のデプロイでこれらのいずれも実際に強制されていない場合、このツールは、キーが到達できるすべてのホストで、すべての呼び出し側に素のターミナルを渡すのとまったく同じ危険性があります。これが意図したモデルです -- SSH自身の認可であり、その再実装ではありません -- デプロイする前に、これが本当に望むモデルであることを確認してください。

host/usernameが欠けている場合:モデルが推測するのではなく、MCPエリシテーションで尋ねる

hostとusernameは、意図的にツールのrequiredスキーマフィールドに含まれていません(commandは必須のままです -- 何を実行するかを決めるのはモデルの仕事であり、人間の仕事ではないからです)。host/usernameを必須にすると、仕様準拠のモデルはそれらなしではツールを呼び出そうとせず、代わりに自分で平文のフォローアップ質問を即興で作ってしまいます -- これはまさにこの設計が回避するUXです。どちらかが欠けている場合、ssh_mcp/app.pyのelicit_missing_ssh_args()が、MCPエリシテーション(elicitation/create、フォームモード)を介して、1つの結合フォームで人間に直接尋ねます -- モデルが自分で文言を考える必要も、フィールドごとに個別のラウンドトリップもありません。portも同じフォームに含まれ、スキーマのdefaultによって通常のデフォルト値(22)が事前入力され、編集可能ですが、それだけが未設定の場合に中断を引き起こす理由にはなりません。

これはエリシテーションをサポートしないクライアントでも安全に縮退します。 elicit_missing_ssh_args()は、リクエストを送信する前にクライアントの宣言されたケイパビリティ(session.check_client_capability(...))をチェックし、呼び出し自体の失敗も捕捉します。どちらの場合も、ツール呼び出しがエラーになったりハングしたりする代わりに、モデルがテキストの質問として中継できるプレーンなmissing_host/missing_usernameエラーにフォールバックします。エリシテーションのサポートはクライアントによって異なります -- 執筆時点では、いくつかの人気MCPクライアント(LibreChatを含む)はまだ実装していないため、これは今日のところ主に将来互換のための基盤として機能します。サポートされていない場合はコストがかからず、後で実際のエリシテーションサポートを追加するクライアントでは、ここでの変更なしに自動的に有効になります。

mcp.shared.memoryのインメモリトランスポートを介した実際のClientSessionで検証済みです:elicitation_callbackを登録した場合としない場合、accept/decline/cancelの各分岐、および完全な結合フォーム(host + usernameが欠落、portのデフォルトが上書き)をテストし、3つの値すべてがエリシテーションされたとおりに実際にSSH呼び出しに到達することを確認しました。仕様を読んだだけの推測ではありません。

LibreChatでこれを使う

LibreChatは、customUserVarsを介して、ユーザーごとの値をMCPリクエストヘッダーに付与できます -- 各ユーザーは自分のキーをSettingsで一度入力するだけで、LibreChatがそのユーザーのすべてのリクエストで設定されたヘッダーにそれを注入します。librechat.yaml:

mcpServers:
  ssh:
    type: streamable-http
    url: http://ssh-mcp:8080/mcp
    serverInstructions: true
    headers:
      X-SSH-Private-Key: '{{SSH_PRIVATE_KEY}}'
      X-SSH-Key-Passphrase: '{{SSH_KEY_PASSPHRASE}}'
    customUserVars:
      SSH_PRIVATE_KEY:
        title: "SSH Private Key (Base64)"
        description: "Your personal SSH private key, base64-encoded: `base64 -w0 ~/.ssh/id_ed25519`"
      SSH_KEY_PASSPHRASE:
        title: "SSH Key Passphrase (optional)"
        description: "Only fill in if your private key is passphrase-protected"

customUserVarsの両エントリには、titleとdescriptionの両方が必要です -- titleのみのエントリは、起動時にLibreChatの設定検証でZodErrorを引き起こします。紛らわしいことに、このエラーは無関係に見えるフィールドに対して報告されます(LibreChatはmcpServersブロック全体をトランスポートタイプの1つのユニオンとして検証するため、1つのフィールド欠落が、一見無関係な複数のエラーとして同時に現れます)。librechat.yamlはコンテナ起動時にのみ読み込まれます -- 編集後はLibreChatを再起動してください。

このサーバーを表示できるユーザーを制限する

このプロジェクトには、誰が使用できるかを制限するものは何もありません -- SSH_PRIVATE_KEYヘッダーを設定できるユーザーは誰でもssh_execを呼び出せます。これをユーザーの一部に限定する必要がある場合、それはLibreChat(または使用しているクライアント)側で行う必要があり、ここではありません。LibreChat 0.8.5以降では、管理パネルに設定オーバーライドシステム(Configuration Management)があり、追加のmcpServersエントリを特定のロールまたはグループにスコープできます -- そのグループ外のユーザーは、解決された設定にsshエントリがまったく存在しません(単に非表示になるだけではありません)。これに依存する前に、推測せずに自分のLibreChatバージョンで確認すべきことが2つあります:

  • 執筆時点では、GAではなく**「プレビュー版」**として文書化されています。

  • ロールスコープのオーバーライドは適用されたのに、グループスコープのオーバーライドが黙って適用されないという既知の経緯があります(danny-avila/LibreChat#13172)。直接テストして、修正が実行中のバージョンに含まれていることを確認してください -- ユーザーをグループに入れたり外したりして、サーバーが実際にそのユーザーに対して表示されるか(されないか)を確認します。

実行

docker build -t ssh-mcp .
docker run --rm -p 8080:8080 -v ssh-mcp-hostkeys:/data ssh-mcp

/dataボリュームは、TOFUホストキーのピンをコンテナ再作成後も維持するためのものです -- これがないと、再デプロイのたびに以前に認識したすべてのホストキーを忘れ、次回の接触時に再ピンします(セキュリティホールではなく、各ホストに一度再接触するまで「後からの変更を検出する」特性が一時的に失われるだけです)。

ローカルクローンからビルドするdocker-compose.ymlサービスの例:

services:
  ssh-mcp:
    build: .
    container_name: ssh-mcp
    volumes:
      - ssh-mcp-hostkeys:/data
    restart: always

volumes:
  ssh-mcp-hostkeys:

検証

curl -s http://127.0.0.1:8080/readyz   # "ok" once the session manager is up

手動でエンドツーエンドの検証を行いました(単なるユニットテストではありません):イメージをビルドし、実行し、認証情報ヘッダーを付けてstreamable-http経由で実際のMCPクライアントを接続し、tools/listでssh_execが表示され、実際の使い捨てasyncsshベースのSSHサーバーに対するtools/callで、実際のSSHハンドシェイクを介して実際のコマンドが実行され、その実際のstdoutが返されました。また、同じ使い捨てサーバーに対してHTTP層をバイパスして直接テストも行いました:最初の接触でのTOFUピン、一致する2回目の接触での受け入れ、変更/不一致のホストキーでの完全拒否、不正な秘密鍵のinvalid_keyとしての拒否、権限のないキーのconnection_failedとしての拒否、およびリモートの非ゼロ終了コードがその終了コードとともにok: trueとして通過すること(ツールの失敗として扱われないこと)を確認しました。

テスト

pip install -e '.[dev]'
pytest

ユニットテスト(認証情報ヘッダーの解析、TOFUのピン/受け入れ/拒否ロジック、ツールスキーマ、フェイクセッションに対するelicit_missing_ssh_argsのケイパビリティチェック/フィールド選択/accept/decline/cancel/失敗の各分岐)-- 実際のネットワーク、サブプロセス、MCPトランスポートは使用しません。実際のハンドシェイクシナリオと実際のClientSessionエリシテーションのラウンドトリップ(上記参照)は手動で実行され、自動テストスイートには含まれていません。

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    D
    maintenance
    Enables secure SSH connections to remote servers for executing shell commands and managing active sessions. It supports authentication via passwords or private keys and provides optional host-based access control.
    4
    106 npm
    MIT
  • A
    license
    C
    quality
    F
    maintenance
    Enables SSH remote command execution on remote machines with persistent connections, supporting automatic key discovery and connection pooling.
    2
    14 npm
    MIT
  • F
    license
    B
    quality
    D
    maintenance
    Enables SSH-based deployment operations such as git pull, command execution, script upload/run, and SSH config management.
    4
    -