MCP tunnelsをHelmでデプロイする手順 — 通信制限とOIDCトークン供給
HelmチャートでMCP tunnelsをKubernetesにデプロイし、NetworkPolicyでの通信制限とOIDCトークンの差し替え、症状別の切り分けまで、チャート2.0.2の設定キー単位でまとめます。
HelmチャートはKubernetes上に何を作るのか
Anthropicが配布するHelmチャートは、MCP tunnelsのトンネルスタック(cloudflaredとプロキシの2コンテナ)を単一のDeploymentとしてKubernetesクラスタにインストールします。クラスタ自体をClaudeから直接操作したい場合はKubernetes MCPサーバーの追加手順が別記事にあります。接続先は、チャートのsetupフックが作る新規トンネルか、Consoleで先に作った既存トンネルのどちらかです。作成手順はMCP tunnelsをConsoleで作成・管理する手順で扱っています。
チャートのバージョンは2.0.2です。バージョンは変わり得るので、実行前にhelm show valuesで最新のデフォルト値を取得してから進めます。既存の配布チャートではなく汎用的なHelmチャートを自分でゼロから書く手順はClaude CodeでHelmチャートを作成する手順にまとめています。
最初の分かれ道は、認証情報をどう用意するかです。この選択で、以降の手順とトークン・証明書の運用が変わります。
認証情報の用意の仕方で運用が変わる
プログラム的アクセス
setup.enabled: true(既定)。setupコンポーネントがWorkload Identity Federationで認証し、トンネルトークンの取得、CAとサーバー証明書の生成、CAの登録までを行います。federation ruleの用意が必要で、証明書更新はCronJobが担います。
手動
setup.enabled: false。チャートはAPI呼び出しをせず、トークンの取得、CAと証明書の作成、Secretの更新はすべて利用者の作業です。CronJobもありません。
単一ホストでの検証ならMCP tunnelsをDocker Composeでデプロイする手順もあります。Composeでも、mcp-tunnel/ディレクトリを各ホストにコピーすれば、同じトンネルトークンと証明書で複数台に広げられます。Helmとの違いは動かす場所と、証明書更新をdocker compose run --rm setup renew-cert --output=dir:/dataで自分で実行する点です。
事前に必要なもの
- トンネル。プログラム的アクセスではチャートのsetupフックが未指定なら新規作成し、指定すればConsoleで作った既存トンネルに接続します。手動フローでは必ずConsoleで先に作成し、トンネルトークンとドメインを控えます
- Tunnels APIへの認証手段。プログラム的アクセスなら
workspace:manage_tunnelsスコープのfederation rule、手動ならトンネルトークンとCA証明書 helmとkubectlが使えるKubernetesクラスタ。手動フローではopenssl(1.1.1以降)も使います- アウトバウンド通信。
api.anthropic.comへの443/TCPと、トンネルエッジ(198.41.192.0/19、2606:4700:a0::/44)への7844/TCP・UDP。前者はsetupコンポーネントが使う通信です - 到達可能なMCPサーバーが1つ以上。無ければ次節のサンプルDeploymentで代用できます
- Zero Data RetentionやHIPAA BAAの対象範囲。MCP tunnelsは研究プレビューの機能で、対象かどうかはAPIとデータ保持のページにある機能別の一覧で見ます
手元にMCPサーバーが無いときのテスト用サンプル
到達可能なMCPサーバーをまだ持っていない場合、helloツールが1つあるだけのFastMCPサンプルをDeploymentとして立てられます。サーバーのソースはConfigMapに入れ、Podの/appにマウントする構成です。ConfigMapを省くと、起動コマンドが指す/app/hello_server.pyが存在せず、Podは起動に失敗します。
kubectl create namespace mcp-tunnel --dry-run=client -o yaml | kubectl apply -f -
kubectl -n mcp-tunnel apply -f - <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-mcp-src
data:
hello_server.py: |
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello-server", host="0.0.0.0", port=9000)
@mcp.tool()
def hello(name: str = "world") -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-mcp
spec:
replicas: 1
selector:
matchLabels: { app: hello-mcp }
template:
metadata:
labels: { app: hello-mcp }
spec:
containers:
- name: hello-mcp
image: python:3.13-slim
command: ["sh", "-c", "pip install --quiet mcp && python /app/hello_server.py"]
volumeMounts:
- { name: src, mountPath: /app }
ports:
- { containerPort: 9000 }
volumes:
- name: src
configMap: { name: hello-mcp-src }
---
apiVersion: v1
kind: Service
metadata:
name: hello-mcp
spec:
selector: { app: hello-mcp }
ports:
- { port: 9000, targetPort: 9000 }
EOFこのサンプルを使う場合、gateway.config.routesはecho: http://hello-mcp:9000にします。以降の検証ではこのechoルートを使います。
プログラム的アクセスでインストールする
まずクラスタのOIDC発行者を登録し、federation ruleを作ります。setupコンポーネントはリリース名から導出される専用のServiceAccountで動くため、リリース名がmcp-tunnel以外なら次のコマンドでServiceAccount名を確認してからruleを作ります。
helm template <リリース名> \
oci://us-docker.pkg.dev/anthropic-public-registry/charts/mcp-tunnel \
--version 2.0.2 -n mcp-tunnel | grep -A2 'kind: ServiceAccount'| 項目 | 値 |
|---|---|
| Subject | 値system:serviceaccount:mcp-tunnel:mcp-tunnel-setup |
| Audience | 値api.anthropic.com(スキームなし) |
| Scope | 値workspace:manage_tunnels |
トンネルが組織の既定以外のワークスペースにある場合は、ruleのサービスアカウントをそのワークスペースのメンバーにも加えます。Tunnels APIは、サービスアカウントのワークスペース所属で認可するためです。毎日動く証明書更新のCronJobは別のServiceAccountで動きますが、Tunnels APIを呼ばないのでruleの対象に含める必要はありません。
デフォルト値を取得し、federation ruleのIDとルート先を設定します。
helm show values \
oci://us-docker.pkg.dev/anthropic-public-registry/charts/mcp-tunnel \
--version 2.0.2 > values.yamlvalues.yaml(プログラム的アクセスで編集する主要キー)
api:
wif:
federationRuleId: "fdrl_..."
organizationId: "00000000-0000-0000-0000-000000000000"
# workspaceId: "wrkspc_..." # 非デフォルトワークスペースのときだけ設定
tunnel:
id: "" # 空なら新規作成。既存トンネルに繋ぐならtnl_...を指定
tokenVersion: "1" # ローテーション時にこの値を上げる
gateway:
config:
routes:
docs: http://docs-mcp.internal:8080
search: http://search-mcp.internal:8080routesのキーはサブドメインになり、この例ならClaudeからはdocs.<トンネルのドメイン>とsearch.<トンネルのドメイン>で届きます。値はscheme://host:portの形で、ポートは必須です。パスを含めると、設定の読み込みでinvalid upstream (must be scheme://host:port)として拒否されます。リクエストのパスとクエリは、変更されずにそのままMCPサーバーへ転送されます。
レンダリング結果を確認してからインストールします。
helm template mcp-tunnel \
oci://us-docker.pkg.dev/anthropic-public-registry/charts/mcp-tunnel \
--version 2.0.2 -n mcp-tunnel -f values.yaml > rendered.yaml
helm install mcp-tunnel \
oci://us-docker.pkg.dev/anthropic-public-registry/charts/mcp-tunnel \
--version 2.0.2 \
--namespace mcp-tunnel --create-namespace \
-f values.yamlsetupコンポーネントはHelmのpre-installフックJobとして動くため、helm installはこのJobが完了するまでブロックします。成功するとHelmがJobを自動で削除します。tunnel.idを空にした場合、トンネルはfederation ruleが対象とするワークスペースに作られます(api.wif.workspaceIdを設定しなければ組織の既定ワークスペースです)。そのIDとドメインはmcp-tunnelという名前のSecretに保存され、次のコマンドで読み出せます。ConsoleのManage > MCP tunnelsにあるトンネルの詳細ページでも、同じドメインを確認できます。
kubectl -n mcp-tunnel get secret mcp-tunnel \
-o jsonpath='{.data.tunnel-domain}' | base64 -dアップグレードやトークンローテーションでsetupコンポーネントを再実行しても、このSecretに保存されたトンネルIDを再利用するだけで、2本目のトンネルは作られません。
api.wif.*の値は識別子であって秘密情報ではないため、Helmのリリース履歴Secretに残ってもリスクにはなりません。実際に機微なのは、setupコンポーネントが作るmcp-tunnel Secret(トンネルトークンとTLS秘密鍵を保持)のほうです。
手動フローでインストールする
setup.enabled: falseにすると、チャートはAPI呼び出しを一切行わず、setupコンポーネントも証明書更新のCronJobも動きません。Workload Identity Federationを組みたくない場合はこちらを使います。
まずConsoleでトンネルを作り、トンネルトークンを控えます。詳細ページに表示されるトンネルのドメインは、後でgateway.config.tunnel_domainに設定します。次に、CAとサーバー証明書を自分で作ります。サーバー証明書のSANには*.<トンネルのドメイン>を含める必要があります。
export TUNNEL_DOMAIN=YOUR_TUNNEL_DOMAIN_HERE
mkdir -p mcp-tunnel/data && cd mcp-tunnel
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout data/ca.key -out data/ca.crt \
-days 3650 -subj "/CN=mcp-tunnel-ca" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"
cat > data/tls.ext <<EOF
subjectAltName = DNS:${TUNNEL_DOMAIN},DNS:*.${TUNNEL_DOMAIN}
authorityKeyIdentifier = keyid,issuer
extendedKeyUsage = serverAuth
EOF
openssl req -newkey rsa:2048 -nodes \
-keyout data/tls.key -out /tmp/server.csr \
-subj "/CN=${TUNNEL_DOMAIN}"
openssl x509 -req -in /tmp/server.csr \
-CA data/ca.crt -CAkey data/ca.key -CAcreateserial \
-out data/tls.crt -days 90 -extfile data/tls.ext拡張を-copy_extensionsでなく-extfileで渡しているのは、OpenSSL 3.0以降でしか使えない前者を避け、1.1.xでも動かすためです。上のコマンドで作った証明書を読み出すと、確認すべき点は次のように出ます(ドメインは例です)。
openssl x509 -in data/tls.crt -noout -text \
| grep -A1 -E "Alternative|Authority Key|Extended Key"X509v3 Subject Alternative Name:
DNS:abc123.tunnel.example.com, DNS:*.abc123.tunnel.example.com
X509v3 Authority Key Identifier:
AF:E3:20:3E:D5:18:9A:EC:EE:23:71:FC:A2:02:44:E1:8A:9B:95:C0
X509v3 Extended Key Usage:
TLS Web Server AuthenticationAuthority Key Identifierは、CA側のSubject Key Identifier(openssl x509 -in data/ca.crt -noout -textで見えます)と一致している必要があります。サーバー証明書は、登録済みのCAが中間CAを挟まずに直接署名したものでなければならないからです。CAのdata/ca.crtはConsoleに登録し、data/ca.keyは更新時の署名に使うので安全な場所に残します。
そのうえで、チャートが読む2つのSecretを作ります。Secret名は変更できますが、中のキー名は固定です。
kubectl create namespace mcp-tunnel --dry-run=client -o yaml | kubectl apply -f -
kubectl -n mcp-tunnel create secret generic mcp-tunnel-token \
--from-literal=tunnel-token='eyJ...'
kubectl -n mcp-tunnel create secret generic mcp-tunnel-cert \
--from-file=tls.crt=data/tls.crt --from-file=tls.key=data/tls.keyvalues.yamlでは以下を設定します。
setup.enabled: falseexternal.tunnelTokenSecretName(中身のキーはtunnel-token)external.serverCertSecretName(中身のキーはtls.crtとtls.key)gateway.config.tunnel_domain(setup有効時はSecretから自動注入されるが、無効時は明示が必須)
この先のhelm templateとhelm installはプログラム的アクセスと同じコマンドです。
インストール後に疎通を確かめる
プロキシはプレーンなWebSocketで待ち受け、TLSはそのストリームの内側で行います。このため、プロキシのアドレスにcurl https://...を打つとwrong version numberで失敗しますが、これは正常です。確認はAnthropic側から行います。Managed AgentsのセッションかMessages APIのリクエストで、https://<route>.<トンネルのドメイン>/<path>を指定します。<route>はgateway.config.routesのキー、<path>はMCPサーバーが公開しているパスです。サンプルサーバーならhttps://echo.<トンネルのドメイン>/mcpになります。
失敗したときは、2つのコンテナのログを見ます。
kubectl -n mcp-tunnel logs deploy/mcp-tunnel -c mcp-proxy
kubectl -n mcp-tunnel logs deploy/mcp-tunnel -c cloudflaredNetworkPolicyで外向き通信(egress)を絞る(任意設定)
プロキシPodへの受信通信は既定で遮断されます(networkPolicy.ingress.enabledの既定値がtrue)。さらにPodからの送信も絞るなら、networkPolicy.egress.enabled: trueにし、networkPolicy.egress.mcpServersにPodラベルセレクタかCIDRを列挙して、アップストリームのMCPサーバー以外への通信を遮断します。cloudflaredからトンネルエッジへの送信は別枠のnetworkPolicy.egress.cloudflaredEgressCIDRsで許可されるので、この2つは独立して設定します。
NetworkPolicyはネットワーク経路を絞るだけで、トンネル自体はアップストリームのMCPサーバーを認証しません。トンネルが運ぶのは暗号化された通信で、MCPサーバーが認証を要求するなら、ほかのMCPサーバーと同じ方法で個別に渡します。各MCPサーバー側にOAuthかbearer認証を設定する必要があり、仕組みはリモートMCPのOAuth認証で解説しています。
プロキシの設定を調整する
gateway.config.*の項目は、プロキシの設定ファイルにそのまま渡されます。よく触るのはupstream.allowed_ips、log_level、upstream.tlsです。listen_addr、tls.cert_file、tls.key_fileはチャートが常に設定するので、gateway.configに書いても効きません。
アップストリームがhttps://のルートなら、upstream.tls.ca_fileかupstream.tls.include_system_casのどちらかを設定します。どちらも無いと、プロキシにはアップストリームの証明書を検証する信頼の起点がありません。
自前のOIDCトークンを供給する(任意設定)
チャートは既定でKubernetesのServiceAccountトークンをsetupコンポーネントに投影します。SPIFFEやVault、クラウドSDKのサイドカーなど別のIDプロバイダのトークンを使いたい場合は、setup.extraVolumesとsetup.extraVolumeMountsでトークンファイルをマウントし、api.wif.tokenFileにそのマウントパスを指定します。チャートはANTHROPIC_IDENTITY_TOKEN_FILE環境変数をそのパスに設定し、setupコンポーネントはそこからトークンを読みます。
設定を変える・トークンをローテーションする
ルートやレプリカ数、NetworkPolicyなどの通常の変更は、values.yamlを直してhelm upgradeします。helm upgradeには必ず--versionを付けます。省くと、意図せず新しいチャートを引いてしまいます。
トークンのローテーションは、プログラム的アクセスならvalues.yamlのtunnel.tokenVersionを上げてから、--set setup.force=trueを付けてアップグレードします。setupコンポーネントは、フォースしない限りアップグレード時には再実行されません。
helm upgrade mcp-tunnel \
oci://us-docker.pkg.dev/anthropic-public-registry/charts/mcp-tunnel \
--version 2.0.2 -n mcp-tunnel \
-f values.yaml --set setup.force=trueこの経路はWorkload Identity Federationで認証するので、失効させるAPIトークンはありません。
手動フローでは、Consoleのトンネル詳細ページでRotate tokenを押した後、mcp-tunnel-token Secretを更新してDeploymentをロールアウトし直します。
kubectl -n mcp-tunnel create secret generic mcp-tunnel-token \
--from-literal=tunnel-token='eyJ...' --dry-run=client -o yaml | kubectl apply -f -
kubectl -n mcp-tunnel rollout restart deploy/mcp-tunnel証明書を更新する
寿命まわりの数字は次のとおりです。setupコンポーネントが自動生成する証明書の値で、更新の判定に使うrenewBeforeもあわせて載せています。
自動生成される証明書の寿命と更新の周期
サーバー証明書の有効期間
90日
RSA 4096ビット、ワイルドカードSAN
更新を始める残り日数
30日
serverCert.renewBeforeの既定値
CAの有効期間
5年
ECDSA P-256
登録できる有効なCA
2つ
無停止のローテーションに使う
プログラム的アクセスでは、リリース名にちなんで命名されたCronJob(サフィックス-cert-renew)が、serverCert.cronScheduleの既定値(毎日UTC 0時)でsetup renew-certを実行します。有効期限までserverCert.renewBeforeを切っていない限りno-opです。更新はローカルで完結し、Secretに保存済みのCAで新しい証明書に署名するだけなのでAPI呼び出しは発生せず、プロキシはSecretのマウントを自動でホットリロードします。CronJobが動いていても、期限の監視と更新の完了確認は利用者の仕事として残ります。
手動フローにはCronJobが無いため、インストール後も残したmcp-tunnel/ディレクトリの中で、既存のCAを使って署名し直します。CAは作り直しません。
openssl req -new -key data/tls.key -out /tmp/server.csr \
-subj "/CN=${TUNNEL_DOMAIN}"
openssl x509 -req -in /tmp/server.csr \
-CA data/ca.crt -CAkey data/ca.key -CAcreateserial \
-out data/tls.crt -days 90 -extfile data/tls.ext
kubectl -n mcp-tunnel create secret generic mcp-tunnel-cert \
--from-file=tls.crt=data/tls.crt --from-file=tls.key=data/tls.key \
--dry-run=client -o yaml | kubectl apply -f -Secretを更新すれば、プロキシがマウントを自動でホットリロードするので、Deploymentの再起動は要りません。
チャート1.xからアップグレードする
チャート2.0.0で、トンネルIDの置き場所がapi.wif.tunnelIdからtunnel.idに変わりました。アップグレード前にvalues.yamlを編集し、tnl_...の値をtunnel.idへ移してapi.wif.tunnelIdを削除します。tunnel.idを空のままにしても、setupコンポーネントがmcp-tunnel Secretに保存済みのIDを再利用するので動きますが、values.yamlの記述が実態とずれます。federation ruleのスコープも、Console側でorg:manage_tunnelsからworkspace:manage_tunnelsへ更新が必要です。
詰まったときの切り分け
リクエストは3つの層のどこかで失敗します。外側から順に、トンネルエッジへの外向き接続、AnthropicからプロキシへのインナーTLS、プロキシからアップストリームへのルーティングとIP検証です。症状がどの層かを決めてから、下の表で原因を探します。
切り分けの順番
- 1
トンネルエッジに繋がっているか
cloudflaredのログを見ます。トンネルトークンの欠落・期限切れ・コピーミスか、7844番ポートのTCP/UDPをファイアウォールが塞いでいるのが典型です。UDP受信バッファのサイズに関する警告はQUICの調整のヒントで、エラーではありません。
- 2
インナーTLSが通っているか
プロキシが
tls handshake failedを出すなら、サーバー証明書の期限、SANの*.<トンネルのドメイン>、署名したCAがこのトンネルに登録されているか、の3点を順に見ます。 - 3
ルートとアップストリームに届いているか
プロキシのログに
no route for hostやIP validation failedが出ていれば、この層です。routesのキー、tunnel_domain、許可IPの順に確認します。
| 症状 | 原因 | 直し方 |
|---|---|---|
helm installがフックのエラーで失敗し、setup Jobが残る | 原因federationの交換が失敗している | 直し方kubectl logs job/mcp-tunnel-setup -n mcp-tunnelで読み、helm uninstall後にJobを手で消してから再試行 |
Tunnels APIから403 | 原因ruleのスコープにworkspace:manage_tunnelsが無い、またはサービスアカウントがトンネルのワークスペースのメンバーでない | 直し方スコープを設定し、メンバーに加える |
| エージェントの+ MCP Serverにトンネルが出ない | 原因一覧に出るのは、セッションのワークスペースにあり、有効な証明書が1つ以上あるトンネルだけ | 直し方CA証明書を登録するか、トンネルを作ったワークスペースでセッションを開く |
no route for host | 原因tunnel_domainが割り当てドメインと違うか、設定を直したのにプロキシが再起動していない | 直し方詳細ページのドメインを設定し、helm upgradeで反映する |
cannot unmarshal !!seq into map[string]stringでプロキシが終了 | 原因routesがYAMLのリストになっている | 直し方routes: { 名前: http://host:port }のマップにする |
認証の失敗は、federationの交換そのものの問題と、MCP tunnels固有の原因に分かれます。前者は件名・Audience・発行者・JWKS・有効期限の不一致で、Workload Identity Federationのリファレンスにある切り分けと同じです。後者の代表がAudienceのスキーム違いで、チャートの既定はapi.anthropic.com、Consoleの提案はhttps://api.anthropic.comでした。
IP validation failed: <ip> is not a private addressは、プロキシのSSRF対策に当たっています。プロキシが接続するのは、既定ではRFC1918の私設アドレス(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16)で、IPv4のみです。マネージドKubernetesの一部は、ServiceのCIDRをこの範囲の外に割り当てます。次のコマンドの結果が私設範囲に入っていなければ、そのクラスタのCIDRを足す必要があります。
kubectl get svc kubernetes -n default -o jsonpath='{.spec.clusterIP}'足すときは、必要最小の範囲をgateway.config.upstream.allowed_ipsに書きます。落とし穴になるのは、allowed_ipsが既定のRFC1918を置き換えることです。拡張ではないので、ほかのMCPサーバーが私設アドレスにあるなら、その範囲も一緒に列挙します。
gateway:
config:
upstream:
allowed_ips:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 203.0.113.0/24 # 例: 自クラスタのService CIDRに置き換える0.0.0.0/0にするとSSRF対策が丸ごと無効になるため、ローカルでの試験以外では使いません。
まとめ
Helmで詰まるのは、Audienceの文字列、allowed_ipsが既定を置き換える点、手動フローの証明書更新の3か所です。運用の手間を減らしたいなら、トークンはtokenVersionとforceアップグレード、証明書は日次のCronJobで回せるプログラム的アクセスを選ぶ、という分かれ道になります。