MCP サーバーの API キーを AWS Secrets Manager で管理してみた

記事タイトルとURLをコピーする

アプリケーションサービス部の山本です。
中途社員の研修を担当しています。
が、毎日こちらが学ぶことばかりです。

はじめに

Claude Desktop で MCP(Model Context Protocol)サーバーを使って Backlog などの外部サービスと連携する際、API キーの管理は避けて通れない課題です。

設定ファイルに API キーをベタ書きしていませんか? その設定ファイルを Claude 自身が読めてしまう状態になっていませんか?

この記事では、設定ファイルに API キーを一切残さず、AWS Secrets Manager と Python の venv を使って安全に MCP サーバーへキーを渡す方法を、実際に動作確認しながら解説します。きっかけは「これ AWS Secrets Manager で出来ないかな?」「Windows でも Mac でも同じ手順でやりたい」という素朴な疑問でした。Python で書けば OS を問わず同じコードで動きます。

なお、シークレットの保管先としては macOS Keychain や Windows Credential Manager も有力な選択肢です。個人利用であればそちらの方がシンプルで十分安全なケースも多いでしょう。本記事では、チームでの共有・監査ログ・キーの自動ローテーションといった運用面のメリットから AWS Secrets Manager を採用しています。両者の詳しい比較は記事の後半にまとめています。

SSM Parameter Store を使用すると無料なので、追加で記事を書きました。よろしければ、こちらもどうぞ。
MCP サーバーの API キーを AWS Systems Manager Parameter Store で管理してみた - サーバーワークスエンジニアブログ

この記事で実現すること

flowchart LR
    Claude["Claude Desktop\n( MCP 設定ファイルの command に\nrun_mcp.py を指定)"]
    Wrapper["run_mcp.py"]

    Claude -- "ツール呼び出し・結果受信\n(stdin/stdout)" --> Wrapper
flowchart LR
    Wrapper["run_mcp.py"]
    Secrets["AWS Secrets Manager"]

    Wrapper -- "① API キーを取得" --> Secrets
flowchart LR
    Wrapper["run_mcp.py"]
    MCP["Backlog MCP サーバー"]

    Wrapper -- "② API キーを環境変数にセット\n③ MCP サーバーを子プロセスとして起動" --> MCP
flowchart LR
    MCP["Backlog MCP サーバー"]
    Backlog["Backlog API"]

    MCP -- "環境変数の API キーで\n認証・リクエスト" --> Backlog

ポイント:

なぜ API キーを Claude に読ませてはいけないのか

MCP サーバーの設定方法として、以下のような args に直接キーを書くパターンを見かけることがあります。

{
  "mcpServers": {
    "backlog-server": {
      "command": "npx",
      "args": [
        "-y", "backlog-mcp-server",
        "--api-key", "ここにAPIキーを直書き"
      ]
    }
  }
}

これには複数のリスクがあります。

リスク 説明
プロセス一覧からの漏洩 ps コマンドやタスクマネージャーで引数が丸見えになる
エラーログへの出力 MCP サーバーがクラッシュした際、Node.js のエラーハンドラや OS のクラッシュレポートが process.argv(起動引数)をログファイルやコンソールにそのまま出力することがある。args に API キーが含まれていると、ログ収集ツール(CloudWatch Logs、Datadog 等)やクラッシュレポートサービスに平文で送信されてしまう
Filesystem MCP 経由の読み取り Claude に Filesystem ツールを使わせている場合、設定ファイルを読み取って API キーを出力してしまう可能性がある

env ブロックに書く方法はこれらのリスクを軽減しますが、設定ファイル自体にキーが残ることに変わりはありません。AWS Secrets Manager を使えば、設定ファイルにすらキーを書かない状態を実現できます。

前提条件

  • Python 3.9 以上
  • Node.js(MCP サーバーの実行に必要)
  • AWS CLI が設定済み(aws login または aws sso login で一時トークンを利用)
  • 上記で認証したプロファイル名を、後述の設定ファイルで AWS_PROFILE に指定します
"env": {
  "AWS_PROFILE": "aws login / aws sso login で使用したプロファイル名"
}
  • AWS Secrets Manager へのアクセス権限(secretsmanager:GetSecretValue

手順 1: AWS Secrets Manager にシークレットを登録する

AWS マネジメントコンソール、または AWS CLI でシークレットを作成します。

aws secretsmanager create-secret \
  --name "mcp/backlog-keys" \
  --secret-string '{"BACKLOG_API_KEY":"あなたのBacklog APIキー"}' \
  --region ap-northeast-1

実行結果:

{
    "ARN": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:mcp/backlog-keys-AbCdEf",
    "Name": "mcp/backlog-keys",
    "VersionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

手順 2: Python venv のセットアップ

任意のディレクトリにプロジェクトを作成します。

mkdir backlog-mcp-wrapper
cd backlog-mcp-wrapper

# venv 作成
python3 -m venv venv

# boto3 と aws login 用の依存パッケージをインストール
venv/bin/pip install boto3 "botocore[crt]"        # macOS / Linux
# venv\Scripts\pip install boto3 "botocore[crt]"  # Windows

手順 3: ラッパースクリプトの作成

run_mcp.py を作成します。このスクリプトが全体の要です。

import os
import sys
import json
import subprocess

import boto3
from botocore.exceptions import ClientError, BotoCoreError


# ============================================================
# 設定値(ご自身の環境に合わせて変更してください)
# ============================================================
SECRET_NAME = "mcp/backlog-keys"
AWS_REGION = "ap-northeast-1"
BACKLOG_DOMAIN = "yourspace.backlog.com"

# MCP サーバーの起動コマンド
# npx 経由で backlog-mcp-server を起動する
MCP_COMMAND = os.environ.get("MCP_COMMAND", "npx")
MCP_ARGS = os.environ.get(
    "MCP_ARGS",
    "-y,backlog-mcp-server,--enable-toolsets,project,wiki",
).split(",")


def get_aws_secret() -> dict:
    """AWS Secrets Manager からシークレットを取得して dict で返す。"""

    aws_profile = os.environ.get("AWS_PROFILE", "default")

    try:
        session = boto3.session.Session(
            profile_name=aws_profile,
            region_name=AWS_REGION,
        )
        client = session.client(service_name="secretsmanager")
        response = client.get_secret_value(SecretId=SECRET_NAME)
        return json.loads(response["SecretString"])

    except (ClientError, BotoCoreError) as e:
        # 【重要】ログは必ず stderr へ。
        # stdout に出すと Claude ⇔ MCP の JSON-RPC 通信が壊れる。
        print(
            f"[run_mcp] AWS シークレットの取得に失敗しました (Profile: {aws_profile})",
            file=sys.stderr,
        )
        print(f"[run_mcp] エラー詳細: {e}", file=sys.stderr)
        sys.exit(1)


def main() -> None:
    # 1. AWS Secrets Manager からキーを取得
    secrets = get_aws_secret()

    # 2. 子プロセス用の環境変数を構築
    env = os.environ.copy()
    env["BACKLOG_API_KEY"] = secrets.get("BACKLOG_API_KEY", "")
    env["BACKLOG_DOMAIN"] = os.environ.get("BACKLOG_DOMAIN", BACKLOG_DOMAIN)

    # 3. MCP サーバーを起動
    cmd = [MCP_COMMAND] + MCP_ARGS
    print(f"[run_mcp] 起動コマンド: {' '.join(cmd)}", file=sys.stderr)

    try:
        result = subprocess.run(cmd, env=env)
        sys.exit(result.returncode)
    except FileNotFoundError:
        print(
            f"[run_mcp] '{MCP_COMMAND}' コマンドが見つかりません。",
            file=sys.stderr,
        )
        sys.exit(1)
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()

ラッパースクリプトの各ステップと設定ファイルの関係

このスクリプトが何をしているのか、通常の MCP 設定と比較しながらステップごとに解説します。

通常の設定(API キーが設定ファイルに残る):

{
  "mcpServers": {
    "backlog-server": {
      "command": "npx",
      "args": ["-y", "backlog-mcp-server", "--enable-toolsets", "project", "wiki"],
      "env": {
        "BACKLOG_DOMAIN": "yourspace.backlog.com",
        "BACKLOG_API_KEY": "ここにAPIキーが丸見え"
      }
    }
  }
}

ラッパーを使った設定(API キーが設定ファイルに存在しない):

{
  "mcpServers": {
    "backlog-server": {
      "command": "/path/to/venv/bin/python",
      "args": ["/path/to/run_mcp.py"],
      "env": {
        "AWS_PROFILE": "my-sso-profile"
      }
    }
  }
}

違いは明確です。envBACKLOG_API_KEY が存在しません。では API キーはどこから来るのか? それがラッパースクリプトの3つのステップです。

ステップ ①: AWS Secrets Manager から API キーを取得

secrets = get_aws_secret()

Claude Desktop が MCP サーバーを起動する際、設定ファイルの command に書かれたプログラムを実行します。通常は npx が直接呼ばれますが、ラッパー方式では代わりに venv/bin/python run_mcp.py が起動します。

起動直後、スクリプトは env で渡された AWS_PROFILE を使って AWS Secrets Manager に接続し、API キーを取得します。この時点で API キーはプロセスのメモリ上にだけ存在し、ファイルには一切書かれていません。

ステップ ②: 環境変数を構築

env = os.environ.copy()
env["BACKLOG_API_KEY"] = secrets.get("BACKLOG_API_KEY", "")
env["BACKLOG_DOMAIN"] = os.environ.get("BACKLOG_DOMAIN", BACKLOG_DOMAIN)

取得した API キーを、これから起動する MCP サーバーに渡すための環境変数としてセットします。os.environ.copy() で現在の環境変数をコピーし、そこに BACKLOG_API_KEY を追加しています。

通常の設定では MCP 設定ファイルの env ブロックがこの役割を担いますが、ラッパー方式ではスクリプトが動的に組み立てます。

ステップ ③: MCP サーバーを起動

cmd = [MCP_COMMAND] + MCP_ARGS  # → ["npx", "-y", "backlog-mcp-server", ...]
result = subprocess.run(cmd, env=env)

最後に、通常の設定で command + args に書かれていたコマンド(npx -y backlog-mcp-server ...)を、ステップ②で構築した環境変数付きで実行します。

subprocess.run はデフォルトで親プロセスの stdin/stdout を子プロセスに引き継ぎます。Claude Desktop は MCP サーバーと stdin/stdout 経由で JSON-RPC 通信を行うため、この引き継ぎが正しく動くことが必須です。

つまり、Claude Desktop から見ると通信相手は変わりません。間に Python が挟まっていることすら意識しない構造です。

通常:

flowchart LR
    A1["Claude Desktop"] -- "stdin/stdout" --> B1["npx\nbacklog-mcp-server"]

ラッパー方式:

flowchart LR
    A2["Claude Desktop"] -- "stdin/stdout" --> B2["python\nrun_mcp.py"] -- "stdin/stdout" --> C2["npx\nbacklog-mcp-server"]
    D2["AWS\nSecrets Manager"] -. "API キーを取得\nenv に注入" .-> B2

その他の重要ポイント

ログは必ず stderr

print("メッセージ", file=sys.stderr)  # ✅ 正しい
print("メッセージ")                    # ❌ JSON-RPC 通信が壊れる

Claude と MCP サーバーは stdout で JSON-RPC 通信を行います。ラッパースクリプト内で stdout にログを出すと、通信データが破損して MCP サーバーの起動エラーになります。

環境変数 MCP_COMMAND / MCP_ARGS での上書き

MCP_COMMAND = os.environ.get("MCP_COMMAND", "npx")
MCP_ARGS = os.environ.get(
    "MCP_ARGS",
    "-y,backlog-mcp-server,--enable-toolsets,project,wiki",
).split(",")

テスト時にモックサーバーに差し替えられるよう、環境変数でコマンドを上書きできるようにしています。MCP_ARGS はカンマ区切りで指定します。

手順 4: MCP 設定ファイルの更新

MCP サーバーの設定ファイルは、使用するクライアントによって場所が異なります。

クライアント 設定ファイルの場所
Claude Desktop ~/Library/Application Support/Claude/claude_desktop_config.json(macOS)
%APPDATA%\Claude\claude_desktop_config.json(Windows)
Kiro ~/.kiro/settings/mcp.json(ユーザーレベル)
.kiro/settings/mcp.json(ワークスペースレベル)
Claude Code(VS Code 拡張) ~/.claude.json.projects

いずれの場合も、mcpServers に追加する内容は同じです。

macOS / Linux

{
  "mcpServers": {
    "backlog-server": {
      "command": "/path/to/backlog-mcp-wrapper/venv/bin/python",
      "args": ["/path/to/backlog-mcp-wrapper/run_mcp.py"],
      "env": {
        "AWS_PROFILE": "my-sso-profile"
      }
    }
  }
}

Windows

{
  "mcpServers": {
    "backlog-server": {
      "command": "C:\\path\\to\\backlog-mcp-wrapper\\venv\\Scripts\\python.exe",
      "args": ["C:\\path\\to\\backlog-mcp-wrapper\\run_mcp.py"],
      "env": {
        "AWS_PROFILE": "my-sso-profile"
      }
    }
  }
}

commandvenv 内の Python 実行ファイルを直接指定するのがポイントです。source activate のような手順は不要で、venv の Python バイナリを直接呼べば自動的にその仮想環境が使われます。

動作確認

シークレット取得テスト

まず、AWS Secrets Manager からシークレットを正しく取得できるか確認します。

# test_secret.py
import os, sys, json, boto3
from botocore.exceptions import ClientError, BotoCoreError

def main():
    aws_profile = os.environ.get("AWS_PROFILE", "default")
    print(f"[テスト] 使用プロファイル: {aws_profile}")

    session = boto3.session.Session(
        profile_name=aws_profile, region_name="ap-northeast-1"
    )
    client = session.client(service_name="secretsmanager")
    response = client.get_secret_value(SecretId="mcp/backlog-keys")
    secrets = json.loads(response["SecretString"])

    print(f"[成功] キー一覧: {list(secrets.keys())}")
    api_key = secrets.get("BACKLOG_API_KEY", "")
    print(f"  BACKLOG_API_KEY: {'*' * len(api_key)} ({len(api_key)}文字)")

if __name__ == "__main__":
    main()
venv/bin/python test_secret.py

実行結果:

[テスト] 使用プロファイル: default
[成功] キー一覧: ['BACKLOG_API_KEY']
  BACKLOG_API_KEY: **************************** (28文字)

E2E テスト(モック MCP サーバーを使用)

実際の Backlog MCP サーバーの代わりに、環境変数を受け取って JSON-RPC レスポンスを返すモックサーバー(mock_mcp_server.js)と、それを使った E2E テストスクリプト(test_e2e.py)を作成してテストします。

まず、モックサーバー mock_mcp_server.js です。実際の backlog-mcp-server が Node.js 製のため、同じ Node.js で stdin/stdout の挙動を再現しています。

// mock_mcp_server.js
const readline = require('readline');

const apiKey = process.env.BACKLOG_API_KEY || '';
const domain = process.env.BACKLOG_DOMAIN || '';

// ログは stderr に(stdout は JSON-RPC 専用)
process.stderr.write(`[mock-mcp] BACKLOG_API_KEY: ${'*'.repeat(apiKey.length)}\n`);

const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
  const request = JSON.parse(line);
  const response = {
    jsonrpc: '2.0',
    id: request.id,
    result: {
      status: 'ok',
      has_api_key: apiKey.length > 0,
      // API キーの値自体は絶対にレスポンスに含めない
    }
  };
  process.stdout.write(JSON.stringify(response) + '\n');
});

次に、テストスクリプト test_e2e.py です。MCP_COMMANDMCP_ARGS を上書きしてモックサーバーに差し替え、run_mcp.py 経由で通信が正常に動くかを検証します。

# test_e2e.py
import os, sys, json, subprocess

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
MOCK_SERVER = os.path.join(SCRIPT_DIR, "mock_mcp_server.js")
RUN_MCP = os.path.join(SCRIPT_DIR, "run_mcp.py")
VENV_PYTHON = os.path.join(SCRIPT_DIR, "venv", "bin", "python")

def test_via_wrapper():
    env = os.environ.copy()
    env["AWS_PROFILE"] = os.environ.get("AWS_PROFILE", "default")
    env["MCP_COMMAND"] = "node"
    env["MCP_ARGS"] = MOCK_SERVER

    request = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "test"}) + "\n"
    proc = subprocess.run(
        [VENV_PYTHON, RUN_MCP],
        input=request, capture_output=True, text=True, timeout=15,
        env=env,
    )
    response = json.loads(proc.stdout.strip())
    assert response["result"]["has_api_key"] is True
    print("✅ 成功: AWS Secrets Manager → 環境変数 → MCP サーバーの連携が正常")

if __name__ == "__main__":
    test_via_wrapper()

実行します。

MCP_COMMAND=node MCP_ARGS=./mock_mcp_server.js venv/bin/python test_e2e.py

実行結果:

✅ 成功: AWS Secrets Manager → 環境変数 → MCP サーバーの連携が正常

テスト2 の結果から、以下が確認できました:

  • AWS Secrets Manager からシークレットが正しく取得された(has_api_key: true
  • API キーの値自体はレスポンスに含まれていない(漏洩チェック通過)
  • stdin/stdout の JSON-RPC 通信が Python ラッパーを経由しても正常に動作する

SSO プロファイル利用時の注意点

aws sso loginaws login で認証している場合、トークンには有効期限(通常数時間〜1日)があります。

トークンが切れた状態で Claude Desktop を起動すると、Python スクリプトが認証エラーを起こし、MCP サーバーが起動に失敗します。その場合は:

# ターミナルで再ログイン
aws sso login --profile my-sso-profile
# または
aws login --profile my-sso-profile

# Claude Desktop を再起動(または Cmd+R / Ctrl+R でリロード)

補足: OS の Keychain / Credential Manager ではダメなのか

macOS Keychain や Windows Credential Manager もシークレットの保管先として有力な選択肢です。AWS Secrets Manager との比較を整理しておきます。

観点 macOS Keychain / Windows Credential Manager AWS Secrets Manager
保管場所のセキュリティ ◎ OS レベルで暗号化、生体認証やログインパスワードで保護 ○ AWS KMS で暗号化、IAM で制御
個人利用 ◎ 追加コストゼロ、セットアップ簡単 △ AWS アカウント必要、月額コストあり
チーム共有 ❌ 端末ごとに手動設定が必要 ◎ IAM ポリシーで複数人に配布可能
キーのローテーション ❌ 手動 ◎ 自動ローテーション機能あり
監査ログ ❌ なし ◎ CloudTrail で誰がいつ取得したか記録
オフライン動作 ◎ ネットワーク不要 ❌ AWS への通信が必要

「どちらが安全か」というよりも、用途が違うというのが正確な整理です。

  • 個人で1台のマシンだけで使う → Keychain / Credential Manager の方がシンプルで十分安全
  • チームで共有したい、監査ログが必要、キーの自動ローテーションをしたい → AWS Secrets Manager

本記事では AWS Secrets Manager を採用していますが、これは筆者の環境が AWS を前提としていることや、チームでの運用上こうせざるを得ない環境もありそう、という背景からです。また、AWS の各種サービスと連携する MCP サーバーを使う場合は、同じ AWS の認証基盤に乗せられる Secrets Manager の方が相性が良いでしょう。個人利用であれば OS のキーチェーンで十分なケースも多いと思います。

この方式の限界

正直に書いておきます。この方式は万能ではありません。

そもそも Claude は env に書いた API キーを読めない

MCP の仕様上、Claude の LLM が受け取るのはツールの定義と実行結果だけです。MCP 設定ファイル(claude_desktop_config.json~/.claude.json 等)の env ブロックに書いた API キーを Claude が読み取ることは、通常ありません。つまり「Claude に API キーを認識させない」という目的だけなら、env に書くだけで達成できています。

この記事の本質的な価値はそこではなく、設定ファイルに平文でキーを残さないこと、そして Filesystem MCP 等で設定ファイルを読まれても安全な状態を作ることにあります。

環境変数自体のリスクは残る

ラッパースクリプトは最終的に API キーを環境変数にセットして子プロセスに渡しています。Linux であれば /proc/<pid>/environ、macOS でも ps eww 等で環境変数を読み取れる可能性があります。これは env に直書きした場合と同じ攻撃面です。ただし、設定ファイルという永続的なファイルにキーが残るのと、プロセスが動いている間だけメモリ上に存在するのとでは、漏洩の機会が大きく異なります。

守るべきものが移動しただけでは?

Backlog API キーを守るために AWS 認証情報を使っているので、その AWS 認証情報が漏洩したら同じことです。ただし、ここが重要なポイントで、AWS 認証情報は aws sso loginaws login を使えば一時的なトークンになります。有効期限があり、MFA で保護でき、CloudTrail で利用履歴を追跡できます。静的な API キーをファイルに置き続けるのとは、リスクの性質が根本的に異なります。

本記事で aws configure による静的なアクセスキーではなく aws sso login / aws login の利用を前提としているのは、この理由からです。

まとめ

この記事では、Claude Desktop × MCP サーバーの構成で API キーを安全に管理する方法を紹介しました。

方法 セキュリティレベル 手軽さ
args に直書き ❌ 危険
env に直書き △ 設定ファイルにキーが残る
AWS Secrets Manager + Python ラッパー ◎ 設定ファイルにもキーなし △(初回セットアップが必要)

初回のセットアップは少し手間がかかりますが、一度構築してしまえば運用は簡単です。Python の venv で環境を分離しているため、OS を問わず同じコードで動作します。

API キーの管理は「動けばいい」で済ませがちですが、MCP サーバーは Claude に強力な外部操作能力を与える仕組みです。その入口となるキー管理こそ、しっかりと守っていきましょう。

山本 哲也 (記事一覧)

多分インフラエンジニアです。データ分析に興味あります。

山を走るのが趣味です。今年は 100 マイルレース完走します。