Jenkins に MCP を生やして、Claude に「dev3 に feature/foo を配って」と言うだけでデプロイできるようにした
社内のインフラリポジトリでの実作業をもとにした記事です。 ホスト名・ジョブ名・パスは仮名に置き換えてあります(
jenkins.example.com/deploy-app//opt/appなど)。
3行まとめ
- Jenkins の
mcp-serverプラグインで、Jenkins 自身を MCP サーバにした - Claude から「dev3 に feature/foo を配って」と言うだけで
deploy-appジョブが流れ、ビルドログもそのまま読める - ハマりどころは
Authorizationヘッダが1リクエストに1つしか載らないことと、権限を絞らないと MCP のトークンが本番への SSH 鍵まで届いてしまうことの2点
1. なぜやったか
もともとの dev 環境へのデプロイ手順はこうでした。
- VPS に SSH する
cd /opt/app/infra/dev3git fetch && git checkout feature/foo- ブラウザで開いて動作確認
これを Jenkins のジョブ(deploy-app)にして、ブラウザのプルダウンから
「環境(test / dev1〜dev8)」と「ブランチ・タグ」を選ぶだけにしたのが前段階。
社内 VPN の外からでも配れるようになり、SSH 鍵を配らなくてよくなりました。
ただ、実際の開発中はこういう流れになります。
「このブランチ、dev3 に上げて確認してもらえますか」 → ブラウザで Jenkins を開く → Basic 認証 → ログイン → ジョブを選ぶ → プルダウンから探す → Build
Claude と会話しながら作業しているときに、この「ブラウザに移動する」が地味に重い。 デプロイが失敗したときも、コンソールログを見るために結局ブラウザへ戻ることになります。
Jenkins には公式の mcp-server プラグインがあり、
Jenkins 自身を MCP(Model Context Protocol)サーバとして喋らせられる。
これを繋げば「デプロイの依頼」も「失敗の原因調査」も会話の中で完結するはずだ、というのが出発点です。
2. できあがったもの
Claude Code に MCP サーバとして登録すると、こうなります。
> dev3 に feature/foo を配って ● infra-jenkins - triggerBuild (deploy-app, ENV_NAME=dev3, GIT_REF=feature/foo) ● infra-jenkins - getBuild (#412 → SUCCESS) dev3 に feature/foo を配りました。疎通確認も通っています(web-dev3 が 200)。
失敗したときはそのままログを読みにいきます。
> 失敗してる。なんで? ● infra-jenkins - getBuildLog (deploy-app #413) git checkout で落ちています。`feature/foo` は origin に無く、ローカルの `feature/foo-2` のことではないですか?
使えるツールは概ねこのあたり。
| ツール | 用途 |
|---|---|
triggerBuild / updateBuild |
deploy-app の起動・中止 |
getJobs / getJob |
ジョブ一覧・定義の閲覧 |
getBuild / getBuildLog |
ビルド結果とコンソールログ |
getBuildChangeSets / getBuildScm |
そのビルドが何を配ったか |
whoAmI / getStatus |
認証と Jenkins の健康状態の確認 |
本番リリースだけは、あえてアプリから叩けないようにしてあります。 後述します。
3. 構成
Claude (MCP クライアント) │ HTTPS + Authorization: Basic <mcp:APIトークン をBase64> ▼ Caddy (jenkins.example.com) ├ /mcp-server/* … Basic認証を掛けない / Authorization をそのまま通す └ それ以外 … 従来どおり Basic認証 + Authorization を落とす ▼ Jenkins (mcp-server プラグイン) ├ admin : Overall/Administer(人がブラウザから使う) └ mcp : 閲覧 + deploy-app の Build/Cancel だけ ▼ deploy-app → ホストの docker compose(DooD)で対象環境を入れ替える
やったことは大きく3つです。
mcp-serverプラグインをイメージに焼く- Caddy で
/mcp-server/*だけ前段の Basic 認証を外す - Jenkins の権限モデルを
matrix-authに変えて、MCP 用ユーザーの権限を絞る
順に、ハマったところと一緒に書いていきます。
4. ハマりどころ①:Authorization ヘッダは1リクエストに1つしか載らない
ここが一番の山でした。
この Jenkins はもともと Caddy の前段 Basic 認証(共有パスワード)の内側に置いてあり、
しかも Caddy は検証し終えた Authorization ヘッダを落として Jenkins に渡していました。
reverse_proxy jenkins:8080 {
# Caddy が検証し終えた Authorization をそのまま渡すと、Jenkins が
# 「Jenkinsユーザー admin の Basic 認証」と解釈して全リクエストが401になる
header_up -Authorization
}
そこへ MCP クライアントを繋ごうとすると、詰みます。
- MCP クライアントは Jenkins の API トークンを
Authorization: Basic ...で送りたい - でも前段の Basic 認証も
Authorization: Basic ...を要求する - HTTP の
Authorizationヘッダは1リクエストに1つしか載せられない
「Basic 認証を2段通す」方法が存在しないので、パスを分けるしかないという結論になりました。
jenkins.example.com {
handle /mcp-server/* {
route {
# Authorization が無いリクエストはJenkinsまで通さない
@noauth not header Authorization *
respond @noauth "Authorization header required" 401
reverse_proxy jenkins:8080 {
flush_interval -1
}
}
}
handle {
basic_auth {
admin {env.DEV_BASIC_AUTH_HASH}
}
reverse_proxy jenkins:8080 {
header_up -Authorization
}
}
}
なぜ basic_auth ではなく「ヘッダの有無」だけ見るのか
/mcp-server/* を完全な素通しにはしたくなかった。
Jenkins は 匿名でも MCP の initialize と tools/list に応答し、セッション ID まで発行します。
ジョブのデータ自体は Overall/Read が無いので AccessDeniedException になり漏れませんが、
資格情報を持たない接続をわざわざ通す理由もない。
かといってここに basic_auth を書くと、まさにそれがこの handle を分けている理由(ヘッダが1つしか載らない)
に引っかかって API トークンを送れなくなる。
そこで「Authorization ヘッダが付いているかだけ見て、中身は検証しない」という中間をとりました。
中身の検証は Jenkins の仕事、という割り切りです。
route で囲む必要がある
最初 handle の直下に @noauth と reverse_proxy を並べて書いたら、401 が返らず素通ししました。
Caddy は handle の直下だとディレクティブを既定の順序に並べ替えるため、書いた順に評価されません。
route で囲むと書いた順になります。
flush_interval -1
streamable HTTP / SSE はレスポンスを少しずつ流し続けます。 Caddy がバッファすると、ツールの結果や通知がクライアントへ届かない(=固まったように見える)。 このプロキシだけバッファを切ります。
Jetty のキープアライブ
MCP の接続は1本の HTTP 接続を張り続けます。Jetty の既定のキープアライブは短く、 アイドル中の接続が切られると クライアント側からは「セッションが消えた」ように見える。 プラグインの推奨どおり伸ばしました。
ENV JENKINS_OPTS="--httpKeepAliveTimeout=600000"
5. ハマりどころ②:権限を絞らないと、MCP のトークンが本番の SSH 鍵まで届く
Caddy の Basic 認証を外した以上、この Jenkins は「守りが1段」になります。 API トークンが漏れたら、それだけで Jenkins に入れる。
そのとき何ができてしまうかを数えると、けっこう怖いことになっていました。
もともとの権限設定は loggedInUsersCanDoAnything(ログインできる人は全権)。この状態だと、
MCP 用のトークン1本で、
- dev 環境へのデプロイ(これは想定内)
- 本番・stg のリリースジョブの起動
- スクリプトコンソール(= Jenkins コンテナ上で任意の Groovy → dev VPS の root)
- リリースジョブが使っている本番 VPS への SSH 鍵
まで届いてしまう。 アプリに「dev に配る権限」を渡したかっただけなのに、本番まで渡すことになる。
そこで matrix-auth の projectMatrix に切り替えました。
authorizationStrategy: projectMatrix: entries: - user: name: "${JENKINS_ADMIN_ID}" permissions: - "Overall/Administer" # 人が使う。従来どおり全権 - user: name: "${JENKINS_MCP_ID}" permissions: - "Overall/Read" # 読むだけ。Build はここでは与えない - "View/Read" - "Job/Read"
そして deploy-app のジョブ定義側でだけ Build を足す。
authorization {
userPermission('hudson.model.Item.Build', '${JENKINS_MCP_ID}')
userPermission('hudson.model.Item.Cancel', '${JENKINS_MCP_ID}')
}
こうすると、
deploy-app… MCP から起動できるrelease-prod/release-stg… 一覧には見えるがtriggerBuildは 403 で落ちる- スクリプトコンソール・資格情報の閲覧・ジョブの作成/変更 … 全部不可
本番リリースは「人がブラウザで、タグとリリースノートと CONFIRM チェックを見てから出す」運用のままにしました。
ここを自動化しない判断は意図的です。dev は壊れても配り直せばいいが、本番はそうではない。
注意:projectMatrix に書いていないユーザーは権限ゼロになる
「ログインできれば全権」からの移行なので、管理画面から手で追加したユーザーがいると、
ログインはできるのにジョブが1つも見えない状態になります。移行時は casc.yaml に足すのを忘れずに。
なお、誰も Overall/Administer を持たない状態にすると管理画面に入れなくなりますが、
設定は Configuration as Code のファイルが正なので、直して再読み込みすれば戻ります。
管理画面でぽちぽち設定していたら詰んでいたところで、JCasC にしておいてよかった点でした。
プラグインのバージョンを固定した
mcp-server プラグインの 0.84.v50ca_24ef83f2 以前は、MCP のツールが Jenkins の権限を見ていませんでした
(CVE-2025-64132、0.86.v7d3355e6a_a_18 で修正)。
つまり古い版だと、上でやった「権限を絞る」という設計そのものが効かず、
mcp のトークンで release-prod まで叩けてしまう。
権限設計が「この修正が入っている版であること」に乗っているので、 キャッシュや古いイメージで静かに前へ戻らないよう、ここだけバージョンを固定しています。
mcp-server:0.203.v12a_6f2a_01d72
6. ハマりどころ③:API トークンであって、パスワードではない
Basic 認証に渡すのは Jenkins の API トークンです。ユーザーのパスワードでも認証自体は通りますが、
パスワードで通すと Jenkins の CSRF 保護が効いてしまい、triggerBuild のような POST が crumb 不足で落ちます。
API トークンでの認証は crumb を免除されます。
# 送るヘッダの値を作る printf 'mcp:%s' "<トークン>" | base64 -w0
Claude Code(CLI)への登録はこれだけ。
claude mcp add --transport http infra-jenkins https://jenkins.example.com/mcp-server/mcp \ --header "Authorization: Basic <上で作ったBase64>"
リモート MCP を直接扱えないデスクトップアプリなら mcp-remote を挟みます。
Authorization: Basic ... は空白入りなので、値は環境変数から渡すのがポイント。
{ "mcpServers": { "infra-jenkins": { "command": "npx", "args": [ "-y", "mcp-remote", "https://jenkins.example.com/mcp-server/mcp", "--header", "Authorization:${JENKINS_MCP_AUTH}" ], "env": { "JENKINS_MCP_AUTH": "Basic <上で作ったBase64>" } } } }
疎通確認は initialize を直接投げるのが早い
MCP クライアントを立ち上げる前に、curl で最初のやりとりだけ投げると切り分けが速い。
curl -sS -u "mcp:<トークン>" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-w '\n--- HTTP %{http_code}\n' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
https://jenkins.example.com/mcp-server/mcp
返ってくるステータスで原因が分かれます。
| 原因 | |
|---|---|
| 401 | Authorization を送っていない(本文が Authorization header required なら Caddy が断っている)/Caddy の Basic 認証がまだ /mcp-server/* に掛かっている/トークンが違う |
| 403 | 資格情報が届いていないか、そのユーザーにその操作の権限が無い |
| 404 | プラグインが入っていない |
401 が2種類あるのが厄介で、Jenkins は打ち間違い・revoke 済みのトークンにも 401 を返します。
curl -sI の WWW-Authenticate の realm が Caddy 側か realm="Jenkins" かで見分けられます。
7. ハマりどころ④:ドロップダウンの既定値が、黙って main を配っていた
これは MCP を繋いで初めて踏んだ、気づきにくい割に一番怖かったバグです。
deploy-app の GIT_REF はブラウザ用に Active Choices(uno-choice)で組み立てたドロップダウンです。
一覧の先頭は main にしていました。
ところが MCP 経由で GIT_REF を渡し忘れると、
Jenkins がパラメータの既定値を入れて、その既定値は Active Choices の一覧の先頭になります。つまり、
「
feature/fooを dev3 に配って」と頼んだのにmainが配られ、ビルドは SUCCESS で返ってくる
これが起きる。人間がブラウザで操作しているときは選択が目に見えるので踏まないが、 アプリ越しだと間違った ref が配られたことに誰も気づかない。
対策は2段にしました。
- 一覧の先頭を空欄にし、既定値も空にする
- パイプラインの確認ステージで
GIT_REF が空ですとして落とす
if (!params.GIT_REF?.trim()) { error("GIT_REF が空です。ブランチかタグを選んでください") }
ENV_NAME には同じ対策を入れていません。 省略すると先頭の test へ配られます
(選択肢が固定の choiceParam で、空を混ぜるとブラウザ側の体験が悪くなるため)。
アプリ側には必ず両方を渡させる、という運用で埋めています。
教訓: 人間の目が入る前提で作った UI をそのままエージェントに叩かせると、 「選ばなかった」が「既定値を選んだ」になる。既定値が安全側に倒れているかを必ず見直すこと。
8. MCP から見ると Jenkins はどう違って見えるか
ドロップダウンの候補は見えない。でも、それでいい
GIT_REF は Active Choices が Groovy で組み立てているので、MCP の getJob では候補が返りません。
アプリ側にはブランチ名を文字列で渡してもらう形になります。
これは不便に見えて、実はMCP 経由のほうができることが多い。
- ブラウザのドロップダウンには「open な PR になっているブランチ」と「タグ」しか出していない
(PR の ref を
git ls-remoteで引いて絞り込んでいる。過去に PR にしたブランチが161本も並ぶのを防ぐため) - MCP 経由なら、PR を出す前のブランチも、
mainマージ済みのブランチも、生のコミットハッシュも指定できる
渡した値はそのまま deploy.sh に届きます
(mcp-server はプラグイン製のパラメータにもリフレクションで createValue(String) を当てるため)。
存在しない ref なら git rev-parse で落ちるので、黙って別のものが配られることはありません
——GIT_REF を渡し忘れたとき以外は。だから前節の対策が要ったわけです。
誰が配ったかは残らない
ビルドの実行者は全部 mcp になります。 「誰がアプリから流したか」は Jenkins 側に残りません。
人ごとに追いたければ、ユーザーとトークンを人数分作って projectMatrix とジョブの authorization に足す必要があります。
今は人数が少ないので割り切っていますが、増えたら分けることになると思います。
トークン = dev 環境へのデプロイ権限そのもの
漏れたら Jenkins の mcp → Security から revoke します。
パスワードを変えるだけでは API トークンは無効になりません(別物なので)。ここは間違えやすい。
9. 実際どうか
体感で一番効いているのは、デプロイそのものより「失敗したときの往復」が消えたことでした。
これまでは「デプロイした → 失敗した → ブラウザでログを開く → 読む → 直す」だったのが、
失敗した瞬間にログが読まれていて、原因の候補が出てくる。
git checkout でコケたのか、疎通確認(http://web-<env>/ に 60 秒待って 2xx/3xx が返るか)でコケたのかで
対処がまったく違うので、ここが自動で切り分けられるのは大きい。
ちなみに、この記事を書いている Claude Code のセッションからも実際に繋がっていて、
whoAmI → {"fullName":"mcp"}
getJobs → 3 件(deploy-app / release-prod / release-stg)
と返ってきます。mcp として認証され、ジョブは見えている。
そして release-prod を triggerBuild しようとすれば 403 で落ちる——狙いどおりの見え方です。
10. これからやるなら、という話
同じことをやる人向けに、順番を間違えるとつらい点だけ。
- 先に権限モデルを決める。 「ログインできる人は全権」のまま MCP を生やすと、
トークン1本の価値が跳ね上がる。プラグインを入れる前に
matrix-authへ移行しておくほうが安全 - Jenkins の設定は Configuration as Code で持つ。 権限設定をいじると管理画面から締め出されることがあり、 そのときファイルが正でないと復旧できない
- エージェントに叩かせるジョブは、既定値を安全側に倒す。 「省略された」が「既定値が選ばれた」になる
- プラグインのバージョンは固定する。 権限を見ないバージョンが存在した以上、 設計が特定バージョン以降に依存している
そして、本番リリースを MCP から叩けるようにしないのは、技術的な制約ではなく選択です。 dev へのデプロイは何度でもやり直せるからエージェントに任せる。本番は人が画面を見て出す。 この線をどこに引くかを最初に決めておくと、権限設計がそのまま素直に書けます。