- カテゴリー
- MCP・認証認可
- 公開日
- 2026.09.26
Contents
はじめに
保護されたHTTP型MCPサーバーは、アクセストークンを検証するリソースサーバーです。ユーザーを認証しトークンを発行する認可サーバーとは役割が異なります。
MCPクライアントが認可サーバーを発見する入口がOAuth 2.0 Protected Resource Metadata(RFC 9728)です。この記事は、HTTPとJavaScriptの基礎を知っていて、MCPの認可設計を初めて学ぶ人向けです。読後には、認可サーバーの発見方法と、トークン不正・権限不足への応答を説明できることを目指します。
掲載するHTTP・JSON・Expressコードは設計を理解するための抜粋です。認可サーバーの構築やログインまで動く完成アプリではありません。example.com配下のURLも説明用で、実際の接続先ではありません。
用語を先に整理すると、issuerはトークンの発行元、audienceは利用先、scopeは許可された操作の範囲です。PKCEは、認可コードを横取りされても、それだけではトークンへ交換できないようにする仕組みです。
全体の流れ
- クライアントがMCP endpointへアクセス
- 未認証ならサーバーが
401 Unauthorized WWW-Authenticateまたはwell-known URIからResource Metadataを取得- そこに書かれた認可サーバーのmetadataを取得
- Authorization Code + PKCEなどでアクセストークンを得る
Authorization: Bearer ...でMCPへ再要求- MCPサーバーがトークンの有効性、発行元、宛先、期限、操作権限を検証
STDIO transportへこのHTTPフローをそのまま適用しません。STDIOでは環境から資格情報を取得する設計が推奨されています。
401応答
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="files:read"
Cache-Control: no-store
resource_metadataは認可画面ではなく、保護対象の説明文書を指します。クライアントはヘッダーを正しくパースし、なければ仕様のwell-known URIを試します。
Protected Resource Metadata
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["files:read", "files:write"],
"bearer_methods_supported": ["header"]
}
resourceはトークンの宛先となるMCPサーバーの標準URIです。クライアントは認可要求とトークン要求へ同じresourceパラメーターを含めます。パラメーターを付けるだけでは不十分で、MCPサーバー側でもトークンの宛先が自分と一致するか検証して、別サービス用トークンの誤用を防ぎます。
サーバー側の最小な入口
以下は認可フロー全体ではなく、401を返すExpress middlewareの例です。
import express from "express";
const app = express();
const RESOURCE = "https://mcp.example.com/mcp";
const METADATA =
"https://mcp.example.com/.well-known/oauth-protected-resource";
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
res.json({
resource: RESOURCE,
authorization_servers: ["https://auth.example.com"],
scopes_supported: ["files:read"],
bearer_methods_supported: ["header"],
});
});
app.use("/mcp", async (req, res, next) => {
const match = /^Bearer (.+)$/i.exec(req.get("authorization") ?? "");
if (!match) {
res
.status(401)
.set("WWW-Authenticate", `Bearer resource_metadata="${METADATA}", scope="files:read"`)
.end();
return;
}
let auth;
try {
auth = await verifyAccessToken(match[1], {
audience: RESOURCE,
});
} catch {
res.status(401)
.set("WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="${METADATA}"`)
.end();
return;
}
// 検証済みの scope を、疑似関数が文字列配列へ正規化して返す契約
if (!auth.scopes.includes("files:read")) {
res.status(403)
.set("WWW-Authenticate", `Bearer error="insufficient_scope", scope="files:read", resource_metadata="${METADATA}"`)
.end();
return;
}
req.auth = auth;
next();
});
verifyAccessTokenは未実装の疑似関数で、Expressの機能ではありません。有効性を検証し、検証済み権限をscopes配列で返す契約を想定しています。無効なトークンは401、有効でもfiles:readがなければ403として処理を分け、どちらの応答にもResource MetadataのURLを示します。実運用では通信障害や内部エラーも無効トークンと区別して扱い、いずれの場合も保護対象へ処理を通しません。MCPハンドラー、待受処理、認可サーバー設定はこの抜粋に含みません。
アクセストークンがJWTの場合は、対応ライブラリで許可するアルゴリズム、公開鍵(JWKS)、issuer、audience、期限を検証します。トークンの中身をdecodeしただけでは検証になりません。JWTではない不透明なトークンの場合は、認可サーバーが提供する検証手段を使います。OAuthだから必ずJWTというわけではありません。
やってはいけないこと
- アクセストークンをURLのqueryへ入れる
- 別API向けトークンを受け入れる
- MCPが受け取ったトークンを下流APIへそのまま渡す
- ログへBearer tokenを出す
- public clientへ固定client secretを埋め込む
audienceやresourceを確認しない- scopeをUI表示だけで判定し、各操作で認可しない
下流APIへアクセスするMCPサーバーは、下流用の別トークンを正規のフローで取得します。トークンのパススルーは権限境界を壊します。
401と403
資格情報がない、無効、期限切れなら401が基本です。トークンは有効だが操作権限が不足する場合は、OAuthのエラー応答とアプリの情報漏えい方針を考慮して403を使います。存在確認そのものが秘密の場合は、詳細を返しすぎません。
動作確認
- tokenなしで401とResource Metadata URLを返す
- metadataの
resourceが実際のMCP URIと一致する - 期限切れ、署名不正、issuer不正、audience不正を拒否する
- 読取scopeで書込Toolを実行できない
- tokenがアクセスログ、例外、分析基盤へ残らない
- 認可サーバー停止時に保護を解除しない
- HTTPSで提供し、localhost以外の平文HTTPを許可しない
トラブル対処
- 認可サーバーを発見できない:401のresource_metadataとwell-known URIを確認します。
- tokenが401:署名だけでなくissuer、audience、期限、対象resourceを個別に確認します。
- 403が続く:tokenをログへ出さず、要求scopeとTool権限の対応表を確認します。
まとめ
MCP OAuthの出発点はログイン画面ではなく、リソースサーバーと認可サーバーの役割分離です。RFC 9728 metadataで発行元を発見し、resourceで宛先を固定し、MCPサーバーでトークンと各操作の権限を検証します。

