Skip to main content
Glama
kazuhiro2188-lgtm

mcp-role-server

README.md
# mcp-role-server

**役割ベースのアクセス制御つき MCP サーバーの雛形。** Next.js の App Router で動きます。

認証・ロール別のツール公開・レート制限という、**本番で必要になる3点を最初から備えた状態**から始められます。
あなたがやることは、**自分のツールを書いて一覧に1行足すだけ**です。

---

## これは何か

MCP(Model Context Protocol)は、**AIに道具を渡すための共通の差し込み口**です。
公式のサンプルは「とりあえず動く」ところまでを示していますが、実際に業務で使おうとすると、すぐに次の3つが必要になります。

| 必要になるもの | この雛形での扱い |
|---|---|
| **誰が呼んでいるのか** | Bearer トークンによる認証(タイミング攻撃対策込み) |
| **その人にどこまで見せるか** | 役割(role)ごとにツールを出し分け |
| **叩かれすぎたらどうするか** | レート制限(超過時は `429` と `Retry-After`) |

**この3つを毎回ゼロから書かなくていい**、というのがこの雛形の目的です。

---

## 特徴

### 1. 権限がないツールは「拒否」ではなく「存在しない」

役割に合わないツールは、**そもそもサーバーに登録されません**。

```
viewer で tools/list  →  ping, echo
admin  で tools/list  →  ping, echo, server_config

viewer が server_config を呼ぶ  →  "Tool server_config not found"
```

「権限がありません」と返すのではなく、**相手から見て最初から存在しない状態**になります。

### 2. 不明な役割は、必ず弱い方へ倒れる

`X-User-Role` が未設定・空・知らない値のときは、**必ず `viewer`(最小権限)**として扱われます。
「知らない値だから admin にしておく」は起こりません。

### 3. 検査は「安い順」に並んでいる

```
レート制限(カウンターを見るだけ)
    ↓
認証(暗号計算が走る)
    ↓
サーバー組み立て(一番重い)
```

大量に叩かれたときに、**弾く処理そのもので潰れない**ようにするための順番です。

---

## 動かし方

```bash
git clone <このリポジトリ>
cd mcp-role-server
pnpm install

# 環境変数を用意する
cp .env.example .env.local

# シークレットを生成して .env.local の MCP_SHARED_SECRET に入れる
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

pnpm dev
```

`http://localhost:3000/api/mcp` で待ち受けます。

### 動作確認(curl)

```bash
SECRET="(.env.local に入れた値)"

curl -s -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer $SECRET" \
  -H "X-User-Role: viewer" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

| 送るもの | 返るもの |
|---|---|
| 鍵なし | `401 Authorization header missing or malformed` |
| 誤った鍵 | `401 Invalid bearer token` |
| 正しい鍵 | `200` + ツール一覧 |
| 上限を超えた回数 | `429` + `Retry-After` |

---

## 自分のツールを足す

**3ステップで終わります。`server.ts` には手を入れません。**

**1. `src/features/mcp/tools/` にファイルを1つ作る**

```ts
import { z } from "zod";
import type { ToolEntry } from "./types";

export const myTool: ToolEntry = {
  name: "my_tool",
  roles: ["admin"],          // ← ここに書いた役割にだけ公開される
  register(server) {
    server.registerTool(
      "my_tool",
      {
        title: "My tool",
        description: "AI がこれを読んで、使うかどうかを判断します。",
        inputSchema: { query: z.string().min(1) },
      },
      async ({ query }) => ({
        content: [{ type: "text", text: JSON.stringify({ result: query }) }],
      }),
    );
  },
};
```

**2. `src/features/mcp/tools/index.ts` の `TOOLS` に1行足す**

```ts
export const TOOLS: ToolEntry[] = [pingTool, echoTool, serverConfigTool, myTool];
```

**3. 以上です。** `roles` に書いた役割のサーバーにだけ、自動的に載ります。

> **`description` は、AI がツールを選ぶ根拠になります。**
> ここが曖昧だと、AI は間違ったツールを呼びます。実装よりも文章の精度が効く部分です。

---

## 設計の理由

### なぜ「拒否」ではなく「登録しない」のか

「権限がありません」と返す方式だと、**そのツールが存在することは相手に伝わります**。
存在が分かれば「どうすれば呼べるのか」を探られます。

登録しない方式なら、相手から見て最初から存在しません。**呼びようがありません。**

そしてもう1つ。拒否方式は、**各ツールの中に権限チェックを書く必要があります**。
ツールが増えるほど、書き忘れが起きます。**登録の段階で振り分けてしまえば、書き忘れようがありません。**

### なぜ役割をヘッダーで受け取るのか

このサーバーは、**信頼できる呼び出し元(Bot やゲートウェイ)の後ろに置く前提**で設計しています。
本人確認はその手前で済んでおり、このサーバーは「**誰として扱うか**」だけを受け取ります。

役割の判定を各サービスで二重に実装すると、**判定がずれたときに気づけません**。
判定は一箇所に集め、後段はその結果を受け取る——という分担です。

**この前提が崩れる置き方をすると、そのまま脆弱性になります。**(次の警告を参照)

### なぜレート制限をメモリで持つのか

この雛形の目的は「**認証とロール制御の型を示すこと**」であって、レート制限の完全な実装ではありません。
外部ストアを必須にすると、**動かすまでの手間が増えて雛形として使いにくくなります**。

そのかわり、**限界を隠さずに書いています**(下記)。差し替える場所は1ファイルに閉じてあります。

---

## ⚠️ セキュリティ上の重要な注意

**このサーバーを、そのままインターネットに直接公開しないでください。**

役割は `X-User-Role` ヘッダーで受け取っています。つまり——

```
Authorization: Bearer <正しいトークン>
X-User-Role: admin          ← 送れば admin になれる
```

**トークンを持っている相手なら、ヘッダーを書き換えるだけで admin になれます。**

これは想定どおりの動作です。この設計は、**次の条件が満たされている場合にのみ安全**です。

| 条件 | 意味 |
|---|---|
| **呼び出し元が信頼できる** | Bot・BFF・ゲートウェイなど、自分が管理しているものだけが呼ぶ |
| **呼び出し元が本人確認を済ませている** | 実際のユーザー認証(Clerk / Auth0 / 自前)はその手前で完了している |
| **このエンドポイントが公衆に晒されていない** | ネットワークやトークン管理で到達経路が絞られている |

**この条件を満たせない場所に置く場合は、`verifyMcpAuth` を書き換えて、
役割をヘッダーではなくトークン自体(署名付きトークンなど)から導出してください。**

---

## 既知の限界

| 限界 | 内容 | 本番でどうするか |
|---|---|---|
| **レート制限がプロセスメモリ保持** | サーバーレスや複数インスタンスでは実行のたびに別のメモリになりうるため、全体としては上限を超えて通ることがある | Redis 等の外部ストアに差し替える(`rate-limit.ts` の1ファイルのみ) |
| **固定ウィンドウ方式** | 境界で最大2倍通る(59秒目に60回+61秒目に60回) | スライディングウィンドウ方式にする |
| **レート制限が全体で1つ** | 呼び出し元ごとに分かれていない | `checkRateLimit(key)` の `key` を呼び出し元の識別子にする |
| **役割が2つ固定** | `admin` / `viewer` のみ | `McpRole` 型と `normalizeRole` を拡張する |

---

## 構成

```
src/
├── app/api/mcp/route.ts        HTTPの入口(レート制限 → 認証 → 応答)
└── features/mcp/
    ├── auth.ts                 Bearer検証・役割の正規化
    ├── rate-limit.ts           固定ウィンドウのレート制限
    ├── server.ts               役割に合うツールだけを載せてサーバーを組み立てる
    └── tools/
        ├── types.ts            ツール定義の型
        ├── index.ts            ★ ツールの一覧(ここに足す)
        ├── ping.ts             引数なしの例
        ├── echo.ts             引数ありの例(zod による入力検証)
        ├── server-config.ts    admin 限定の例
        └── weather.ts          外部APIを呼ぶ例(不要なら削除してよい)
```

> `weather.ts` は動作確認用のサンプルです。外部API(Open-Meteo・APIキー不要)を呼びます。
> **`description` に制約を書かないと AI が正しく呼べない**、という例にもなっています
> (地名APIが日本語を受け付けないため、「ローマ字で指定すること」と明記している)。
> 自分のツールを入れる際は、削除して構いません。

---

## 環境変数

| 変数 | 必須 | 説明 |
|---|---|---|
| `MCP_SHARED_SECRET` | ✅ | 接続に使う共有シークレット。未設定なら `500` を返し、素通しにはしない |
| `MCP_RATE_LIMIT` | — | 1分あたりの上限リクエスト数(既定 60) |

---

## ライセンス

MIT