Jenkins に MCP を生やして

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 環境へのデプロイ手順はこうでした。

  1. VPS に SSH する
  2. cd /opt/app/infra/dev3
  3. git fetch && git checkout feature/foo
  4. ブラウザで開いて動作確認

これを 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つです。

  1. mcp-server プラグインをイメージに焼く
  2. Caddy で /mcp-server/* だけ前段の Basic 認証を外す
  3. 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段にしました。

  1. 一覧の先頭を空欄にし、既定値も空にする
  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. これからやるなら、という話

同じことをやる人向けに、順番を間違えるとつらい点だけ。

  1. 先に権限モデルを決める。 「ログインできる人は全権」のまま MCP を生やすと、 トークン1本の価値が跳ね上がる。プラグインを入れる前に matrix-auth へ移行しておくほうが安全
  2. Jenkins の設定は Configuration as Code で持つ。 権限設定をいじると管理画面から締め出されることがあり、 そのときファイルが正でないと復旧できない
  3. エージェントに叩かせるジョブは、既定値を安全側に倒す。 「省略された」が「既定値が選ばれた」になる
  4. プラグインのバージョンは固定する。 権限を見ないバージョンが存在した以上、 設計が特定バージョン以降に依存している

そして、本番リリースを MCP から叩けるようにしないのは、技術的な制約ではなく選択です。 dev へのデプロイは何度でもやり直せるからエージェントに任せる。本番は人が画面を見て出す。 この線をどこに引くかを最初に決めておくと、権限設計がそのまま素直に書けます。

MCPサーバーで自動PRレビュー 第三弾:ローカルリポジトリでdiff取得 & レビュー対象の絞り込み

前回の記事では、MCPサーバーに GitHub PR のコメント取得・レビュー投稿ツールを実装しました。

今回は実運用で見えてきた課題を解決する二つの改善を加えます。

  1. diff 取得をローカル clone 経由に変更する
  2. clone したリポジトリ以外をレビュー対象外にする

なぜ GitHub MCP を使わないのか

PR レビューの文脈では GitHub MCP を使う選択肢もあります。しかし今回は採用しませんでした。理由は二つです。

① Backlog にも対応する必要がある

このサーバーは GitHub だけでなく Backlog の PR もレビュー対象です。GitHub MCP を使うと GitHub 専用のツール体系になってしまい、Backlog 向けの実装と統一感が取れなくなります。独自クライアントで揃えることで、両プラットフォームを同じ設計パターンで扱えます。

② MCP 自体の挙動をコントロールしにくい

GitHub MCP のようなサードパーティ MCP サーバーをそのまま Claude に渡すと、どのツールをどの順番で呼ぶかを Claude が自律的に判断します。レビューフローでは「まず diff を取得し、次にコメントを取得してからレビューを投稿する」という順序を守ってほしいのですが、MCP サーバーをそのまま渡す形だとその制御が難しくなります。独自の MCP ツールとして定義し description で指示を与えることで、Claude の動作をフローに沿って誘導しています。


なぜローカル clone が必要なのか

前回まで diff は GitHub REST API の List pull requests files で取得していました。しかしこのエンドポイントにはいくつか制限があります。

  • ファイル数上限が 300:大きな PR だとファイルが切り捨てられる
  • 差分行数の上限:ファイルごとに patch フィールドが省略されることがある

レビューコメントの行番号は unified diff の RIGHT 側の行番号と一致しなければなりません。API が返す情報が不完全だと行番号のズレが生じ、インラインコメントの投稿に失敗します。

そこで あらかじめリポジトリをローカルに clone しておき、git fetch → git diff でフルの unified diff を取得する アプローチに切り替えました。


実装の全体像

MCPツール (getPrDiff)
   │
   ▼
GetPrDiffService
   ├── GitHubApiClient    … PR の base/head ブランチ名を取得
   └── LocalGitDiffClient … ローカル clone で git diff を実行
         │
         ▼
      DiffParser … unified diff から「コメント可能な行番号」を抽出

ディレクトリ構成(抜粋)

my-mcp-server/
├── module/
│   ├── domain/
│   │   └── DiffParser.java          # unified diff パーサ
│   ├── repository/
│   │   ├── LocalGitDiffClient.java  # git コマンド実行
│   │   ├── LocalGitException.java   # 例外クラス
│   │   └── RepositoryClientConfig.java
│   └── service/
│       └── GetPrDiffService.java    # 取得フローの統括
└── mcp/
    └── PrInspectionComponent.java   # MCPツール定義

LocalGitDiffClient

public class LocalGitDiffClient {

  // baseDir 配下に clone 済みのリポジトリが置いてある前提
  public String getDiff(String repoName, String baseBranch, String headBranch) { ... }
}

内部では ProcessBuilder で以下の 2 コマンドを順番に実行します。

git fetch --quiet origin <baseBranch> <headBranch>
git diff origin/<baseBranch>...origin/<headBranch>

ポイント:ディレクトリ存在確認が「レビュー制限」を兼ねる

getDiff() は baseDir.resolve(repoName) にディレクトリが存在しなければ LocalGitException を投げます。「ローカルに clone していないリポジトリは diff が取れない」 という構造がそのままレビュー対象の絞り込みになります。

明示的な許可リストを持たなくても、clone してあるリポジトリだけがレビュー対象 になります。


GetPrDiffService

@Service
@RequiredArgsConstructor
public class GetPrDiffService {

  private final GitHubApiClient gitHubApiClient;
  private final LocalGitDiffClient localGitDiffClient;

  public record FileInfo(String path, List<String> commentableLineRanges) {}

  public record Result(
      boolean success, String prUrl, String diff,
      List<FileInfo> files, String error, ...) {}

  public Result getPrDiff(PrRef ref, String prUrl) { ... }
}

① GitHub API で base/head ブランチ名を取得 → ② ローカル clone で diff を取得 → ③ DiffParser でコメント可能な行番号を抽出、という流れです。PR のメタデータは GitHub API、diff の中身はローカル git と役割を分担しています。


設定とリポジトリの準備

localRepos.dir プロパティで clone の置き場所を指定します。

export localRepos.dir=/home/user/repos
java -jar mcp-server.jar

localRepos.dir 配下にリポジトリ名でディレクトリを作り、clone しておきます。

# github.com/dummy-org/my-app の PR をレビューする場合
cd /home/user/repos
git clone https://github.com/dummy-org/my-app

PR URL https://github.com/dummy-org/my-app/pull/42 を渡したとき、内部では ref.repo() が返す "my-app" をディレクトリ名として解決します。

/home/user/repos/my-app  ← ここを参照

clone していないリポジトリの PR を渡すと、getDiff() が例外を投げてサービス層がエラーレスポンスに変換します。

{
  "success": false,
  "error": "failed to get local diff: local repository not found: /home/user/repos/another-app"
}

MCP ツール定義

@Tool(
    name = "getPrDiff",
    description = """
        GitHub PRの unified diff を取得し、各ファイルについて
        「コメント可能な RIGHT 側の行番号範囲」のサマリも返す。
        reviewExamToPrPending を呼ぶ前に、この結果を参照して正しい
        path と line を決定してください。
        """)
public GetPrDiffService.Result getPrDiff(
    @ToolParam(description = "PR URL (https://github.com/{owner}/{repo}/pull/{pullNumber})")
        String prUrl) { ... }

description の中で「インラインコメントを投稿する前に必ずこれを参照すること」と指示しています。LLM へのコンテキスト注入はツールの description で行うのがポイントです。


テスト戦略

LocalGitDiffClient は ProcessBuilder 経由で git を呼ぶため、mock では検証しきれません。@TempDir で bare リポジトリ + ワークツリーをテスト内に構築し、実際に git clone → git push → getDiff() を呼ぶ統合テストにしました。

GetPrDiffService はサービス層なので GitHubApiClient と LocalGitDiffClient を Mockito で mock し、「PR メタデータ取得失敗」「ローカルリポジトリ未存在」「ブランチ情報欠落」などの各シナリオを単体テストで網羅しています。


動作フロー

1. Claude が getPrDiff("https://github.com/dummy-org/my-app/pull/42") を呼ぶ

2. PrUrlParser.parse() → PrRef(owner="dummy-org", repo="my-app", pullNumber="42")

3. GitHubApiClient で PR メタデータ取得
   → baseBranch="main", headBranch="feature/add-login"

4. LocalGitDiffClient.getDiff("my-app", "main", "feature/add-login")
   4a. /home/user/repos/my-app が存在するか確認
       存在しない → LocalGitException → Result(success=false, error="...")
   4b. git fetch --quiet origin main feature/add-login
   4c. git diff origin/main...origin/feature/add-login

5. DiffParser でコメント可能な行番号を抽出
   → { "src/Auth.java": [10-12, 15, 20-25], ... }

6. Result(success=true, diff="...", files=[...]) を返す

7. Claude は diff と行番号範囲を使ってレビューコメントを生成・投稿

まとめ

変更点 内容
diff 取得方法 GitHub API → ローカル git diff
レビュー対象の制限 ローカルに clone 済みのリポジトリのみ(許可リスト不要)
設定 localRepos.dir プロパティで clone 置き場を指定

ローカル clone 経由にすることで GitHub API の制限を回避しつつ、「clone していないリポジトリはレビューできない」という制約が自然に生まれます。 明示的な許可リスト管理が不要になるのは副次的なメリットです。

誤解を生みそうだ。意味あるWhyの回答の説明文を書こう、ノットコメント

前回の記事では「コードにコメントは必要か?」にたいして「不要」っと記載したが、そもそもコメントとは?っと誤解されそうなので。

コードを読めばわかること(What/How)ではなく、コードを読んでもわからないこと(Why)は記載しよう。

  • 「なぜ」そのアルゴリズムを選んだのか
  • 「なぜ」あえて標準的ではない書き方をしているのか
  • 「なぜ」このチェックを省略できたのか

具体的には

https://github.com/openjdk/jdk/blob/90d21426d35e78b342f4126a02721e79c5916c0f/src/java.base/share/classes/java/lang/String.java#L4

を見てほしい。一度はみんな読むString.java

すべて理解できてないけど、説明文の書き方が参考になるなと思っている。

AIにコメント書いて -> コードを読めばわかること(What/How)書き出すので、コードを読んでもわからないこと(Why)は人間が記載しよう。

整形はAIにおまかせ。

「コードにコメントは必要か?」という議論。実は母国語の問題ではないかという仮説

「コメント不要論(コードが説明的であるべき)」と「コメント必要論」。

「良いコードならコメントがなくても読めるはずだ」と言う人もいれば、「いや、日本語の補足がないと意図が伝わらない」と言う人もいます。

「そもそもプログラミング言語が日本語(母国語)で書かれていないから、コメントが必要になるのではないか?」

っと思っています。


1. 一般的なプログラミング(英語命名 + 日本語コメント)

/**
 * 商品価格の計算管理クラス
 */
public class PriceCalculator {

    // 消費税率(10%)
    private static final double TAX_RATE = 0.10;

    /**
     * 税抜き価格から税込み価格を計算する
     * @param basePrice 税抜き価格
     * @return 税込み価格(整数)
     */
    public static int calculateTotalPrice(int basePrice) {
        // 税額を計算
        double taxAmount = basePrice * TAX_RATE;
        
        // 元の価格に税額を足して計算結果を返す
        return (int) (basePrice + taxAmount);
    }

    public static void main(String[] args) {
        int price = 1000;
        int totalPrice = calculateTotalPrice(price);
        System.out.println("合計: " + totalPrice + "円");
    }
}

2. 日本語プログラミング(日本語命名 + コメントなし)

public class 価格計算機 {

    private static final double 消費税率 = 0.10;

    public static int 税込み価格を計算する(int 税抜き価格) {
        double 税額 = 税抜き価格 * 消費税率;
        
        return (int) (税抜き価格 + 税額);
    }

    public static void main(String[] 引数) {
        int 元の価格 = 1000;
        int 最終的な税込み価格 = 税込み価格を計算する(元の価格);
        System.out.println("合計: " + 最終的な税込み価格 + "円");
    }
}

3. 一般的なプログラミング(英語命名 + 英語コメント)

/**
 * Class to manage price calculations.
 */
public class PriceCalculator {

    // Consumption tax rate (10%)
    private static final double TAX_RATE = 0.10;

    /**
     * Calculates the total price including tax.
     * @param basePrice Price excluding tax
     * @return Total price including tax
     */
    public static int calculateTotalPrice(int basePrice) {
        // Calculate tax amount
        double taxAmount = basePrice * TAX_RATE;
        
        // Return base price plus tax amount
        return (int) (basePrice + taxAmount);
    }

    public static void main(String[] args) {
        int price = 1000;
        int totalPrice = calculateTotalPrice(price);
        System.out.println("Total: " + totalPrice + " JPY");
    }
}

4. 日本語プログラミング(日本語命名 + 日本語コメント)

/**
 * 価格計算機クラス
 */
public class 価格計算機 {

    // 消費税率(10%)
    private static final double 消費税率 = 0.10;

    /**
     * 税込み価格を計算する
     * @param 税抜き価格 税抜き価格
     * @return 最終的な税込み価格
     */
    public static int 税込み価格を計算する(int 税抜き価格) {
        // 税額を計算
        double 税額 = 税抜き価格 * 消費税率;
        
        // 税抜き価格と税額を足す
        return (int) (税抜き価格 + 税額);
    }

    public static void main(String[] 引数) {
        int 元の価格 = 1000;
        int 最終的な税込み価格 = 税込み価格を計算する(元の価格);
        System.out.println("合計: " + 最終的な税込み価格 + "円");
    }
}

5. 日本語プログラミング(日本語命名 + 英語コメント)

/**
 * Class to manage price calculations.
 */
public class 価格計算機 {

    // Consumption tax rate (10%)
    private static final double 消費税率 = 0.10;

    /**
     * Calculates the total price including tax.
     * @param 税抜き価格 Price excluding tax
     * @return Total price including tax
     */
    public static int 税込み価格を計算する(int 税抜き価格) {
        // Calculate tax amount
        double 税額 = 税抜き価格 * 消費税率;
        
        // Return base price plus tax amount
        return (int) (税抜き価格 + 税額);
    }

    public static void main(String[] 引数) {
        int 元の価格 = 1000;
        int 最終的な税込み価格 = 税込み価格を計算する(元の価格);
        System.out.println("Total: " + 最終的な税込み価格 + " JPY");
    }
}

見慣れないなってコードは思うけど、読むのなら、結局日本人で日本語がメインだから、日本語あってほしいよね?

英語理解できたら英語だけでいいし。

つまり、自分はコメント不要です。

設計=コメント=実装=テスト

より

設計=実装=テスト

と一個でも減らして管理コスト減らしたい人です。

ポート開放不要のローカルLLM連携 実装編 — Google Chat × Pub/Sub × Spring Boot で自動PRレビューを動かす

前回の記事では、ポート開放も Tailscale も不要なアーキテクチャの設計と GCP 側のインフラ構築(Pub/Sub トピック/サブスクリプション、Chat API 設定、IAM 権限)を解説しました。

今回は 実装編として、ローカルPC上で動く Spring Boot アプリがどのように Pub/Sub からメッセージを受け取り、ローカル LLM(LM Studio)を使って PR レビューを自動化するかを説明します。


システム全体の流れ(おさらい)

Google Chat
   │  メンションやURLを投稿
   ▼
Pub/Sub Topic
   │  メッセージを蓄積(最大7日間)
   ▼
ローカルPC (batch アプリ)
   │  Pull でメッセージを取得
   ├─► LM Studio (ローカルLLM) でレビュー生成
   └─► GitHub / Backlog PR へ投稿
          │
          ▼
       Google Chat Webhook で完了通知

前回構築したインフラの上に、今回は batch アプリの部分を載せます。


プロジェクト構成

マルチプロジェクト構成の Gradle プロジェクトになっています。

my-tool/
├── web/        # Webサーバー(直接、呼び出す)
├── mcp/        # STDIO MCP サーバ(mcpクライアントから呼ばれる)
├── batch/      # バッチとして、Pub/Sub サブスクライバする
└── module/     # 共有するドメイン
        ├── domain
        ├── service
        ├── repository
        └── persistence

今回フォーカスするのは batch モジュールです。mcp モジュールは Claude Desktop や LM Studio の MCP ツールとして動作するもので、別の記事で詳しく取り上げる予定です。


batch アプリの仕組み

1. Pub/Sub からメッセージを Pull する — ライブラリと実装

依存関係の追加

Google の公式 Java ライブラリを使います。Spring Boot の BOM には含まれていないため、バージョンを直接指定します。

// batch/build.gradle
dependencies {
    implementation 'com.google.cloud:google-cloud-pubsub:1.142.0'
}

Subscriber の組み立て

Subscriber.newBuilder() にサブスクリプション名と MessageReceiver を渡すだけで基本的な Pull サブスクライバが完成します。

ProjectSubscriptionName subscriptionName =
    ProjectSubscriptionName.of(projectId, subscriptionId);

MessageReceiver receiver = (message, consumer) -> {
    String payload = message.getData().toStringUtf8();
    // 処理 ...
    consumer.ack();   // 成功 or 失敗に問わず消す
};

Subscriber subscriber = Subscriber.newBuilder(subscriptionName, receiver).build();
subscriber.startAsync().awaitRunning();

ローカル LLM に合わせた直列処理設定

LM Studio の推論は1リクエストあたり数十秒〜数分かかります。デフォルトだと複数メッセージが並行処理されてしまうため、1件ずつ順番に処理するよう FlowControl と Executor を絞ります。

ack / nack の判断基準

リッチにせず、成功しても失敗してもメッセージを消す。 chatに何が起きたか返す chatに出せなければログに

2. Google Chat のイベント JSON をそのまま処理できる

Google Chat Bot は以下の形式で Pub/Sub にメッセージを送ります。

{
  "chat": {
    "messagePayload": {
      "message": {
        "argumentText": " https://github.com/owner/repo/pull/123 をレビューして",
        "text": "@bot https://github.com/owner/repo/pull/123 をレビューして"
      }
    }
  }
}

batch アプリはこの JSON から PR の URL を自動抽出します。以下の順でフィールドを探し、最初に見つかったURLを使います。

  1. argumentText
  2. formattedText
  3. text

プレーンな PR URL や独自の JSON 形式も受け付けるので、Google Chat 以外の連携にも使えます。

3. ローカル LLM(LM Studio)でレビューを生成する

PR の URL が特定できたら、

  1. GitHub / Backlog から PR の diff とコメントを取得
  2. LM Studio の OpenAI 互換 API へプロンプトを送信
  3. レビュー結果(指摘コメント + インラインコメント)を受け取る

LM Studio は http://localhost:1234 で OpenAI 互換 API を提供しているため、モデルを切り替えても設定変更だけで対応できます。

# 環境変数で設定
export LM_STUDIO_BASE_URL=http://localhost:1234
export LM_STUDIO_MODEL=qwen3.6-35b-a3b

4. PR へ投稿してチャットに完了通知を送る

投稿が完了すると Google Chat の Webhook 経由で通知が届きます。


5. AIレビューツール比較マトリクス

「ローカルLLMでいいのか?」という疑問に答えるため、主要な AI レビューツールを比較します。

凡例: ◎ 優秀 / ○ 良好 / △ 制限あり / × 非対応

比較項目 GitHub Copilot Claude Code Review Codex (OpenAI) Gemini Code Assist ローカルLLM(本構成)
レビュー精度 ◎ ◎ ○ ○ △
セキュリティ脆弱性の指摘 ○ ◎ ○ ○ △
PR メンションで自動レビュー ◎ ◎(@claude / Actions) ◎(@codex review) ○(PR作成時に自動実行) ×
インラインコメント自動投稿 ◎ ◎ ◎ ◎ ◎
投稿前の内容確認 △(自動投稿) △(自動投稿) △(自動投稿) △(自動投稿) ◎(カスタム可能)
プロンプトのカスタマイズ △ ○ ○ ○ ◎なんでもできる
コスト 有料(月額固定) 約$15〜25/レビュー 従量課金 無料枠あり 無料(電気代のみ)
コードのプライバシー △ △ △ △ ◎
日本語対応 △ ◎ ○ ◎ △
ローカル・オフライン動作 × × × × ◎
対応プラットフォーム GitHub のみ GitHub のみ GitHub 主体 GitHub + GitLab なんでも可能
レート制限 △ △ △ △ なし
モデルの差し替え容易性 × × × × ◎
  • コードのプライバシーは、規約に依存しますが、ローカルLLMは完全に社内で完結するため安心です。ただ、モデルの学習データにコードが含まれている可能性があるため、機密コードを扱う場合は注意が必要です(ほぼない)。

まとめ

今回実装したのは以下の3点です。

  1. Spring Boot batch アプリが Pub/Sub を Pull して PR URL を自動抽出
  2. LM Studio の OpenAI 互換 API を呼んでローカル LLM でレビューを生成
  3. 結果を GitHub / Backlog に投稿し、Google Chat で完了通知

次回はレビュー精度に直結する プロンプトの設計を深掘りします。diff をそのまま渡すのか要約するのか、システムプロンプトに何を含めるか、インラインコメントをどう指示するかなど、ローカル LLM ならではのチューニングポイントを紹介する予定です。MCP モジュールの解説はその次を予定しています。

ポート開放もTailscaleも不要!Google Chat × Pub/Sub でローカルLLM連携のセキュアなインフラを構築する

なぜこの構成なのか?(他アーキテクチャとの比較)

ローカルPCで動かすLLM(Ollama等)を外部から利用しようとした場合、チャットツールごとに通信方式やタイムアウトの仕様が大きく異なります。

今回は代表的なツールやフレームワーク(LINE、Discord、Slack、OpenClaw)を用いた連携パターンと、本記事の「Google Chat + Pub/Sub」構成を具体的な軸で比較しました。

比較構成 難易度 通信 費用 復旧 補足
LINE/Slack/Discord + Local PC
(Webhook / WebSocket型)
★★★☆☆
(普通)
★☆☆☆☆
(設定次第)
★★★☆☆
(無料枠少)
★☆☆☆☆
(消失)
PC切断中のメッセージは受け取れない。
Discord等 + OpenClaw(Local PC)
(OSSフレームワーク)
★★★★★
(超簡単)
★★★★☆
(ツール準拠)
★★★★★
(無料)
★★☆☆☆
(ツール依存)
OpenClaw怖い
Chat + Pub/Sub + Local PC ★★☆☆☆
(IAMの罠あり)
★★★★★
(超安全)
★★★★★
(ほぼ0円)
★★★★★
(自動復旧)
設定大変。

Google Chat + Pub/Sub + Local PC

Google Chatの入力をGCPの「Pub/Sub(メッセージキュー)」に一旦預け、自宅PCが自分のタイミングで「Pull(取得)」しに行く構成です。

  • セキュア通信(ポート開放・VPN不要): PCからGCPへデータを取りに行く「Pull型」のため、自宅ルーターの穴あけも、TailscaleのようなVPNの常時起動も一切不要です。
  • PCの電源を切っていてもOK: PCをスリープさせている間に話しかけられても、メッセージは失われません。 Pub/Subがクラウド上でデータを最大7日間保持してくれるため、帰宅してPCの電源を入れた瞬間に、溜まっていたメッセージを一気に処理(復旧)してくれます。

気になる「料金」について(個人アカウントでも無料?)

「Google Workspace(有料)やGCPの契約が必要なら高くなりそう…」と思われがちですが、無料の個人用 Google アカウント(@gmail.com)でも構築可能であり、クラウド費用も実質0円に収まります。

  • Google Chat: 個人アカウントでも無料で利用できます。
  • GCP Pub/Sub: 毎月 最初の 10 GB まで無料(以降、データ量に応じた従量課金)。Chatのテキストメッセージ(1通数KB)であれば、月に数百万回やり取りしてようやく無料枠に届くレベルです。
  • LLMの推論: 自分のPCのGPU/CPUで動かすため、OpenAIなどのAPI利用料は1円もかかりません。

つまり、かかるコストは**「自分のPCの電気代だけ」**。


手順1:Google Cloud Pub/Sub の作成

まずは、メッセージの受け皿となるメッセージキューを作成します。

  1. Google Cloud コンソールで [Pub/Sub] > [トピック] を開きます。
  2. 「トピックを作成」をクリックし、トピックIDを入力します(例: start-to-work)。
  3. 「デフォルトのサブスクリプションを追加する」にチェックを入れたまま、作成をクリックします。

これにより、メッセージの入り口(トピック)と出口(サブスクリプション)がセットで作成されます。この時点では外部からのアクセスは完全に遮断された状態です。


手順2:Google Chat API の設定

次に、Google Chatからメッセージを送り込むためのボットを設定します。

  1. GCPコンソールで 「Google Chat API」 を検索し、有効化します。
  2. 左メニューの 「構成 (Configuration)」 タブを開き、ボット名やアイコンを設定します。
  3. 「インタラクティブ機能」 をオンにし、以下の接続設定を行います。
    • 接続設定: Cloud Pub/Sub を選択。
    • Cloud Pub/Sub トピック名: 先ほど作成したトピックのフルパスを入力します。 (形式: projects/[自分のプロジェクトID]/topics/start-to-work)
  4. 可視性の設定で、テストするユーザー(自分)を追加して「保存」します。

手順3:【要注意】IAM権限の罠を回避する

正しいサービスアカウントの特定と権限付与

  1. 手順2で開いた Google Chat API の「構成」画面の「接続設定」の項目をよく見ると、入力したトピック名のすぐ上に 「サービス アカウントのメール」 が表示されています。
  2. Pub/Sub の 「トピック」 画面に戻り、作成したトピックを開きます。
  3. 右側の情報パネル(権限タブ)で 「プリンシパルを追加」 をクリックします。
  4. 新しいプリンシパル: 先ほどコピーしたアドレスを貼り付けます。
  5. ロール: Pub/Sub 編集者 を選択し、保存します。

※重要: サブスクリプション側(XXX-sub)ではなく、必ず入り口である 「トピック側」 に対して権限を付与してください。


手順4:開通テスト

インフラが正しく繋がっているか確認します。

  1. Google Chat を開き、作成したボットと 1対1のDM(ダイレクトメッセージ) を開始します。 (※グループチャットのスラッシュコマンド等を使うと原因の切り分けが難しくなるため、最初は必ずDMでテストします)
  2. 「テスト送信」とメッセージを送ります。
  3. GCPコンソールの [Pub/Sub] > [サブスクリプション] から作成したサブスクリプションを開きます。
  4. 「メッセージ」タブ を開き、「PULL」 ボタンをクリックします。

表の中に、自分が送ったメッセージを含むJSONデータが表示されれば、Google Chat からPub/Subまでのトンネル開通は大成功です!


まとめ

これで Google Chat からのメッセージが、リアルタイムかつ安全にクラウド上のキューに溜まるようになりました。複雑なVPN構築やポート開放は一切必要ありません。

あとは、ローカルPCからこのサブスクリプションを Subscribe (Pull) してメッセージを読み取り、ローカルLLMに処理させるだけです。

次回は、このメッセージを実際にローカルLLM(Ollama等)に渡し、Google Chat APIを叩いてチャットルームへ「返信」を書き込む実装についてまとめたいと思います。

LM StudioからGitHubのPRレビューをやってみた 〜コメントも残せる自作MCPサーバー

はじめに

LM Studio + 自作MCPサーバーで、PRの内容取得からインラインコメント投稿までを完結させる環境を作りました。

ローカルLLMなので、無限に回せます。寝てる間にレビューさせられますね。(精度は考えないといけない)

本記事では、次のことを紹介します。

  • なぜLM Studio + MCPなのか
  • 自作MCPサーバー(Spring Boot製)が提供する3つのツール
  • 実際のレビューの流れ(diff取得 → レビュー生成 → コメント投稿)

なぜLM Studio + MCPなのか

  • ローカルで動く LLMにレビューさせたい
  • モデルを自由に差し替えたい(Qwen、Llama、DeepSeek Coder など)
  • コメントの最終投稿は人間が承認してから行いたい(暴走防止)

投稿するレビューはPENDING状態で作成するので、人間がGitHub上で内容を確認してから「Submit review」を押すまで、相手には一切通知されません。安全にAIレ ビューを試せる仕組みです。


自作MCPサーバーが提供する3つのツール

Spring BootとSpring AIの @Tool アノテーションで、3つのツールを公開しています。

ツール 役割
fetchPrComments PR本文・Issueコメント・インラインコメントを取得
getPrDiff 統一diffと、コメント可能な行範囲(RIGHT側)を返す
reviewExamToPrPending PENDING状態のレビュー(本文+インラインコメント)を投稿

細かい実装は省略します。

プロジェクト構成(レイヤードアーキテクチャ)

com.example.demo/
├── domain/         # 値オブジェクト、diffパーサ、URLパーサ
├── repository/     # GitHub REST APIクライアント
├── service/        # 土管
└── mcp/            # @Toolアダプタ

ドメイン層とリポジトリ層を分離しているので、GitHub API以外(GitLab等)への拡張もやりやすい構成になっています。


セットアップ

1. ビルド

./gradlew build

build/libs/my-mcp-server-0.0.1-SNAPSHOT.jar ができます。

2. GitHubトークンの用意

repo スコープ付きのPersonal Access Tokenを発行しておきます。

3. LM Studioからの接続

LM Studioの設定ファイル(mcp.json)に、サーバーをSTDIOで起動するエントリを追加します。

{
  "mcpServers": {
    "pr-review": {
      "command": "java",
      "args": ["-jar", "/absolute/path/to/my-mcp-server-0.0.1-SNAPSHOT.jar"],
    }
  }
}

LM Studioを再起動すると、チャット画面のツール一覧に fetchPrComments / getPrDiff / reviewExamToPrPending の3つが出てきます。


実際のレビューフロー

LM Studioのチャットに、こう投げます。

このPRをレビューしてほしい: https://github.com/owner/repo/pull/123

モデルは内部で次の順に動きます。

  1. getPrDiff を呼んでdiffを取得
  2. fetchPrComments で既存のレビュー状況を確認
  3. ビュー文面とインラインコメントを生成
  4. reviewExamToPrPending で投稿

ハマりどころ

インラインコメントが 422 で弾かれる

→ line がdiffに含まれない行を指している。getPrDiff の commentableLines を必ずモデルに渡すこと。

PENDINGレビューが重複する

→ 既存PENDINGを削除してから投稿するオプションを持たせてあります。


おわりに

LM Studio + 自作MCPサーバーで、ローカルLLMによるGitHub PRレビュー → インラインコメント投稿までを自動化できました。

精度を考えないといけないので100%信じてはいけませんが、 1. 静的解析 2. 表記ゆれや小さい修正

系は一旦良さそうです。細かいのを潰して、claude codeやcodexに最終レビューと、humanの最終レビューを投げると良いでしょう。


参考