UPSIDER Tech Blog

社内用 MCP サーバーを Cloud Run でリモート公開した話

はじめに

こんにちは。オンラインレンディングチームでエンジニアをしている堀航大です。

チームの DB 調査やログ検索を AI に任せられるように、MCP サーバーを作って Cloud Run にホストし、リモート MCP サーバーとしてチームに公開しました。

目指したのは「設定ファイルに URL を 1 行足すだけで誰でも使える」状態です。ただ、そこまで持っていくには MCP の認可仕様である OAuth 2.1 にきちんと乗る必要がありました。この記事では、そこへたどり着くまでの設計判断と、途中で踏んだ落とし穴を書きます。

  • MCP の認可仕様と IAP(Identity-Aware Proxy)をどう共存させるか
  • JWT の署名鍵をどこに置くか
  • どこまで防御層を重ねるか

このあたりに興味がある方の参考になれば嬉しいです。

作ったもの

read-only の 4 ツールだけを持つ、シンプルな MCP サーバーです。

ツール 説明
listTables テーブル一覧の取得
describeTable テーブルのカラム情報の取得
query Read-only SQL の実行(read-only トランザクション内で実行し ROLLBACK)
searchLog Cloud Logging の検索

これを Claude Code などの MCP クライアントに繋ぐと、こんな体験になります。

flowchart LR
    Dev["エンジニア"] -->|"原因を調査して"| AI["AIエージェント"]
    AI -->|"searchLog"| MCP["MCPサーバー"]
    AI -->|"describeTable"| MCP
    AI -->|"query"| MCP
    MCP --> AI
    AI -->|"原因はこれです"| Dev

「このエラーの原因を調査して」と投げるだけで、AI がログを検索し、テーブル定義を確認し、SQL を発行して原因を絞り込んでくれます。人間がやるのは、出てきた結果をレビューすることだけです。

セットアップは、MCP クライアントの設定ファイルに URL を 1 行書くだけです。

{
  "mcpServers": {
    "db-explorer": {
      "type": "http",
      "url": "https://<リモートMCPのURL>/mcp"
    }
  }
}

なぜリモート MCP にしたのか

きっかけは、セキュリティ強化の一環で DB の Public IP を廃止したことでした。接続経路を見直すタイミングだったので、それまでの「各メンバーがローカルから直接繋ぐ」やり方をやめて、MCP サーバーごとクラウドに置いてしまうことにしました。

flowchart TB
    subgraph Before["Before: ローカルから直接接続"]
        direction TB
        A1["メンバーA<br/>個別に接続設定"] --> DB1[(DB)]
        A2["メンバーB<br/>個別に接続設定"] --> DB1
        A3["メンバーC<br/>挫折…"] -.-> DB1
    end
    subgraph After["After: リモート MCP 経由"]
        direction TB
        B1["メンバーA"] --> R["MCPサーバー<br/>(Cloud Run)"]
        B2["メンバーB"] --> R
        B3["メンバーC"] --> R
        R --> DB2[(DB<br/>Private IP)]
    end
    Before ~~~ After

リモートに寄せたことで、次のあたりがまとめて片付きました。

  • 使い始めるための準備がいらない。設定ファイルに URL を書くだけで済み、クレデンシャルを個人のマシンに配る必要もない
  • DB へ到達できる経路がサーバー経由だけになるので、監査と制御がしやすい
  • 誰が使えるか、何ができるかをサーバー側で一元管理できる

前提知識: MCP の認可仕様

本題の前に、MCP の認可まわりを簡単に整理しておきます。

MCP は Streamable HTTP でリモート接続でき、その認可は OAuth 2.1 をベースに規定されています。特徴的なのは、下記の流れを MCP クライアントが自動で走らせてくれる点です。

ステップ 仕様 内容
Discovery RFC 8414 / RFC 9728 認可サーバーのメタデータ(各エンドポイント URL、PKCE 対応など)を取得
クライアント登録 RFC 7591 (DCR) クライアントを動的に登録し client_id の払い出しを受ける
認可 OAuth 2.1 (Authorization Code + PKCE) ユーザーの認可を得て authorization code を取得
トークン取得 OAuth 2.1 access token (JWT) を取得
ツール呼び出し Bearer Token Authorization: Bearer <JWT> を付けて MCP エンドポイントへ

裏を返すと、リモート MCP サーバーを公開する側は、OAuth 2.1 の認可サーバーとリソースサーバーを自前で用意することになります。ただ社内向けに Web アプリを公開するのとは、ここが大きく違うところです。

認証方式の選択肢

Cloud Run 上の MCP サーバーを保護する方法はいくつか考えられます。

方式 手軽さ MCP クライアント対応 備考
gcloud run services proxy + IAM 各自が gcloud CLI とプロキシを起動する必要があり、「URL 1 行」にならない
IAP のみ Cookie ベースのブラウザ向け認証のため、MCP クライアントが越えられない
OAuth 2.1 認可サーバーを立てる(採用) MCP の仕様に準拠。クライアントは URL を書くだけで自動的に認証フローが走る

上 2 つは手軽なのですが、「チーム全員が、追加のツールなしで、URL 1 行で使える」という条件は満たせません。結局、OAuth 2.1 に準拠した認可サーバーを自分たちで用意することにしました。

全体アーキテクチャ

最終的にはこんな構成に落ち着きました。

flowchart LR
    Client["MCPクライアント<br/>(Claude Code 等)"] -->|"Bearer"| LB["Global LB<br/>+ IAP<br/>(Workspace限定)"]
    LB --> Proxy["OAuth2 proxy<br/>(Cloud Run sidecar)"]
    Proxy -->|"localhost"| MCP["MCPサーバー本体<br/>(Cloud Run)"]
    MCP -->|"VPC"| DB[("Cloud SQL<br/>Private IP<br/>IAM認証<br/>read-only")]

Cloud Run は internal ingress にしてあるので、LB を経由しないとアクセスできません。認証は sidecar の OAuth2 proxy に任せていて、MCP サーバー本体はツールの実装だけを持っています。DB へは VPC Connector 越しに Private IP で繋ぎ、認証は IAM を使っているため、パスワードはどこにも持っていません。

最大の壁: IAP だけでは MCP クライアントが通れない

Google Cloud で社内向けのサービスを公開するときの定番は IAP です。Google Workspace のドメインで縛れば、ブラウザから使うツールならこれで十分でした。

ところが MCP クライアントは、さきほどの OAuth 2.1 のフローで動きます。IAP は Cookie ベースのブラウザ向け認証なので、MCP クライアントはこれを越えられません。ここが一番悩んだところでした。

たどり着いたのは、OAuth 2.1 の認可サーバーとして振る舞う proxy を sidecar として同居させ、認証をまるごと任せてしまう形です。おかげで MCP サーバー本体には、認証のコードを 1 行も書かずに済みました。

sequenceDiagram
    participant C as MCP クライアント
    participant B as ブラウザ
    participant P as OAuth2 proxy (sidecar)
    participant I as IAP
    participant M as MCP サーバー本体

    C->>P: Discovery (RFC 8414 / 9728)
    C->>P: POST /oauth2/register (DCR: RFC 7591)
    P-->>C: client_id 発行
    C->>B: ブラウザを起動
    B->>I: GET /oauth2/authorize
    I->>B: Google ログイン (ここだけ IAP)
    B->>P: 同意画面で承認
    P-->>B: authorization code (loopback URL へ)
    B-->>C: code を受け渡し
    C->>P: POST /oauth2/token (PKCE 検証)
    P-->>C: access token (JWT)
    C->>P: POST /mcp (Authorization: Bearer JWT)
    P->>P: JWT 検証 + ユーザー情報をヘッダーに注入
    P->>M: プロキシ
    M-->>C: ツール実行結果

役割分担としては、認可エンドポイントだけ IAP を通し、それ以外のパスは proxy 自身が JWT で守っています。使う側から見れば、初回接続のときにブラウザで Google ログインが挟まるだけです。IAP の手軽さを残したまま、MCP の仕様にも乗れました。

JWT の署名鍵を KMS の外に出さない

OAuth2 proxy が発行するアクセストークンは ES256 で署名していますが、その署名鍵には Cloud KMS の非対称鍵を使っています。

flowchart LR
    KMS["Cloud KMS<br/>(ES256)"] <-->|"署名"| P["OAuth2 proxy"]
    P -->|"公開鍵を配信"| JWKS["/oauth2/jwks"]
    Client["MCPクライアント"] -->|"JWT検証"| JWKS

署名は KMS の API 越しに行うので、秘密鍵はどこにもエクスポートされません。公開鍵だけを /oauth2/jwks で配れば、クライアント側で JWT を検証できます。

鍵をコンテナの環境変数や Secret に置く方法と比べると、そもそも漏れる場所がないというのが気に入っています。

多層防御

「IAP でドメインを絞っているから大丈夫」で止めず、層を重ねました。

flowchart LR
    A["レイヤー1: IAP<br/>Workspace限定"] --> B["レイヤー2: アプリ層認可<br/>allowlist該当者のみ"]
    B --> C["レイヤー3: DB権限<br/>read-onlyのみ"]

email allowlist は設定ファイルに書いて、PR で変更する運用にしています。誰を足したのかが履歴に残りますし、レビューも挟まります。allowlist が空だったり未設定だったりするとデプロイが失敗するようにしてあるので、うっかり全開放になることもありません。

DB 側も read-only ロールで接続しています。そもそも書き込み権限がないため、想定外の SQL が飛んできても壊れません。

業務データを扱う以上、「ドメイン内の全員が read できる」のはやはり広すぎます。アプリ層でもう一段絞って、必要な人にだけ渡す形にしました。AI エージェントに持たせるツールなので、最悪の挙動をしても壊れない状態を権限側で作っておきたかった、というのが正直なところです。

DB 接続はシンプルに

DB まわりは、専用の Connector ライブラリを使わずに済ませました。Cloud Run に VPC Connector を設定して Cloud SQL へ Private IP で直接繋ぎ、認証はパスワードではなく IAM です。アクセストークンをその都度取得して、PostgreSQL クライアントの password としてそのまま渡しています。

const client = new Client({
  host: process.env.DB_HOST,              // Private IP
  user: iamUser,                          // IAM ユーザー
  password: await auth.getAccessToken(),  // IAM のアクセストークン
  database: process.env.DB_NAME,
});

await client.connect();
await client.query(`SET ROLE ${readOnlyRole}`); // read-only ロールに切り替え

IAM のアクセストークンは短時間で失効しますが、リクエストごとに接続し直す設計なら特に問題ありません。コネクションプールで長く持つ設計だと再取得の仕組みが要るので、ここはアーキテクチャ次第だと思います。

ハマりどころ

作っている途中で踏んだものをいくつか挙げます。

Cloud Run の image pull は runtime SA の権限ではない

Cloud Run がコンテナイメージを pull するときに使われるのは、サービスに紐付けた runtime のサービスアカウントではなく、GCP が管理している service agent です。runtime SA に Artifact Registry の reader を付けても pull は通りません。デプロイが image pull で落ちたら、まず service agent 側の権限を見にいくのが早いです。

IaC と CI/CD の二重管理でリビジョンが巻き戻る

Cloud Run サービスを Terraform で管理しながら、image のデプロイだけ CI/CD から gcloud run deploy する構成にしていたところ、apply のたびに image が placeholder へ戻ってしまいました。最初の bootstrap だけ IaC でやって、以降のリビジョンは CI/CD に任せる(IaC の管理対象から外す)という整理が必要でした。

OAuth2 の loopback redirect と CSP

MCP クライアントは認可コードを http://localhost:<ランダムポート>/callback で受け取ります(RFC 8252: OAuth 2.0 for Native Apps)。同意画面の CSP で form-action を厳しくしていると、この loopback へのリダイレクトがブラウザにブロックされます。ポートが毎回変わるため、form-action 側で loopback アドレスを許す書き方にする必要がありました。

使ってみてどうか

MCP クライアントからの一連の流れが end-to-end で通ることを確認できました。Discovery から DCR でクライアントが登録され、ブラウザで Google ログインして同意、PKCE の検証を経て KMS 署名の JWT が発行されます。その JWT を付けてツールを呼ぶと、ちゃんとテーブル一覧が返ってきました。

そして実際に使い始めてみると、調査そのものがかなり楽になりました。

以前は「この現象、データがどうなってるか見てほしい」と依頼が来るたびに、誰かが接続経路を用意して、テーブル定義を思い出しながら SQL を組み立てて…ということをやっていました。今は Claude Code に現象を伝えるだけで、searchLog でエラーログを拾い、describeTable でスキーマを確認し、query で該当データを引いてきて、原因の仮説まで出してくれます。こちらの仕事は、その結果を見て判断するところだけになりました。

read-only なのが保証されているぶん、AI に触らせることへの抵抗感が小さいのも大きいと思います。

おわりに

今回は read-only の調査ツールに絞りました。この構成の上に admin API を呼ぶツールを足していけば、日々の ops 作業も任せられるようになるはずです。

社内向けの管理画面は、構築と保守のコストが高いわりに、使う人は限られます。権限をきちんと絞った MCP ツールを生やしていけば、その操作のためだけに管理画面を作るということ自体が減っていくのかもしれません。

同じように社内ツールのリモート MCP 化を考えている方の参考になれば嬉しいです。

We Are Hiring

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com