Skip to main content
Glama
yamashin55

internal-kb-mcp

by yamashin55
README.md
# internal-kb-mcp — Okta のカスタム認可サーバーで保護した MCP サーバー

Cloudflare Workers 上に構築した **リモート MCP サーバーの検証用実装** です。
アクセス制御を MCP サーバー側で自前実装するのではなく、**Okta のカスタム認可サーバーが発行したアクセストークン (JWT) の中身だけを根拠に、ツール単位で認可する**という構成を検証します。

このリポジトリは検証記事の付属サンプルです。実運用を想定したものではありません。

## 何を検証できるか

| 検証したいこと | このリポジトリでの表現 |
| --- | --- |
| MCP クライアントが「どこで認証すればよいか」を自力で見つけられるか | RFC 9728 の保護リソースメタデータ + 401 応答の `WWW-Authenticate` |
| スコープによるツール単位の認可 | `whoami` / `wiki_search` |
| IdP のグループによる特権ツールの制御 | `contract_lookup` |
| 認可の状態を利用者が確認できるか | `whoami` が検証済みクレームをそのまま返す |

## 認可フロー

```mermaid
sequenceDiagram
    autonumber
    participant C as MCP<br/>クライアント
    participant W as Worker<br/>(MCP サーバー)
    participant O as Okta<br/>認可サーバー

    C->>W: POST /mcp (トークンなし)
    W-->>C: 401 + WWW-Authenticate<br/>resource_metadata=...
    C->>W: GET /.well-known/<br/>oauth-protected-resource
    W-->>C: resource / authorization_servers<br/>/ scopes_supported
    C->>O: 認可リクエスト<br/>(audience = MCP_RESOURCE)
    O-->>C: アクセストークン JWT<br/>(scp / groups を含む)
    C->>W: POST /mcp<br/>Authorization: Bearer ...
    W->>O: JWKS 取得<br/>({issuer}/v1/keys)
    O-->>W: 公開鍵
    W-->>C: 署名 / iss / aud 検証 OK<br/>→ ツール実行
```

ポイントは、**Worker 側がクライアントシークレットを一切持たない**ことです。
この Worker は OAuth のリソースサーバーであり、JWKS で取得した公開鍵を使って JWT を検証するだけです。認可の判断材料はすべてトークンの中にあります。

## ツールと認可条件

| ツール | 認可条件 | 引数 | 内容 |
| --- | --- | --- | --- |
| `whoami` | スコープ `whoami.read` | なし | 検証済みトークンの subject / scopes / groups / audience / issuer / 有効期限を返す |
| `wiki_search` | スコープ `wiki.read` | `query` (必須) | 社内ナレッジのダミーデータを全文検索する |
| `contract_lookup` | Okta グループ **`mcp-managers`** への所属 | `customer` (任意) | 顧客の契約情報のダミーデータを返す。省略時は全件 |

判定に使うクレームは次のとおりです。

- スコープ: アクセストークンの `scp` クレーム
- グループ: アクセストークンの `groups` クレーム(Okta 側でトークンに載せる設定が必要)

条件を満たさない場合はトランスポート層で弾かずに、**ツールの応答として「なぜ実行できないか」を返します**(`isError: true`)。認可の効き方が MCP クライアントの画面上で見えるようにするための、意図的な設計です。

> データはすべてソースコード内のダミーです(`WIKI` / `CONTRACTS`)。実在の顧客情報は含まれていません。

## セットアップ

### 前提

- Cloudflare アカウント(Workers が有効)
- Okta のテナント(**カスタム認可サーバー**を作成できるプラン)
- Node.js と npm

### 1. Okta 側

Okta 管理コンソールの **Security > API > Authorization Servers** から、カスタム認可サーバーを作成し、以下を設定します(UI の名称は Okta のバージョンにより多少異なります)。

1. **カスタム認可サーバーを作成する**
   - 作成すると issuer が `https://<your-okta-domain>.okta.com/oauth2/<authorization-server-id>` の形式で払い出されます。これが `OKTA_ISSUER` になります。
2. **Audience を Worker の URL に設定する**
   - 後述の `MCP_RESOURCE` と**完全に一致**させます。ここがずれていると、署名が正しくても `aud` 不一致で常に 401 になります。
3. **スコープを追加する**
   - `whoami.read`
   - `wiki.read`
4. **グループを作成する**
   - `mcp-managers` を作成し、特権ツールを使わせたいユーザーを所属させます。
   - 権限差を確認するために、**所属していないユーザーも 1 人用意しておく**と検証しやすくなります。
5. **アクセストークンに `groups` クレームを載せる**
   - カスタム認可サーバーの **Claims** で、`groups` という名前のクレームを **Access Token** に対して追加します。
   - これを設定しないと `contract_lookup` は誰も実行できません(グループ判定が常に空になるため)。
6. **アクセスポリシー / ルールを設定する**
   - どのクライアント・どのユーザーに、どのスコープを付与するかを定義します。

MCP クライアントが Okta からトークンを取得するための OAuth クライアントの登録方法(動的クライアント登録を使うか、事前に登録したクライアントを使うか)は、利用する MCP クライアントによって異なります。**この部分は検証記事側の手順を参照してください。**

### 2. Cloudflare 側

```bash
git clone https://github.com/yamashin55/okta-mcp-cloudflare-demo.git
cd okta-mcp-cloudflare-demo
npm install
```

`wrangler.jsonc` の `vars` を、手順 1 で確定した自分の環境の値に**必ず**書き換えます。

```jsonc
"vars": {
  "OKTA_ISSUER": "https://<your-okta-domain>.okta.com/oauth2/<authorization-server-id>",
  "MCP_RESOURCE": "https://<worker-name>.<your-subdomain>.workers.dev"
}
```

`MCP_RESOURCE` はデプロイ後に確定する URL なので、**一度デプロイして URL を確認してから書き換え、もう一度デプロイする**流れになります。

```bash
npx wrangler deploy
```

`OKTA_ISSUER` と `MCP_RESOURCE` はどちらも秘密情報ではありません。次項のメタデータエンドポイントから無認証で公開される値です。そのため `wrangler secret` ではなく平文の `vars` で管理しています。

### 3. 接続確認

保護リソースメタデータは無認証で取得できます。

```bash
curl -s https://<your-worker-url>/.well-known/oauth-protected-resource
```

```json
{
  "resource": "https://<your-worker-url>",
  "authorization_servers": ["https://<your-okta-domain>.okta.com/oauth2/<authorization-server-id>"],
  "scopes_supported": ["whoami.read", "wiki.read"],
  "bearer_methods_supported": ["header"]
}
```

トークンなしでツールを呼ぶと、401 と `WWW-Authenticate` が返ります。MCP クライアントはこのヘッダーを見て、認可サーバーの場所を知ります。

```bash
curl -i -X POST https://<your-worker-url>/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

```
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://<your-worker-url>/.well-known/oauth-protected-resource"
```

## 認可が効いていることの確認

| 確認したいこと | 操作 | 期待される結果 |
| --- | --- | --- |
| トークンが検証されている | `whoami` を実行 | `subject` / `scopes` / `groups` / `audience` / `issuer` が返る |
| スコープで守られている | `wiki.read` を含まないトークンで `wiki_search` を実行 | 「このツールにはスコープ wiki.read が必要です。」 |
| グループで守られている | `mcp-managers` に所属していないユーザーで `contract_lookup` を実行 | 「このツールは Okta グループ mcp-managers のメンバーのみ実行できます。」 |
| audience が効いている | `MCP_RESOURCE` と Audience をずらす | すべてのリクエストが 401 |

`whoami` を最初に実行して、実際にどのスコープとグループがトークンに載っているかを確認してから他のツールを試すと、原因の切り分けが楽になります。

## 実装

すべて `src/index.ts` の 1 ファイルです。

| 箇所 | 役割 |
| --- | --- |
| `fetch` ハンドラ | `/.well-known/oauth-protected-resource` の応答、Bearer トークンの取り出し、`jwtVerify` による検証 |
| `getJwks()` | issuer ごとに JWKS を使い回すキャッシュ |
| `unauthorized()` | RFC 9728 に沿った `WWW-Authenticate` 付きの 401 応答 |
| `createServer()` | 3 つのツールの登録と、スコープ / グループによる認可判定 |
| `claimsOf()` / `scopesOf()` / `groupsOf()` | 検証済みクレームをツールから参照するためのヘルパー |

検証済みのクレームは `createMcpHandler` の `authContext` 経由でツールに渡しています。ツール側はトークンの検証を一切行わず、**検証済みの事実だけを見て判断する**構造です。

## 開発

```bash
npm run dev          # ローカル開発サーバー (wrangler dev)
npm run type-check   # 型チェック (tsc --noEmit)
npm run lint:fix     # oxlint
npm run format       # oxfmt
npm run cf-typegen   # wrangler types (バインディング変更時)
npm run deploy       # wrangler deploy
```

`wrangler dev` でローカル起動した場合も Okta のトークン検証は有効なままです(JWKS の取得にネットワークアクセスが発生します)。ローカルで検証を通すには、`MCP_RESOURCE` と Okta の Audience を合わせておく必要があります。

## 注意事項

- 本リポジトリは **検証・学習を目的としたサンプル実装**です。無保証で提供され、実運用環境での利用は想定していません。
- 収録しているナレッジ・契約情報はすべて**ダミーデータ**です。
- `wrangler.jsonc` の値はプレースホルダです。そのままデプロイしても動作しません。
- 内容の正確性・動作について、作者はいかなる責任も負いません。利用は自己責任でお願いします。