コンテンツにスキップ

リモート MCP と OAuth

AI エージェントを MCP で接続するはローカルの経路 — 同一マシンのエージェントには stdio、loopback 上の素の HTTP — を扱いました。hosted なエージェント (claude.ai のカスタムコネクタ・ChatGPT のコネクタ・別マシンの Claude Code セッション) にインターネット越しで MCP サーバーへ到達させるには、すべてのリクエストに検証済みの identity が必要です。

それを担うのが server.mcp.http.auth ブロックです。このブロックが存在すると、MCP HTTP エンドポイントは OAuth 2.1 resource-server モード (MCP authorization 仕様) に切り替わります。Kozou は protected-resource metadata (RFC 9728) であなたの ID プロバイダを広告し、各リクエストの bearer トークンを検証し、トークンの scope でツール面をゲートし、実行 — call ツール — を SET LOCAL ROLE <トークン由来の role> の下で走らせます。per-caller identity を、あなたの PostgreSQL RLS ポリシーが施行する形です。(describe ツールが返すのは introspect 済みのスキーマ context で、scope はそれが見えるかどうかを決めます。)

本ガイドはモード自体と安全なデプロイ方法を扱います。ID プロバイダ側の設定は、KeycloakAuth0 の実践レシピを参照してください。

Kozou が行うこと — 行わないこと

Section titled “Kozou が行うこと — 行わないこと”

Kozou は resource server のみです。REST 面と同じ委譲の姿勢を取ります。

  • bearer トークンを ID プロバイダの公開鍵で検証し、issuer・audience・scope を確認し、role claim を PostgreSQL ロールに対応付けます。
  • MCP クライアントがサインイン先を発見できるよう、あなたの authorization server を広告します。
  • トークンは発行しません。ログイン・consent UI も持たず、クライアント登録も行いません。それらは identity を守るために作られたシステム — あなたの IdP — の仕事です。
  • hosted クライアントから到達できる OAuth 2.1 / OIDC の ID プロバイダ (authorization-server metadata と JWKS エンドポイントを持つもの)。次の 2 点を満たせるならどのプロバイダでも動きます。
  • プロバイダがアクセストークンに正しい audience を入れられること。 Kozou はトークンの aud が MCP サーバーの resource URI に一致することを要求します。RFC 8707 resource indicators を実装するプロバイダ (Auth0) はネイティブに対応し、そうでないプロバイダ (Keycloak) は audience mapper が必要です — 両方ともレシピで扱います。
  • enterprise SSO と同じく、引き受ける PostgreSQL ロールを名指しするスカラーの role claim
  • 許可するロールの PostgreSQL ロールと RLS ポリシーdatabase.url のログインロールは各ロールへの membership を GRANT されている必要があります。
  • エンドポイントの公開 https URL — Kozou の前段にリバースプロキシやトンネルを置くのが通常の形です。

kozou.config.yamlserver.mcp.http の下に auth ブロックを追加します。

server:
mcp:
http:
port: 3334
host: 127.0.0.1 # bind はローカルのまま。前段はプロキシが受ける
auth:
resource: https://mcp.example.com/mcp
authorizationServers:
- https://idp.example.com/realms/myrealm
jwt:
jwksUri: https://idp.example.com/realms/myrealm/protocol/openid-connect/certs
algorithms: [RS256]
roleClaim: role
allowedRoles: [app_viewer, app_editor]
extraScopesSupported: [offline_access]
execution:
enabled: true # 任意: `call` ツールを公開する

その後 kozou mcp --http でサーバーを起動します。このブロックは HTTP transport にのみ適用されます — stdio 起動では無視されます (起動時にその旨を表示します)。stdio のセキュリティ境界はネットワークではなくプロセスへのアクセスだからです。

  • resource — この MCP サーバーの canonical な resource URI (RFC 8707)。常に明示指定で、Host ヘッダから導出されることはありません (ヘッダ由来の identity こそ DNS-rebinding guard が信用しないものです)。プロキシやトンネルの背後では公開 URL を指定します。トークンに期待する audience の既定値でもあります。
  • authorizationServers — protected-resource metadata で広告される issuer URL。クライアントはこのリストからあなたの IdP を発見します。jwt.issuer を自分で設定しない限り、受理するトークンの期待 iss にもなります — iss は常に検証されます。
  • jwt — 検証の設定。jwksUri / publicKey / secret のいずれか 1 つと、任意の algorithmsissuer。省略時はトップレベルの auth ブロックから継承されます — ただし audience だけは決して継承されません。REST の audience は client id、MCP の audience は resource URI だからです。プロバイダが resource URI を aud に入れられない場合は、実際に発行される値を jwt.audience にここで設定してください。
  • roleClaim / allowedRoles — 省略時はトップレベルの auth ブロックから継承されます。allowedRoles はトークンが選択できるロールの allowlist です。必須になる条件は設計上の厳格さを参照。
  • scopes — 期待する scope 名の変更 (既定: mcp:describemcp:executemcp:admin)。scope 名にプレフィックスを強制する IdP のためにあります。トークンとの照合は完全一致です。
  • extraScopesSupported — 広告する scope リストへの追加分。クライアントはこのリストを「authorization server に要求すべきもの」として扱い、dynamic client registration にそのまま echo するものもあります。クライアントに要求させたいものはすべて載せてください。典型は、IdP が refresh トークンに offline_access を要求する場合 (Keycloak がそうです)。これらは広告されるだけで、Kozou 自身が要求することはありません。
  • adminRefresh — 既定 false: auth モードでは POST /admin/refresh は完全に無効です (404、未知のパスと区別できません)。opt-in すると、admin scope を持つ有効なトークンを条件に到達可能なままにできます。admin scope は意図的に広告されません — 広告リストを echo するクライアントが、不要な admin 権限を要求してしまうためです。
  • allowInsecureHttp — 既定 false: 非 loopback ホストの平文 http: の resource / authorization-server URL は起動エラーです。これらの URL は第三者クライアントに配られ、bearer トークンを運ぶためです。loopback (localhost127.0.0.0/8::1) はローカル開発用に常に許可されます。opt-in すると起動時に警告が出ます。隔離されたテストネットワークのためのもので、本番用ではありません。

この面は REST 面より意図的に厳格です。REST の 3 つの緩和はここには存在しません。

  • 匿名アクセスなし。 トークンのないリクエストは常に 401 です — anonRole はありません。この 401 は行き止まりではなく、protected-resource metadata を指す WWW-Authenticate challenge を運びます。実クライアントはまさにこの challenge を起点にあなたの IdP を発見します。匿名リクエストを通すと発見面が曖昧になりますし、describe のメタデータ自体が守る価値のある情報です。
  • default role なし。 検証済みでも role claim を欠くトークンは拒否されます — defaultRole はありません。ここに既定値を置くと、IdP 管理者がロールを割り当てていない認証済みユーザー全員に黙ってロールを与えることになります — federated なディレクトリ (Keycloak や Auth0 の背後の Google Workspace) では、それは初回ログインの全ユーザーです。ロールの割り当ては IdP 側の明示的なアクションであり続けます。具体的な場所はレシピで示します。
  • execution 有効時は allowedRoles が必須。 server.mcp.execution.enabled を auth ブロックと同時に有効にする場合、空でない実効 allowedRoles (このブロックで設定するか継承) が起動時に必須です。トークンの role claim が実行ロールを選ぶため、引き受け可能なロールは明示的な allowlist でなければなりません — 幅広い接続ロールを持っているからといって、誤って対応付けられた IdP claim にデータベースが許すロールを選ばせてはならないのです。

scope はツール面そのものをゲートします。7 つの describe ツールには describe scope、call ツールには execute scope が必要です。トークンが持たない scope のツールはそのクライアントに広告されず、それでも呼べば欠けている scope を明示する 403 insufficient_scope challenge が返ります。

auth ブロックなしで call ツールを有効化する場合、固定の execution.role が 1 つ必要でした — 全呼び出し元がそれを共有するため、マルチテナントの認可には不向きです。

auth ブロックがあると、call検証済みトークンごとの role として実行され、設定済みの execution.role / execution.claims は無視されます。トークンの claim は REST 面とまったく同じように RLS ポリシーへ公開されます (auth.claimsGuc、既定 request.jwt.claims) — 1 組のポリシーがすべての入口に適用されます。

通常のデプロイでは Kozou を loopback に bind したまま、前段のリバースプロキシやトンネルが TLS を終端します。

  • resource には公開 URL を設定します。そのホスト名は DNS-rebinding guard に自動で追加されるため、トンネル経由のリクエストは追加設定なしで Host チェックを通過します。
  • protected-resource metadata は well-known の両形 — ルート形 (/.well-known/oauth-protected-resource) とパス挿入形 (…/oauth-protected-resource/mcp) — で提供されます。実クライアントは文脈によってどちらも引くためです。両方とも同じ文書から出るので、設定は不要です。
  • プロキシまで https を貫いてください。起動時の transport チェック (allowInsecureHttp) が偶発的な平文公開を拒否します。
  • 権限対応の introspection (introspection.respectPrivileges) をこのモードと併用する場合、allowedRolesちょうど 1 つのロールを指定する必要があります。describe の注記は 1 つのロールの権限を語るため、それが正直であり得るのは引き受け可能なロールが 1 つのときだけです。

主要な hosted クライアント 3 種は同じ発見プロトコルを話しますが、登録と consent の挙動が異なります。差分を先に知っておくとデバッグ時間を節約できます。

  • Claude Code は dynamic client registration で登録し、広告された scope リストをそのまま登録に echo します — offline_access scope で refresh トークンをゲートする IdP で extraScopesSupported: [offline_access] が効くのはこのためです。登録はサーバー URL ごとにキャッシュされ、claude mcp logout で破棄されると次のログインは新規クライアントを登録します。
  • claude.ai (カスタムコネクタ) は dynamic registration を試み、ほぼ即座に認可へ進みます。新規クライアントごとに個別の承認手順が要る IdP (Auth0) ではこのレースが繰り返し失敗します — 安定する道は事前登録した confidential client です。IdP 側で作成して一度承認し、その client ID と secret をコネクタの設定に貼り付けます。また claude.ai は使用するツールごとに per-tool の consent ダイアログ (「許可 / 拒否」) を挟みます — ユーザーにはコネクタ単位でなくツール単位の確認が出ると考えてください。
  • ChatGPT (コネクタ) は client-ID metadata document の不在を検知して dynamic registration にフォールバックします — しかも接続試行のたびに新規クライアントを登録するため、クライアントごと承認型の IdP では承認の自動化か、ここでも事前登録が必要です。scope は広告分を初回に一括要求します。ユーザーに伝えるべき UX の罠が 1 つ: コネクタの「公開」と「OAuth 接続」は別ステップです — 公開直後はアプリに利用可能なアクションがないと表示されることがあります (未認証のプローブが正しく 401 を受けた結果です)。OAuth サインインは作成時にも公開時にも勝手には走りません。ChatGPT の画面によって、チャットでの初回使用時に走る場合と、アプリのページから明示的に開始する必要がある場合 (ディレクトリのアプリページにある接続するボタン) があります。ユーザーが完走するとアクションが現れます。ChatGPT はほとんどのリクエスト前に initialize をやり直し、resources/list も探索しますが、Kozou は両方とも処理します。
  • 配布するものには事前登録の confidential client を推奨します。 dynamic registration は開発者が自分のエージェントをつなぐには最適ですが、hosted 配布ではクライアントごとの承認・登録のレース・IdP のクライアント数上限に突き当たります。テナントごとに 1 つの事前登録クライアントなら 3 つとも回避できます。
  • 使用中かもしれない dynamic 登録クライアントを削除しないでください。 クライアントは client_id をキャッシュします。削除後、IdP の invalid_client エラーはエンドユーザーには原因不明の失敗として現れます。GC は明示的な失効時のみに。
  • ロール割り当てをオンボーディングの一部として扱ってください。 federated ディレクトリの新規ユーザーは role claim なしで到達し、設計どおり拒否されます。流れは、ディレクトリにユーザーが存在 → IdP 管理者がロールを割り当て (user attribute・app metadata・app-role 対応付け — レシピ参照) → そののトークンにロールが乗る、です。