こんにちは。ネットワールドで Cloudflare を担当しております。
Cloudflare Workers 上に MCP サーバーを作り、Okta のカスタム認可サーバーで保護する構成を実際に組んでみました。2本立ての1本目です。
- 記事の目的
- AI エージェントに社内データを触らせるとき、「誰が」「どこまで」を制御する具体的な作り方を、実機の画面とともに示すことです。
- 検証の背景
- この検証のきっかけは、2026年7月に Cloudflare の MCP サーバーポータルへ追加された手動 OAuth (Static OAuth) です。ポータルが上流の MCP サーバーへ接続する際、従来は動的クライアント登録が使えるプロバイダーしか選べず、企業で使われている多くの IdP はそのままでは上流に据えられませんでした。手動 OAuth はこの制約を外しています。これを実機で確かめるのが今回の企画です。ただし検証するには、Okta で保護された MCP サーバーが手元になければ始まりません。本記事は、その土台を作る回にあたります。
- 登場する製品の位置づけ
- 本検証の主役は、後編で扱う Cloudflare One の MCP サーバーポータル(手動 OAuth)です。Okta は「事前登録型で使う企業向け IdP」の代表例として、Workers 上の自作 MCP サーバーは「OAuth で保護された上流 MCP サーバー」の検証用の代役として使っています。同種の IdP や任意の上流 MCP サーバーに読み替えられる内容です。
- 今回検証した範囲
- 本記事(A編)で扱うのは、Cloudflare Workers 上に自作した MCP サーバーを Okta のカスタム認可サーバーで保護し、Claude から直接つないで、スコープとグループの2軸でツール単位の可否を制御できるかまでです。実際に権限を落として拒否される様子まで確認します。MCP サーバーポータルと手動 OAuth 本体の検証は、後編(B編)で扱います。
- 検証時点と前提
- 2026年8月時点の検証です。Cloudflare は Enterprise プラン、Okta は API Access Management が有効な組織を使用しています。MCP の認可仕様は改訂が続いている領域なので、画面や挙動が変わっている可能性があります。最新の情報は公式ドキュメントをご確認ください。
1. なぜこれを検証したのか
社内で AI の活用を進めようとすると、遅かれ早かれ同じ壁に当たります。「社内のドキュメントを AI に読ませたい。でも、契約情報や人事情報まで全員に見せるわけにはいかない」という壁です。
技術的に AI と社内システムを繋ぐこと自体は、そこまで難しくありません。API キーを1本発行して、AI にそれを持たせればいい。問題はその先です。
| 共有 API キー方式でできないこと | なぜ困るか |
|---|---|
| 誰がアクセスしているのか分からない | 監査ログを見ても、全員が同じ「APIキー」として記録される |
| 人によって見える範囲を変えられない | 一番権限の低い人に合わせるか、全員に全部見せるかの二択になる |
| 退職・異動時に個別に止められない | キーを作り直して全員に配り直すことになる |
結局のところ、「AI が誰の代わりに動いているのか」を、システム側が分かっていないのが原因です。ここを解決しないと、本番の業務データには繋げられません。
Cloudflare でどこまでできるか
この課題に対して、Cloudflare には必要な部品が一通り揃っています。
| やりたいこと | 使う Cloudflare の機能 | 扱う記事 |
|---|---|---|
| MCP サーバーを動かす | Cloudflare Workers | 本記事(A編) |
| 複数の MCP サーバーの入口をひとつにする | MCP サーバーポータル(Cloudflare One) | 後編(B編) |
| その入口を ID 基盤で守る | Cloudflare Access | 後編(B編) |
| 誰がどのツールを呼んだか記録する | ポータルの実行ログ | 後編(B編) |
本記事では、まず一番小さい単位から始めます。Workers 上に MCP サーバーを1つ作り、それを Okta で守る。ここが成立しないと、複数サーバーをまとめる話にも進めないためです。後編で扱う MCP サーバーポータルは、2026年7月に手動 OAuth という機能が追加されたことで、Dynamic Client Registration(動的クライアント登録)が使えない環境、つまり DCR 非対応、あるいは無認証での登録を許していない IdP を上流に据えられるようになりました。そちらが本命ですが、まずは土台からです。
2. MCP とは何か
本題に入る前に、前提となる MCP について少しだけ触れます。既にご存じの方は読み飛ばしてください。
MCP(Model Context Protocol)は、AI に外部の道具を使わせるための共通規格です。AI 単体では社内のデータベースも社内 Wiki も見られませんが、「こういう道具がありますよ」と教えてあげる仕組みがあれば、AI はそれを呼び出せます。
登場人物は3つです。
「ツール」は要するに関数です。名前と、受け取る引数と、やることが決まっている。MCP サーバーは「うちにはこういうツールがあります」という一覧を返し、AI はその中から必要なものを選んで呼びます。
ここで重要なのは、ツールを呼ぶかどうかを決めるのは AI ですが、呼ばせてよいかどうかを決めるのはサーバー側だという点です。今回の話は、まさにこの「呼ばせてよいか」の作り方です。
3. 今回作るもの
社内ナレッジを扱う MCP サーバーを Cloudflare Workers 上に作りました。ツールは3つです。
| ツール | やること | 誰が使えるか |
|---|---|---|
whoami |
今使っているトークンの中身を見せる | 接続できた人全員 |
wiki_search |
社内ナレッジを検索する | 接続できた人全員 |
contract_lookup |
顧客の契約情報を参照する | 特定のグループの人だけ |
なぜ3つなのかを先に説明しておきます。この3つは、それぞれ違う役割を持っています。
whoami は検証のための計測器です。実際の業務では要らないツールですが、これがあると「今このトークンには何の権限が載っているのか」が目で見えます。認可の仕組みを検証するとき、これが無いと何が起きているのか分かりません。
wiki_search と contract_lookup は対比のためのペアです。前者は誰でも使える一般ツール、後者は限られた人だけの特権ツール。この2つが同じサーバーに同居していて、同じ人が接続したときに片方だけ弾かれる、という状態を作りたかったのです。
4. 認可の設計:2つの軸を使い分ける
ここが今回の記事で一番伝えたい部分です。
OAuth には「スコープ」という仕組みがあります。トークンに「この権限を持っている」という印を付けるもので、今回で言えば wiki.read がそれにあたります。では contract_lookup 用に contract.read というスコープを作ればいいかというと、それではうまくいきません。
スコープだけでは足りない理由
ホテルのカードキーで例えます。
| ホテル | OAuth | |
|---|---|---|
| スコープ | カードキーに書き込まれた「開けられる扉」の一覧 | トークンに載る権限の一覧 |
| グループ | 宿泊者名簿の「この人はスイート客」という区分 | ID基盤側の所属情報 |
カードキーを発行するとき、フロントは名簿を見て「この人はスイート客だからラウンジも開けられるキーを作ろう」と判断します。つまり名簿(グループ)が先にあって、キー(スコープ)はその結果です。
問題は、キーを発行する側が「全員に同じキーを配る」設計になっている場合です。実は後編で扱う Cloudflare の MCP サーバーポータルがまさにそれで、管理者が登録したスコープの一覧が全利用者に一律で使われます。ここで特権的なスコープを混ぜると、権限のない人にまでそれを要求してしまい、トークンの発行自体が失敗します。
そこでスコープは全員共通にして、人による差はグループで表現する設計にしました。
5. Okta 側を用意する
前提:カスタム認可サーバーが使えること
最初に確認すべきことがあります。Okta 管理画面の セキュリティ > API に「認可サーバー」タブがあり、default という認可サーバーが存在するかどうかです。

これはカスタム認可サーバーの機能が有効かどうかの判定です。公式にはこう書かれています。
Okta の API Access Management 製品は、カスタム認可サーバーを使用するための必須要件であり、本番環境ではオプションのアドオンです。 原文: Okta's API Access Management product — a requirement to use Custom Authorization Servers — is an optional add-on in production environments.
出典: https://developer.okta.com/docs/concepts/auth-servers/
ここが無い場合、今回の構成は組めません。本番の Okta 組織で試す前に、必ず確認してください。
認可サーバーを作る
mcp-kb という名前で新規作成します。既存の default は他の用途にも使われうるので、専用のものを立てます。
ここで最も重要な入力が「オーディエンス」です。MCP サーバーの URL を、末尾スラッシュなしで正確に入れます。この値が、後で Workers 側がトークンを検証するときの照合先になります。1文字違うだけで、以降すべての検証が失敗します。

スコープを2つ作る
whoami.read と wiki.read を追加します。オプションのチェックボックスはすべて既定のまま(オフ)で構いません。

contract_lookup 用のスコープは作りません。4章で説明したとおり、こちらはグループで制御するためです。
groups クレームを追加する
クレームタブで、トークンにグループ情報を載せる設定をします。
| フィールド | 設定値 |
|---|---|
| 名前 | groups |
| 含めるトークンの種類 | アクセストークン |
| 値のタイプ | グループ |
| フィルター | 次で始まる:mcp- |

Everyone を含む全所属グループがトークンに載ります。公式にも明記されています。フィルター:Groups を選択した場合に表示されます。グループフィルターを追加するために使用します。空欄のままにすると、このクレームはすべてのユーザーを含みます。 原文: Filter: Appears if you choose Groups. Use it to add a group filter. If you leave it blank, then this claim includes all users.
出典: https://help.okta.com/en-us/content/topics/security/api-config-claims.htm
そして、この設計がカスタム認可サーバーでしか成立しない理由もここにあります。
組織認可サーバーでは、groups クレームを持たせられるのは ID トークンのみで、アクセストークンには持たせられません。 原文: For an org authorization server, you can only create an ID token with a groups claim, not an access token.
出典: https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/
アプリとアクセスポリシー
Claude 用のアプリを OIDC のネイティブアプリケーションとして登録します。PKCE を使う公開クライアントになり、クライアントシークレットは発行されません。Claude のようなクライアントにシークレットを預けなくて済む形です。


リダイレクト URI には https://claude.ai/api/mcp/auth_callback を指定します。
最後に、認可サーバーにアクセスポリシーを作ります。これが無いとトークンは1つも発行されません。ポリシーを作り、その中にさらにルールを追加する二階建ての構造です。

6. 繋ぐ前に、Okta 単体で確かめる
コードを書く前に、Okta が期待どおりのトークンを出すか確認します。ここを飛ばすと、後で失敗したときに原因がどちらにあるのか分からなくなります。
認可サーバーの「トークンのプレビュー」タブで、クライアント・ユーザー・スコープを指定するとトークンの中身が見られます。
ここで注意点があります。プレビュー画面には id_token と「トークン」の2つのタブがあり、確認すべきは後者(アクセストークン)です。ID トークンの aud はクライアント ID になるので、これを見て「オーディエンスの設定を間違えた」と勘違いしがちです。


ここで aud が MCP サーバーの URL になっていること、scp に要求したスコープが入っていること、groups にフィルタどおりのグループだけが載っていることを確認します。3つ揃えば Okta 側は完成です。
7. Cloudflare Workers 側の実装
MCP サーバーは Cloudflare Workers 上に置きました。Cloudflare が公開しているテンプレートから始められます。
npm create cloudflare@latest -- internal-kb-mcp --template=cloudflare/ai/demos/remote-mcp-authless
テンプレート名に authless とあるとおり、初期状態では認証がありません。ここに認証を足していきます。
やることは3つだけ
コード全体は GitHub に置いてあります(こちら)。要点は3つです。
(1) 保護リソースのメタデータを返す
MCP の認可仕様は、RFC 9728 という規格に沿っています。サーバーは「自分を守っているのは誰か」を決まった場所で公開し、クライアントはそれを読んで認証先を知ります。
if (url.pathname === "/.well-known/oauth-protected-resource") {
return Response.json({
resource: env.MCP_RESOURCE,
authorization_servers: [env.OKTA_ISSUER],
scopes_supported: ["whoami.read", "wiki.read"],
bearer_methods_supported: ["header"],
});
}
この scopes_supported が後で効いてきます。10章で実際に検証します。
(2) トークンを検証する
jose というライブラリで JWT を検証します。発行者と宛先を指定するのが肝心なところです。
const result = await jwtVerify(token, getJwks(env.OKTA_ISSUER), {
issuer: env.OKTA_ISSUER,
audience: env.MCP_RESOURCE,
});
ここで audience に指定する値が、Okta の認可サーバーに設定した「オーディエンス」と一致している必要があります。5章で「1文字違うと失敗する」と書いたのはこの照合のことです。
検証に失敗したら 401 を返し、WWW-Authenticate ヘッダーでメタデータの場所を教えます。これがクライアントにとっての「認証してください」という合図になります。
(3) ツールごとに条件を見る
// スコープで守る
if (!scopes().includes("wiki.read")) {
return refuse("このツールにはスコープ wiki.read が必要です。");
}
// グループで守る
if (!groups().includes("mcp-managers")) {
return refuse("このツールは Okta グループ mcp-managers のメンバーのみ実行できます。");
}
たったこれだけです。認可の実体は、検証済みトークンの中身を見て分岐しているだけで、特別なことは何もしていません。
設定ファイル
発行者とリソース URL を wrangler.jsonc の環境変数として持たせます。

{発行者URI}/.well-known/oauth-authorization-server にアクセスして JSON が返るかで確認できます。404 なら認可サーバー ID の写し間違いです。ここが違うと公開鍵が取得できず、すべてのトークン検証が失敗します。8. Claude から繋ぐ
Claude の設定からカスタムコネクタを追加します。入力するのは MCP サーバーの URL と、Okta で発行されたクライアント ID だけです。シークレットは空欄です(ネイティブアプリなので存在しません)。
接続を押すと Okta のログイン画面が開き、認証が終わるとツールが3つ見えるようになります。
まず whoami を実行します。

自分のメールアドレスが subject として返り、スコープとグループが載っています。AI が「誰として」動いているかが、サーバー側から見えている状態です。共有 API キーでは決して得られない情報です。
9. 権限を落として確かめる
ここからが本編です。設定が正しく効いているかは、権限の無い人で試して初めて分かります。
2人目のユーザーを Okta に作りました。mcp-users にだけ所属させ、mcp-managers には入れていません。

この状態で、2人目として Claude に接続し直します。
まず whoami で状態を確認

subject が2人目のメールアドレスに変わり、groups から mcp-managers が消えています。接続する人が変われば、サーバーから見える人も変わる。当たり前のようですが、これが成立していることが今回の前提です。
3つのツールを叩いてみる

| ツール | 必要な条件 | このトークンの状態 | 結果 |
|---|---|---|---|
whoami |
スコープ whoami.read |
あり | 成功 |
wiki_search |
スコープ wiki.read |
あり | 成功 |
contract_lookup |
グループ mcp-managers |
なし | 拒否 |
これが今回の検証で一番見せたかった状態です。同じ人・同じ接続・同じトークンで、ツールによって結果が分かれています。

注目してほしいのは、contract_lookup に対応するスコープが存在しないのに、制御が効いている点です。OAuth のスコープは「あり/なし」しか表現できませんが、そこにグループという別の軸を足すことで、より細かい制御ができています。
10. スコープを外すと何が起きるか
もうひとつ確かめたいことがありました。クライアントは、どうやって「要求するスコープ」を知っているのか。
Claude の接続設定では、スコープを入力する欄はどこにもありません。にもかかわらず、トークンには whoami.read と wiki.read がきちんと載っていました。7章で書いた scopes_supported を読んでいるのではないか、という推測はできますが、確証がありません。
そこで、Workers 側の宣言だけを変えて、Okta には一切触らずに試しました。

デプロイして、コネクタを切断・再接続します。
whoami を叩いても、スコープは変わりませんでした。scopes_supported はクライアントが認可を要求する時点で読む値なので、既に発行済みのトークンには影響しません。トークンを取り直す必要があります。
消えました。Okta 側の設定は一切変えていません。Workers が宣言を変えたら、クライアントの要求が変わったのです。RFC 9728 に基づく発見の仕組みが、実際に動いていることの証明になります。
そして、この状態で wiki_search を叩くと、今度はスコープ不足で拒否されます。

| 状態 | scopes | groups | wiki_search | contract_lookup |
|---|---|---|---|---|
| 変更前 | wiki.read / whoami.read | mcp-users | 成功 | 拒否(グループ不足) |
| 変更後 | whoami.read | mcp-users | 拒否(スコープ不足) | 拒否 |
同じユーザー・同じグループのまま、スコープの有無だけで挙動が分岐しました。9章のグループ軸と合わせて、2つの軸が独立して機能していることが実データで示せたことになります。
11. 分かったこと
スコープとグループは役割が違う
エラーメッセージが軸ごとに分かれているのが分かりやすい証拠です。
| 拒否理由 | 返る文言 | 制御している軸 |
|---|---|---|
| スコープ不足 | このツールにはスコープ wiki.read が必要です | OAuth スコープ |
| グループ不足 | このツールは Okta グループ mcp-managers のメンバーのみ実行できます | ID基盤のグループ |
スコープは「アプリケーションに何をさせてよいか」、グループは「この人が誰なのか」。前者は同意の話、後者は身元の話です。混ぜないほうが、設計としても運用としても素直になります。
権限変更はトークンの再発行で効く
Okta 側でグループから外しても、既に発行されているトークンはそのまま使えました。トークンにはその瞬間の情報が焼き付いており、次に発行されるまで更新されません。
| 利点 | 制約 | |
|---|---|---|
| トークンに情報を載せる方式 | 毎回 ID 基盤に問い合わせないので速い。ID 基盤が落ちても動く | 権限剥奪が即時に反映されない |
アクセストークンの有効期限を短くするか、重要な操作のときだけ問い合わせるか。どこまで即時性が必要かは、扱うデータ次第です。設計時に決めておくべき論点として認識しておくとよいと思います。
拒否がどの層から返っているか
今回のツール側の拒否は、HTTP のステータスコードではなくツールの応答として返しています。トークン自体は正しいので認証は通っており、その先のアプリケーション判断で弾いている形です。
この区別は、トラブルシュートのときに効いてきます。「401 が返る」のか「ツールがエラーを返す」のかで、疑うべき場所がまったく違います。
12. Cloudflare 側から見た全体像と、次回
ここまでで、Cloudflare Workers 上の MCP サーバー1つを、Okta の権限で守る形ができました。改めて Cloudflare の観点で整理すると、本記事で使ったのは Workers だけです。
| 層 | 担当 | 本記事での状態 |
|---|---|---|
| MCP サーバーの実行基盤 | Cloudflare Workers | 使用済み |
| トークン発行(認可サーバー) | Okta | 使用済み |
| 集約と保護 | Cloudflare One(MCPサーバーポータル / Access) | 未使用 |
| ツール単位の監査ログ | MCPサーバーポータル | 未使用 |
現実には、MCP サーバーは1つでは済みません。社内 Wiki 用、チケット管理用、CRM 用と増えていきます。そうなると接続先も認証も分散し、利用者は何個もコネクタを登録することになります。管理側も「誰がどのサーバーに繋いでいるか」を横断して把握できません。
後編では、Cloudflare の MCP サーバーポータルでこれらの入口をひとつにまとめます。ポータル自体は Cloudflare Access で保護し、その先の自作 MCP サーバーへは本記事で作った Okta の認可をそのまま使います。
そのとき問題になるのが、MCPサーバポータルが上流の MCP サーバーに接続するときの OAuth をどうするかです。動的クライアント登録が使えない上流プロバイダーは多いのですが、2026年7月に追加された手動 OAuth(Static OAuth) の機能により、事前登録した資格情報を使って Okta のような IdP を上流に据えられるようになりました。
加えて、ポータルにはツール単位の実行ログがあります。本記事で確認した「誰として動いているか」が、管理画面から一覧で追えるようになる。ここまで含めて、後編でお伝えします。
参考にした情報
- Okta: 認可サーバーの種類と使い分け
https://developer.okta.com/docs/concepts/auth-servers/ - Okta: groups クレームのカスタマイズ
https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/ - Okta: API アクセスクレームの設定項目
https://help.okta.com/en-us/content/topics/security/api-config-claims.htm - Cloudflare: リモート MCP サーバーの構築
https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/ - Model Context Protocol: 認可仕様
https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization - Model Context Protocol: 2026-07-28 仕様のリリース告知
https://blog.modelcontextprotocol.io/posts/2026-07-28/ - 本記事の Workers 実装(GitHub)
https://github.com/yamashin55/okta-mcp-cloudflare-demo/
