🔗 この記事は https://blog.tak3.jp/ja/blog/kozou-mcp-oauth-resource-server/ からの転載です(一次情報源)。
Kozou は、PostgreSQL データベースの構造化されたコンテキストを AI エージェントへ — MCP と REST API の両方で — 公開するオープンソースのツールだ。本稿は、その MCP をリモートに置いて OAuth を載せたときの設計判断の記録である。機能としては v1.13.0 で入っているが、話の中心はリリースの中身ではなく、なぜ Kozou は自前の認可サーバーを持たなかったのかのほうだ。以下の記述は v1.19.0 時点のものである。
MCP サーバーをリモートの URL に置いた瞬間、認証は脚注ではなく本題になる。Kozou 自身の出発点は、そこに正直だった。auth block を設定しなければ、リモート呼び出しに取れる形はひとつだけ — すべての呼び出し元が単一の固定 execution.role を共有する構成だ。ローカルの単一ユーザー用途なら問題ないが、マルチテナントには使えない。「誰が」呼んでいるのかという概念がそもそも存在しないからだ。Kozou v1.13.0 は、呼び出し元ごとの identity がそもそも成立するようになったリリースである。
入ったのは、mode の選択だった
Kozou v1.13.0(2026-07-13 リリース)で remote MCP with OAuth (resource-server mode) が入った。MCP トランスポートが OAuth で呼び出し元を認証できるようになり、データに触れる実行ツール(call)は、共有された単一の role ではなく検証済みトークンの role で走る。スキーマを説明する側のツールは、トークンが持つ scope で公開の可否が決まり、共通のスキーマコンテキストを読む。
ここで mode が重要で、それが本稿の主眼だ。Kozou は自前の認可サーバー(authorization server)を育てなかった。OAuth の resource server になった。誰か別の発行者が発行したトークンを検証し、その持ち主に許されたことを施行する。セットアップガイドは kozou.org/ja/guides/mcp-oauth/ にある。ここでは手順を再掲しない — この記事は設定方法ではなく、なぜこの設計になっているのかの話だ。
posture が先にあった
魅力的なストーリーはこうだ。「MCP 仕様が role を分離せよと言ったから、Kozou は role を分離した」。それは逆だ。posture のほうが先に公開されていた。
2026-06-08 — v1.13.0 の1か月以上前 — に、Kozou の auth posture はすでに公開・確定していた。Kozou は施行層であり、identity は発行しない。これは理想ではなかった。その1週間前に、REST 面はもう出荷されている — JWT 認証と Postgres RLS による施行は 2026-06-01 の #54 で入り、v0.2.0 に含まれた。JWT を JWKS エンドポイントで検証し、そのトークンが持つ identity へ SET LOCAL ROLE し、実際の施行は Postgres の行レベルセキュリティ(RLS)に任せる。
だから remote MCP が認証を必要としたとき、頭を抱えるような設計判断は無かった。resource-server の形は仕様に合わせて選んだのではない — その形はすでにそこにあり、MCP authorization spec, revision 2025-11-25 がちょうどその分離を記述していた。施行する resource server、発行する authorization server、その間のきれいな境界。v1.13.0 は、REST 面ですでに出荷されていた JWT → SET LOCAL ROLE → RLS のパイプラインを、MCP トランスポートへ延長したものだ。同じ posture、面がひとつ増えただけ。
順序がそのまま論拠になっている。実装が 2026-06-01、posture の明文化が 06-08、MCP への延長が 07-13 — コードが先、言葉が後、面の追加はさらに後だ。「仕様に合わせて設計したのではない」とは、この順序のことを言っている。すでに動いていたものが、あとから出てきた仕様の記述とたまたま一致した。
認可サーバーを持たないことで得られるもの
authorization server にならないという選択は、機能の欠落ではない。残りを一貫させているのはそこだ。
- Kozou は credential を持たない。 ユーザーアカウントも、保管すべき identity の credential も無く、破られるトークンストアも無い。パスワードを見ることはない。検証するのは署名と、それに付いてくる条件 — アルゴリズム、有効期限、issuer、audience — だけだ。
-
IdP は乗り換えなくていい。 Kozou はあなたの authorization server が発行したものを検証するだけなので、企業 SSO も特別な統合ではない — あなたの IdP に向けた、同じ JWKS 検証の延長だ。ただし何もしなくていいわけではない。トークンに正しい audience と
mcp:*の scope、そして role claim が乗るように IdP 側を設定する必要がある。実際、最初につまずくのはここだ(後述の 403 がそれである)。 -
データに触れる実行の、最終的な認可の判断は経路に依存しない。 最後の決定権は Postgres RLS にあり、そのクエリが REST で来ようと MCP の
callで来ようと、同じポリシーが適用される。トランスポートは変わっても、ルールは変わらない。
最後の点が静かな見返りだ。identity をトランスポートごとに再実装するのではなくデータベースで施行していれば、面をひとつ増やしてもアクセスモデルを導出し直す必要はない — 継承されるからだ。
MCP 面はあえて厳しくしてある
見落としやすいのがここだ。MCP 面は、REST 面にトークンチェックをボルト留めしたコピーではない。わざと、REST 面より受け入れる範囲を狭くしてある。
その根拠は「誰が呼んでくるか」ではない。そこに置くと理屈が立たなくなる — claude.ai を操作しているのは人間だが、それで規律がひとつも緩まないからだ。効いているのは別の2つで、どちらも同じ形をしている。自分が制御していないものが、ひとつ増えるという形だ。
ひとつめ。トークンと、広告するメタデータを、自分が動かしていないクライアントに手渡すことになる。remote MCP の呼び出し元はホスト型のクライアント — claude.ai、ChatGPT、Claude Code — で、Kozou が公開する保護リソースメタデータはそこへ渡り、bearer トークンはそのメタデータが名指しした URL に乗っていく。
ふたつめ。role を付与するのが自分ではない。identity はフェデレーテッドディレクトリから来る。Keycloak や Auth0 の背後に Google Workspace が居る構成なら、初めてログインしてくる人は全員「認証は通ったが、誰も role を割り当てていない principal」だ。
REST では、設定すれば Kozou は匿名 role とデフォルト role を受け入れる。MCP では、あえて受け入れない。ふたつめの理由がそのまま効く場所だ。
- 匿名アクセスなし、デフォルト role なし。 ここでデフォルト role を許すと、IdP 管理者が role を割り当てていない principal — フェデレーテッドなら初回ログインの全員 — に、黙って権限が生える。role claim が無ければどの権限で実行するかが決まらないので、推測せず fail-closed になる。
-
executeの有効化には非空のallowedRolesが必須。 そのツールがどの role に適用されるかを宣言しなければならない。省略によって任意の role の実行が許可されることはない。設定漏れがそのまま権限昇格になるのを防ぐ。
残りは、REST 面に対応する概念がそもそも無い。他人のクライアントに手渡すトークンとメタデータのほう — さきほどのひとつめ — から出ている。
-
広告する URL に、非 loopback の平文
httpは使えない。auth.resourceとauth.authorizationServersは保護リソースメタデータとして第三者クライアントへ渡り、そこへ bearer トークンが乗る。だから loopback 以外の平文httpURL は警告ではなく起動時のエラーになる(隔離テスト網向けの明示 opt-inallowInsecureHttpはあり、使えば起動時に警告が出る)。チェックしているのは広告値であってリスナーの TLS 終端ではない — Kozou 自身は loopback に bind し、https はリバースプロキシやトンネルが前で終端する、というのが通常の形だ。 -
resource URI を
Hostヘッダーから導出しない。 ヘッダーは詐称され得るので、Kozou は DNS-rebinding 攻撃が突こうとする種類の判断にHostヘッダーを信用しない。 -
mcp:adminは既定の scope にあるが、広告しない。 広告リストをそのまま自分の登録要求へ写すクライアントがあるので、載せれば「要らない admin 権限を要求する」動きを誘発してしまう。だからscopes_supportedから外してある。
とはいえ、厳しさは拒否だけの話ではない。トークンに scope が足りないとき、Kozou は(resource server として)何が足りないかを示す insufficient_scope challenge を返す — scope と resource_metadata のポインタを添えて — ので、scope の昇格に対応したクライアントはそこから再認可へ進める。ここで広告している scope は mcp:describe と mcp:execute だ。実運用でこの challenge が最も多く出るのは、認識できる scope をひとつも持たないトークンが入口で弾かれるときである。原因はたいていセットアップ時の取り違えだ — IdP の mapper が scope claim を出していない、audience が違う、別クライアント向けのトークンを使っている。auth.resource を https://mcp.example.com/mcp と設定してあれば、こう返る。
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:describe", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json
{"error":"This operation requires the \"mcp:describe\" scope."}
ツール単位でも同じ challenge は出るが、そちらはずっと稀だ。tools/list が scope で出し分ける — 持っていない scope のツールは載らない — ので、提示されていない名前を呼んだときか、tools/list をキャッシュしたあとに scope が縮んだときにしか起きない。
そして、資格情報がまったく無いときの対になるケース — RFC 6750 のとおり error 属性の無い 401 に、ルールがどこに広告されているかへのポインタを添えて返す。
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json
{"error":"Missing or malformed Authorization header."}
どちらの応答も本文は同じ形の JSON だ。ただし トークン自体が弾かれたときは、署名・有効期限・audience・issuer のどれで落ちたかを本文に出さず、ひとつの一般的なメッセージにまとめる。scope 不足のように昇格で解ける話は明示し、トークンの検証失敗は明示しない — クライアントが次に打てる手だけを WWW-Authenticate に置く、という線引きだ。
覚えておく価値があるのは、この形だ。拒否と 発見可能性(discoverability) は、同じふるまいの両輪である。ただ「no」としか言わない厳格なサーバーは敵対的だが、「no、そして必要な scope とメタデータのありかはここだ」と言うサーバーは、厳格 かつ 使える。discovery の面では、Kozou は WWW-Authenticate と well-known メタデータの両方の discovery 機構を実装しているので、クライアントはどちらの経路でもルールを見つけられる。
その後 — 暗黙に決まっていたことが、運用する側の宣言になった
resource-server mode で決めたことのひとつに、resource URI は設定で宣言する。導出しないがある。上に書いたとおり Host ヘッダーを信用しないからだ。この「暗黙に決めない」という規律は、その後 OAuth を使っていない構成のほうへ広がった。
きっかけは、地味な取り違えだった。自分が bind しているポートは、「自分がどこで待っているか」でしかない。クライアントが来るアドレスとは別物だ。この2つは、あいだに何も挟まっていないうちだけ一致する。プロキシが1台入れば、トンネルを通せば、published port を付け替えれば、devcontainer の中で動かせば、一致は壊れる。Kozou の接続ページ — 非エンジニアにそのまま渡せる接続設定を出す画面 — は、前者から後者を組み立てていた。壊れたときに直す手段は無く、エンドポイントではないアドレスを、自信を持って配ることになる。
OAuth 側には、もう答えがあった。auth.resource は宣言された値で、Host ヘッダーから導出されない。v1.18.0 の server.mcp.http.advertisedUrl は、それを auth を設定しない構成にも渡したものだ(両方を書くと設定エラーになる)。詐称を警戒して引いた線が、誰も攻撃していない場面 — ただプロキシが1台あるだけの場面 — でも正しかった。厳しさのために引いた規律の、思っていなかった配当である。
似た話がもうひとつある。v1.17.0 の server.mcp.http.enabled(既定 true)は、MCP の HTTP エンドポイントをそもそも動かさないと宣言できる設定だ。それまで唯一のレバーは bind アドレスで、エンドポイントは立ったまま、posture はネットワーク構成に委ねられていた。エージェントを向ける気が無いなら、隠せる場所を探すのではなく、走らせない選択肢があるべきだ。
ただしこちらは OAuth から降りてきたものではない。当時の動機は「posture がネットワーク構成の副産物になっている」ことのほうで、Host ヘッダーの詐称とは関係が無い。同じ規律の派生ではなく、隣に立っている別の規律だ。並べてみれば、暗黙だったものを宣言に変えるという同じ方向は向いている — が、そこまでにしておく。
トレードオフ、正直なところ
これはどれもタダではないし、そう装う紹介は読む価値がない。
- 外部の authorization server が要る。 resource-server mode は、発行者を自分で用意することを意味する。ガイドは2つの具体的なレシピ — Keycloak と Auth0 — を解説している。
- audience の入れ方は IdP によって違う。 Auth0 は resource indicators(RFC 8707)をネイティブにサポートするが、Keycloak はトークンに audience を入れるのに mapper が要る。行き先は同じ、設定の手順が違う。
- ホスト型の authorization server は dynamic-registration のふるまいが異なる。 これは resource server が決めることではなく、クライアント↔AS 側の関心事なので、ここで中途半端に説明せず上記のレシピに委ねる。
試すには
最小構成、IdP 別のレシピ、トラブルシュートはすべて一箇所にある。kozou.org/ja/guides/mcp-oauth/ だ。手順を私が打ち直す意味はない — どうやるか はガイドが source of truth だ。
まとめ
- Kozou の remote MCP with OAuth は authorization server ではなく resource server として作られている — トークンを検証しアクセスを施行するが、identity は発行しない。
- これは仕様の後追いではない。posture(「施行する、発行しない」)が先に公開され REST 面で先に出荷されていて、MCP authorization 仕様の resource-server / authorization-server の分離が、すでに存在していた形にちょうど一致した。
- データに触れる実行のモデルが端から端まで一貫している — JWT →
SET LOCAL ROLE→ Postgres RLS — からこそ、MCP 面は REST 面より あえて厳しく できる。匿名アクセスなし、デフォルト role なし、既定で fail-closed、そして拒否のときにはどの scope が足りないかをクライアントに正確に伝える。 - そして「導出せず宣言する」という規律は、resource-server mode の外へも出た。到達アドレスを宣言する
advertisedUrl(v1.18.0)は、OAuth 側にすでにあった答えを、そのまま他の構成へ渡したものだ。厳しさのために引いた線が、他の構成の正しさにもなった。
この記事は basou — AI コーディングエージェントを操舵するために私が作っているハーネス — の助けを借りて書いた。