1. Resource ServerとしてのMCPサーバー: 何を設定するのか
前回の講義ではトークンを発行する Auth Server(認可サーバー)を設定しました。今回はこの組み合わせのもう一方、つまりトークンを受け取り検証する側である、Resource ServerとしてのMCPサーバーを扱います。
OAuth 2.1の観点では、あなたのMCPサーバーはResource Serverです。サーバーは「リソース」(MCPのツールやユーザーデータ)を保持し、リクエストのヘッダー Authorization: Bearer ... に含まれるアクセストークンを受け取ります。ツールを実行する前に、トークンが本物であり、有効期限切れでなく、信頼できる認可サーバー(Auth Server)から発行され、かつ自分のMCPサーバー宛(aud)で、必要な権限(scope)を持っていることを確認する必要があります。
以下の2層を分けて考えるのが重要です。
- トランスポート層 — HTTPヘッダーとトークンを処理します。ここであなたは:
- Authorization: Bearer を受け取り/パースし、
- トークンがない/不正な場合は 401 Unauthorized と WWW-Authenticate: Bearer ... を返し、
- 有効なトークンであればユーザーコンテキストを組み立てます。
- MCP SDK層 — JWTを知っている必要はありません。ここは「すでに認証された」呼び出しを受け取り、ハンドラー内で ctx.userId、ctx.scopes などを使うだけです。
たとえ話にすると、MCP SDK はキッチンのシェフ、OAuthミドルウェアは入口の警備員です。シェフは身分証の確認はせず、注文を調理するだけです。
学習用の例として GiftGenius を続けます。MCPサーバーは http://localhost:3000 上にあり、ツールは list_my_gifts。Auth Server(たとえば Keycloak または自作のミニAS)は http://localhost:4000 とします。
2. .well-known/oauth-protected-resource: MCPリソースの名刺
なぜリソースに .well-known が必要か
ChatGPT(や MCP Jam)が初めてあなたのMCPサーバーにアクセスして 401 を受け取ったとき、次の2点を知る必要があります。
- どこへトークンを取りに行けばよいか。
- このリソースがサポートしている権限(スコープ)は何か。
これをクライアントにハードコードしないために、ディスカバリエンドポイントを使います。
GET /.well-known/oauth-protected-resource
このエンドポイントは RFC 9728 に準拠した保護リソースのメタデータ(Protected Resource Metadata)のJSONを返します。
GiftGeniusの例:
{
"resource": "http://localhost:3000",
"authorization_servers": ["http://localhost:4000"],
"scopes_supported": ["gifts:read", "gifts:write"],
"bearer_methods_supported": ["header"]
}
OpenAIのガイドでもほぼ同じ例が示されていますが、HTTPSと実ドメインが使われています。
クライアント(ChatGPT/Jam)はこのドキュメントを読み、次を理解します。
- トークンの audience は http://localhost:3000 であるべきこと。
- どの authorization_servers(issuer URL)を使うか。
- サポートされるスコープ一覧(同意画面やリクエストの組み立てを助ける)。
メタデータ項目の解説
主要なフィールドの要約:
| フィールド | 目的 |
|---|---|
|
MCPサーバーの正規のHTTPS/HTTP識別子。のちにトークンの aud と一致します。 |
|
あなたの認可サーバー(Auth Server/issuer)のURL一覧。クライアントはここからOAuth/OIDCメタデータを取得します。 |
|
サポートされるスコープの配列。クライアントが適切なUXとトークン要求を組み立てるために使います。 |
|
トークンの渡し方。一般的には ["header"]、つまり Authorization: Bearer ...。 |
必要に応じて resource_documentation、jwks_uri、introspection_endpoint なども公開されますが、基本シナリオでは最初の4つで十分です。
重要なポイント: resource は、Auth Server がトークンの aud に入れる値と一致しなければなりません。一致しないと、MCPクライアント(そしてあなた自身のサーバー)もトークンを拒否します。
Next.js 16での .well-known 実装
MCPサーバーがNext.jsアプリ(Apps SDKのバックエンド、ポートは 3000)で動いているとします。最も簡単なのは、次のように route handler を作る方法です( app/.well-known/oauth-protected-resource/route.ts)。
// app/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const body = {
resource: "http://localhost:3000",
authorization_servers: ["http://localhost:4000"],
scopes_supported: ["gifts:read", "gifts:write"],
bearer_methods_supported: ["header"],
};
return NextResponse.json(body);
}
本番環境では、resource は本番のMCPサーバーのHTTPS URL(例: https://mcp.giftgenius.com)であるべきで、IdPから発行されるトークンの aud と一致する必要があります。
3. WWW-Authenticate と 401: MCPが「トークンが必要」と知らせる方法
すでに .well-known/oauth-protected-resource でリソースの「名刺」を用意しました。次は、MCPサーバーがクライアントにその名刺を参照すべきことをどう知らせるかを見ます。それが 401 と WWW-Authenticate ヘッダーです。
基本シナリオ: トークンなしで来た場合
ChatGPT が初めてツール list_my_gifts を呼び出すとしましょう。ネットワークリクエストは次のようになります。
GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000
トークンはありません。MCPサーバーは黙って 403 や適当なHTMLページを返すべきではありません。OAuthの世界での正しい挙動は、401 Unauthorized を返し、WWW-Authenticate ヘッダーを通じてクライアントに認可方法を伝えることです。
正しいレスポンスの例:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource", scope="gifts:read"
Content-Type: application/json
{"error":"unauthorized","error_description":"Missing or invalid access token"}
重要な点:
- スキーム Bearer は、OAuthのBearerトークンを必要としていることを示します。
- resource_metadata パラメータは .well-known/oauth-protected-resource のURLを指します。
- scope パラメータは必要な最小権限を示します(例: gifts:read)。
MCP Jam と ChatGPT はこのヘッダーを解釈できます。これを見たとき、彼らは次を行います。
- .well-known/oauth-protected-resource を取得する。
- authorization_servers から Auth Server とその OpenID/OAuth メタデータを見つける。
- Authorization Code + PKCE フローを開始し、ユーザーにログインページを開き、トークンを取得する。
つまり WWW-Authenticate はトリガーです。これがないと、クライアントはここにOAuthがあることすら気づきません。
401 レスポンス用ミドルウェア(Next.js)
すべての保護エンドポイントで使える小さなユーティリティを書きます。まずはレスポンスを生成する関数です。
// lib/authResponses.ts
import { NextResponse } from "next/server";
export function unauthorized(scope?: string) {
const wwwAuth = [
`Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"`,
scope ? `scope="${scope}"` : null,
]
.filter(Boolean)
.join(", ");
return new NextResponse(
JSON.stringify({
error: "unauthorized",
error_description: "Missing or invalid access token",
}),
{
status: 401,
headers: {
"WWW-Authenticate": wwwAuth,
"Content-Type": "application/json",
},
}
);
}
これで任意のルート(たとえばMCPのエンドポイント)は return unauthorized("gifts:read") と書くだけで、クライアントは正しいチャレンジを受け取れます。 unauthorized() 関数は NextResponse(標準の Response と互換)を返します。 以降の例では、このオブジェクトを例外として投げ、route handler 側で Response として捕捉し、各ルートで 401 レスポンスの生成コードを重複させないようにします。
4. Bearer トークンの受信と検証
ここからが本題です。Bearerトークンを受け取り検証する方法を見ていきます。
どこで検証を行うか
MCPトランスポートはおそらく次のいずれかで実装されています。
- Next.js の route handler(app/mcp/route.ts)。POST を受け取り、MCP SDKに委譲する。
- Express/Fastify サーバー。/mcp をリッスンし、JSONをMCPハンドラーに渡す。
いずれの場合でも、HTTP層こそが次を行うべき場所です。
- ヘッダーから Authorization を取り出す。
- 存在しない/不正な場合は、上の unauthorized で 401 を返す。
- 成功したら、コンテキストオブジェクト(userId、scopes、roles)を作り、MCP SDK に渡す(ハンドラーの引数/コンテキスト経由)。
MCP SDK(例: @modelcontextprotocol/sdk)はJWTを知らなくても問題ありません。そこはあなたの責任範囲です。
検証方法: JWT と introspection
主なスタイルは2つあります。
- Auth Server の JWK キーを使って、JWTの署名とクレームをローカルで検証する。
- 認可サーバーの /introspect に問い合わせて「このトークンはまだ有効?スコープは?」と確認する。
この講義では、Auth Server がJWTを発行し jwks_uri を公開している前提で、MCPサーバーは署名とクレームをローカル検証する想定(高速かつ自律的)で進めます。
TypeScriptのユーティリティ verifyAccessToken
ESMフレンドリーな人気ライブラリ jose を使います。必要なのはおおよそ次のようなヘルパーです。
// lib/verifyAccessToken.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("http://localhost:4000/.well-known/jwks.json")
);
const EXPECTED_ISS = "http://localhost:4000";
const EXPECTED_AUD = "http://localhost:3000";
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: EXPECTED_ISS,
audience: EXPECTED_AUD,
});
return {
sub: String(payload.sub),
scopes: String(payload.scope || "").split(" ").filter(Boolean),
raw: payload,
};
}
このヘルパーで行っていること:
- jwks_uri から Auth Server のJWKキーを取得。
- 署名と標準クレーム(iss、aud)の検証。
- sub(ユーザーID)と scope を取り出す(スペース区切りなので split(" "))。
audience は、我々の .well-known/oauth-protected-resource の resource と一致している必要があります。これにより、トークンがまさに自分のMCPサーバー向けであることが保証されます。
Authorization ヘッダーの簡易チェック
次に、ヘッダーからトークンを取り出して verifyAccessToken に通す小さなヘルパーを作ります。
// lib/getUserFromRequest.ts
import type { NextRequest } from "next/server";
import { unauthorized } from "./authResponses";
import { verifyAccessToken } from "./verifyAccessToken";
export async function getUserFromRequest(req: NextRequest) {
const auth = req.headers.get("authorization") || "";
const [, token] = auth.split(" ");
if (!token) throw unauthorized("gifts:read");
try {
return await verifyAccessToken(token);
} catch {
throw unauthorized("gifts:read");
}
}
注意点: ここでは unauthorized(...)(つまり Response オブジェクト)を例外として投げています。これにより、route handler で簡潔に捕捉してそのままレスポンスとして返せます。
5. audience と scope: トークンをリソースと操作に結びつける
Audience(aud): トークンは「誰向け」か
クレーム aud は、このトークンがどのリソース向けかを示します。今回の例では:
- Auth Server はトークンの aud を http://localhost:3000 に設定する。
- 我々の .well-known/oauth-protected-resource は resource: "http://localhost:3000" を公開する。
- verifyAccessToken でそれを検証する。
もしトークンが別のリソース(例: https://api.other-app.com)向けなら、あなたのMCPサーバーは「自分宛ではない」として拒否すべきです。
よくあるミスは、resource と aud の同期を忘れることです。その結果、すべてを設定したつもりでも、ChatGPT が常に 401 を受け取ることになります。これは後述の「よくあるエラー」でも触れます。
Scopes: 「何ができるか」を表す
トークンの scope クレームは、ユーザーがクライアントに与えた権限の一覧です。この例では:
- gifts:read — 自分のギフトを読む権限。
- gifts:write — ギフトの作成/更新権限。
.well-known/oauth-protected-resource では、これらが scopes_supported として公開され、クライアントが事前に要求可能なスコープを把握できます。
認可サーバーのディスカバリドキュメント(.well-known/openid-configuration)にも scopes_supported が掲載されますが、こちらはIdPのグローバルなスコープ一覧です(.well-known/oauth-protected-resource の「リソース固有の一覧」とは混同しないでください)。
この2つのリストを混同しないことが大切です。リソースの scopes_supported はあなたのMCPサーバーに必要な権限を示し、IdPの scopes_supported はプロバイダー全体のカタログです。クライアントは通常、この2つの交差を取ります。
MCPサーバー側で必要なこと:
- 各ツールに必要なスコープを定義する。
- ツール呼び出しごとに、トークンがそのスコープを含んでいるかを検証する。
ヘルパーを書きます。
// lib/requireScope.ts
import { unauthorized } from "./authResponses";
export function requireScope(
user: { scopes: string[] },
needed: string[]
) {
const hasAll = needed.every((s) => user.scopes.includes(s));
if (!hasAll) throw unauthorized(needed.join(" "));
}
これで requireScope(user, ["gifts:read"]) を、ツール実行前に呼び出せます。
6. MCPツールとの接続: トークンから list_my_gifts まで
Next.jsにおけるMCPのルート
あるSDKを使ったMCPサーバーがHTTPリクエストを処理するとします。Next.jsの視点では、次のように書けます。
// app/api/mcp/route.ts
import { NextRequest } from "next/server";
import { unauthorized } from "@/lib/authResponses";
import { getUserFromRequest } from "@/lib/getUserFromRequest";
import { mcpServer } from "@/lib/mcpServer";
export async function POST(req: NextRequest) {
try {
const user = await getUserFromRequest(req);
const body = await req.json();
const result = await mcpServer.handle(body, { user });
return Response.json(result);
} catch (err) {
if (err instanceof Response) return err; // unauthorized(...)
console.error(err);
return unauthorized();
}
}
ここで重要なのは次の点です。
- トークンからユーザーとスコープを抽出する(getUserFromRequest)。
- それらをコンテキスト { user } としてMCPサーバーに渡す。
- トークンがない/不正な場合は、WWW-Authenticate を含む 401 を返す。
具体的なMCP SDKのAPIは異なるかもしれませんが、どれも本質は同じです。MCP呼び出しを、呼び出し主が「誰か」を理解するミドルウェアで包むことです。
スコープ検証付きの list_my_gifts ツール
次にツールの実装を覗いてみます。たとえば、MCPのTypeScript SDKを使っていて、次のようなコードだとします。
// lib/mcpServer.ts (フラグメント)
import { createMcpServer } from "@modelcontextprotocol/sdk";
import { requireScope } from "./requireScope";
export const mcpServer = createMcpServer<{ user: any }>();
mcpServer.registerTool(
"list_my_gifts",
{
title: "List my gifts",
description: "Shows your saved gift ideas.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
async (_input, ctx) => {
requireScope(ctx.user, ["gifts:read"]);
const gifts = await loadGiftsForUser(ctx.user.sub);
return {
content: [{ type: "text", text: `Found ${gifts.length} gifts` }],
structuredContent: { gifts },
};
}
);
ここでは次の3つを行っています。
- 本処理の前に gifts:read を要求する。
- ctx.user.sub をユーザー識別子(トークンから)として使う。
- そのユーザーのデータだけを返す。
これにより、ツールは「一般的なAPI」ではなく、Auth Serverのアイデンティティに紐づいたパーソナライズドなものになります。
7. フローのまとめ: 401 から成功呼び出しまで
ここまでを整理するため、保護されたMCPサーバーが実現するフローを簡単な図でまとめます。
sequenceDiagram
participant ChatGPT
participant MCP as MCP Server (3000)
participant AS as Auth Server (4000)
ChatGPT->>MCP: POST /api/mcp (no Authorization)
MCP-->>ChatGPT: 401 + WWW-Authenticate: Bearer resource_metadata=...
ChatGPT->>MCP: GET /.well-known/oauth-protected-resource
MCP-->>ChatGPT: { resource, authorization_servers, scopes_supported }
ChatGPT->>AS: GET /authorize?scope=gifts:read&resource=...
AS-->>ChatGPT: redirect with ?code=XYZ
ChatGPT->>AS: POST /token (code + code_verifier)
AS-->>ChatGPT: { access_token, scope, ... }
ChatGPT->>MCP: POST /api/mcp Authorization: Bearer token
MCP->>MCP: verify JWT (iss, aud, exp, scope)
MCP-->>ChatGPT: tool result for this user
Auth Server へのリクエストに含まれる resource パラメータに注意してください。これはトークンの aud にコピーされ、.well-known/oauth-protected-resource の resource と一致する必要があります。
8. curl での簡単な動作確認
手動で2つのリクエストを試すと安心できます。
1つ目 — トークンなしでMCPを呼ぶ:
curl -i http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
期待するのはステータス 401 と、resource_metadata、scope="gifts:read" を含む WWW-Authenticate です。
2つ目 — Auth Server から取得した有効なトークンを付ける:
curl -i http://localhost:3000/api/mcp \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
ここで abc123 が正しい iss と aud="http://localhost:3000" を持ち、かつ scope に gifts:read を含む有効なJWTであれば、ツールのJSONレスポンスが返り、structuredContent.gifts に現在のユーザーのギフトが入ります。
9. Resource ServerとしてMCPサーバーを設定するときのよくあるエラー
ここまで実装してきた .well-known、WWW-Authenticate、トークン検証、スコープチェックにおいて、特に起きやすい落とし穴をまとめます。
エラー1: resource と audience が同期していない。
.well-known/oauth-protected-resource の resource と、Auth Server がトークンに入れる aud が食い違うケースがよくあります。署名や有効期限は正しくても、結果的に jwtVerify がトークンを弾きます。特に、MCPサーバーのドメイン/ポートを変えたのに、.well-known か Auth Server の設定更新を忘れたときに起きがちです。この講義の例では http://localhost:3000 という同一の文字列が、.well-known の resource と、verifyAccessToken 内の EXPECTED_AUD に使われています。RESOURCE_ID のような単一の定数を作り、両方で使い回すと不一致を防げます。
エラー2: 401 で WWW-Authenticate を返していない。
開発者が 401 や 403 だけを返し、WWW-Authenticate ヘッダーを付けないことがあります。ブラウザ目線では問題なくても、ChatGPT や MCP Jam は、どこへトークンを取りに行き、どのスコープが必要かを判断できません。その結果、MCPサーバーが「壊れている」と見なされ、リンク用のUIも表示されません。最低限必要なのは、WWW-Authenticate: Bearer と resource_metadata=".../.well-known/oauth-protected-resource" です。さらに scope="..." も付けるとフローが明確になります。上の unauthorized() ヘルパーなら、401 時に必ずこのヘッダーが入ります。
エラー3: 署名や iss を検証せずにトークンを信用してしまう。
初期段階でありがちな誘惑は「自分のAuth Server発行のトークンだし、JSON.parse(atob(..)) で中身を読むだけでいいでしょ」というもの。これはNGです。形式さえ合っていれば偽造トークンでも受け入れてしまいます。正しいアプローチは、jwks_uri から鍵を取得し、jose や jsonwebtoken などのライブラリで署名と iss/aud を検証すること。その後にクレームの内容を信用すべきです。
エラー4: トークン検証とビジネスロジックを混在させる。
トークン検証がツールのコードに散らばると、あるツールでは scope を確認しているのに別のツールでは忘れていたり、どこかで aud の確認を落としたり、ユーザーIDをツール引数から受け取ってしまったりと、奇妙なバグや脆弱性につながります。はっきり分離しましょう。HTTP層のミドルウェアがトークン(署名、iss、aud、期限)を扱い、ツール側では ctx.user を「真実」として前提にしつつ、必要であればロール/テナントなどの業務チェックを追加します。
エラー5: 公開している scopes_supported と実際に使うスコープが一致していない。
これもありがちです。.well-known/oauth-protected-resource で公開するスコープ、Auth Server 側に設定するスコープ、ツールでチェックするスコープがそれぞれ異なると、ChatGPT/MCP Jam は公開された scopes_supported に基づいて認可リクエストを作るのに、あなたのサーバーは「必要なスコープがない」と文句を言う事態になります。スコープ数を最小限に抑え、TypeScriptのenumなどで「単一の真実」を管理し、そこから .well-known の生成や Auth Server のクライアント設定を共有するのがよいでしょう。
エラー6: Apps SDK の securitySchemes だけに頼り、サーバー側の検証を忘れる。
Apps SDK ではツールに対して securitySchemes(noauth、oauth2、scopes)を記述でき、ChatGPT は適切なUXを表示してくれます。しかし、それだけでサーバーが自動的に安全になるわけではありません。ツールがOAuthトークン必須だと宣言していても、MCPサーバーは毎リクエストでトークン、issuer、audience、scopes を必ず検証すべきです。でなければ、URLに直接リクエストを送って検証を回避できてしまいます。
エラー7: アクセストークンの短い有効期限と失効処理を忘れる。
アクセストークンの寿命が長すぎると安全性が下がります。逆に短すぎるのに、サーバーが失効を適切に処理できないと、ユーザーはたびたびエラーに遭遇します。望ましいモデルは「短命のアクセストークン」+「exp が過去になったときに WWW-Authenticate を付けて 401 を返す準備がある」ことです。クライアント(ChatGPT)はこれを受けて再度OAuthフローを実行し、トークンを更新します。
GO TO FULL VERSION