こんにちは。エンジニアの山下です。
LLM に対する機能追加のためのプロトコルとして Anthropic が定義した MCP が注目を集めています。MCP を使用することで個々のモデルに依存することなく LLM に対する追加機能の開発を行うことが可能になりました。
MCP の仕様は 2024 年 11 月 5 日に初版が公開されましたが、2025 年 3 月 26 日に最新版が公開され、いくつかの機能についての記述が追加されています。今回はその中から認証機能について取り上げたいと思います。
MCP に対して適切な権限制御を行いたい状況はそこそこあり、その筆頭がリソース参照系の機能です。例えばファイルサーバに置いてあるドキュメントを LLM に参照させたい場合、ユーザプロファイルに紐づくファイルの参照権限を利用したいと考えるのは自然でしょう。このように考えると MCP に対する認証にはかなりの需要がありそうに思えます。
というわけで、今回は MCP で現状利用可能な認証方法とその実装について書きたいと思います。
前提知識
認証の話に入る前に MCP にはローカルとリモートの2種類の実行方式があるということを理解しておいた方がよいので、まずはそちらについて説明しておきます。
とはいえ話は簡単で、ローカルとはローカルマシン上のプロセスとして MCP サーバを立てる方式で、リモートとはインターネット上にリモートサーバとして MCP サーバを立てる方式のことです。要するに MCP をインターネット経由で利用するかそうでないかの違いです。
ローカル実行というと開発中に行う確認作業のイメージが強いですが、MCP の文脈では今のところローカル方式でのリリースが主流です。例えば GitHub MCP Server は比較的有名な MCP サーバですが、ローカルでの実行が前提になっています。
最新仕様以前の認証
MCP の仕様に認証機能についての記述が追加されたのは 2025 年 3 月 26 日ですが、それ以前にも認証を要する MCP サーバは存在しました。先ほど例に挙げた GitHub MCP Server もその一例です。これらの MCP サーバは環境変数にクレデンシャルを設定することで認証を行います。
例えば GitHub MCP Server では GITHUB_PERSONAL_ACCESS_TOKEN
に Personal Access Token (PAT) を設定することで認証を行います。
{ "mcp": { "inputs": [{ ... }], "servers": { "github": { ..., "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "..." } } } } }
PAT にはユーザごとの権限の情報が含まれているので、これでユーザが保有する権限の下で機能実行が行えるわけです。環境変数への設定による認証では、PAT 以外にも Client Credentials Flow などに対応することができます。
この方法はシンプルではありますが、以下に挙げるようにいくつか弱点もあります。
例えば GitHub では PAT を利用してユーザ単位での権限制御を実現していますが、全ての IDP が PAT の払い出しをサポートしているわけではありません。むしろ現状では PAT をサポートしていない方が主流です。IDP が PAT の払い出しをサポートしていない場合、MCP には単一のクレデンシャルを設定する他なく、当然ながらユーザ単位での権限制御はできません。
また、当然ですが、PAT はあくまで個人用に発行されたトークンであり、基本的にリモートサーバのクレデンシャルとして利用すべきではありません。従って、何らかの理由で MCP のリモート実行が前提となる場合、この方法によるユーザ単位での権限制御はやはりできないことになります。
最新仕様での認証
そういった事情を汲んだのか、MCP の仕様改訂によって OAuth 2.1 への準拠が明記されるようになりました。MCP の仕様の こちら に記載されています。
仕様には以下の認証フローへの準拠が明記されています。
- Authorization Code Flow
- Client Credentials Flow
Authorization Code Flow への準拠が明記されたことで、MCP 利用時にユーザにログイン要求を行うことができるようになり、個々のユーザの権限に応じた機能提供ができるようになるというわけです。準拠への要請度は SHOULD なので、絶対にサポートされるという保証はありませんが、認証機能の根幹を成す部分なので概ねサポートされると考えてよいでしょう。
ただし、普段使用してる OAuth の用法とは異なる MCP 固有の仕様もあるので注意が必要です。最低限以下の2点を押さえておく必要があります。
- 認証要求時に 401 Unauthorized が返却される
- Dynamic Client Registration への準拠が推奨されている
逆にこれらの点だけ押さえておけば後は概ね OAuth による認証と同じなので、これらについて簡単に説明しておきます。
認証要求時に 401 Unauthorized が返却される
一般的に Web で OAuth の認証を行う場合、リソースサーバへのリクエスト発行時に 302 Found が返却され、これによりリダイレクトが行われます。しかし、MCP における認証フローでは 302 Found の代わりに 401 Unauthorized が返却されます。つまりリダイレクトによる認証サーバの URL の引き渡しが行えません。
ではどうするかというと、以下のいずれかに従う約束になっています。
つまり、クライアントはメタデータ取得用のエンドポイントをコールし、レスポンスの中に認証サーバの URL が含まれていればそこに、そうでなければ MCP サーバ自身に対して OAuth による認証要求をするということになっています。
後者の場合は MCP サーバが認証サーバも兼ねることになるわけですが、この仕様のために MCP TypeScript SDK には MCP サーバに OAuth エンドポイントをプロキシさせるための関数群まで用意されています。ちなみに MCP Python SDK も覗いてみたのですが、そちらには現時点では認証機能の実装自体がないようなのでご注意ください。
参考までに各種エンドポイントのパスは以下のように定義されています。
- メタデータ取得用エンドポイント(上記 1 の場合)
/.well-known/oauth-authorization-server
- OAuth エンドポイント(上記 2 の場合)
/authorize
/token
/register
Dynamic Client Registration への準拠が推奨されている
Web の文脈における OAuth の使い方では、OAuth クライアントの登録を事前に済ませておき、認証エンドポイントを呼ぶタイミングで Client ID によって作成済みのクライアントを指定するという流れが一般的です。
しかし、OAuth には Dynamic Client Registration (DCR) という仕組みが用意されており、OAuth クライアントの事前登録がシステムの性質に適合しない場合、OAuth クライアントを動的に登録できるようになっています。
MCP ではこの DCR への準拠が推奨されており、推奨レベルは SHOULD です。従って DCR をサポートしない実装という選択肢はあり得ますが、実際には MCP が公式に開発している MCP Inspector は DCR をサポートしていないサーバに対して認証を打ち切るように振る舞う点から見るに、準拠への圧力はそこそこ高そうです。
ただし、Microsoft Entra や Amazon Cognito などの IDP は現時点では DCR をサポートしておらず、このあたりは過渡期特有の混沌という感じがします。
MCP の OAuth への準拠の注意事項
目ざとい方は既に見抜かれているかもしれませんが、MCP が準拠を表明しているのは OAuth 2.1 で、かつ OAuth 2.1 は現在ドラフトです。つまり仕様策定中であって現在公開されている情報は変更される可能性があります。策定中の仕様は こちら で確認できます。
とはいえ、OAuth 2.1 での変更点の中で Authorization Code Flow に関わるものは現状以下の変更のみです。
- Token エンドポイントを呼び出す際に
redirect_uri
の指定が不要になる
redirect_uri
が削除された理由については こちら に書かれています。ざっくり述べると、元々は認可コードの詐取を防ぐために追加されていましたが、PKCE が標準適用されるようになったことでこの問題は解消され、redirect_uri
はその役目を終えたと判断され削除されたようです。
OAuth 2.0 では Token エンドポイントを呼び出す際に redirect_uri
の指定が必須です。従って、OAuth 2.1 にのみ準拠するクライアントは OAuth 2.0 サーバで認証を受けようとすると必須パラメータ不足で認証に失敗することになります。一方で OAuth 2.0 クライアントは PKCE の適用さえされていれば OAuth 2.1 サーバで問題なく認証を受けられます。
現状 OAuth 2.1 はドラフトのため、当然ながら各社 IDP は OAuth 2.0 のサポートに留まっています。そのため、現時点で MCP の OAuth 認証を行いたい場合、クライアントは OAuth 2.0 に準拠するように実装する必要があります。ドラフトへの準拠を表明した結果、色々とややこしいことになっていますね ... 。
MCP TypeScript SDK で実装してみる
とはいえ、クライアント側が OAuth 2.0 準拠であればサーバ側の OAuth のバージョンに依らず認証は動作するはずです。MCP TypeScript SDK には既に認証機能のための関数群が実装されているので、認証付きの MCP サーバを実装して動作確認してみることにしましょう。
MCP 自体には認証機能はないので、動作確認のためには別途 IDP が必要です。今回は IDP に Auth0 を使用します。Auth0 は DCR に対応しているので今回のテストにはうってつけです。同様に MCP クライアントも必要ですが、今回は MCP Inspector を使用します。念のため確認しましたが、MCP Inspector は OAuth 2.0 準拠になっており、Token エンドポイントを呼び出す際に redirect_uri
を含めてくれるようになっていました。
Auth0 の設定
DCR を Auth0 で動かすためにはいくつか設定が必要なため、実装の前に済ませておきましょう。Auth0 テナントの Settings > Advanced にある以下の2つの設定を有効化します。
前者は DCR 自体を有効化するための設定で、後者は DCR で作成されたクライアントに Auth0 の Connection を自動でアサインするための設定です。後者の設定が有効でないと Connection がないというエラーが発生して認証ができない状態になるため注意が必要です。
また DCR で作成されたクライアントは Third-Party として扱われますが、Auth0 では Third-Party 扱いのクライアントには Domain Level Connection しか接続できないという制約があります。よって、以下のドキュメントに従って必要な Connection を Domain Level に設定しておく必要があります。
単に Update a Connection API をコールして is_domain_connection
を True に設定すれば OK です。
実装方針
環境の準備は済んだので、あとは実装していくだけです。
MCP TypeScript SDK の GitHub の README には、ご丁寧に OAuth エンドポイントを MCP でプロキシするためのサンプル実装が掲載されています。従って、ナイーブに認証機能を実装しようとした場合、このサンプルに釣られて OAuth エンドポイントのプロキシ用の関数群を使うことになりがちですが、これは罠です。これらの関数群は実装があまりにもお粗末なので使用は絶対にオススメできません。
具体的には以下のような意味不明な実装上の制約があります。
- OAuth 2.1 で未定義のパラメータがプロキシ先に渡されない
つまり、Token エンドポイントに redirect_uri
が渡されないのはもちろんのこと、一切拡張されていない OAuth 2.1 にのみ準拠した IDP でないと認証が通らないという状態になっています。当然ながらそのような IDP は現時点では存在しないため、端的に述べると使い物になりません。
というわけで、使えない関数群はそっとしておき、今回は MCP のメタデータで認証先の Auth0 の URL を提供する方針で実装していきます。
実装
MCP SDK の認証機能周辺はドキュメントがないも同然の状態で、SDK のソースコードを読みながら実装していくしかなく、割と苦労が多いというのが現状です。
筆者が調べた結果、MCP でのメタデータ提供のために metadataHandler
という関数が用意されているようなので、今回はこちらを利用します。
以下が実装したコードです。
import express, { Request, Response, RequestHandler } from "express" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js" import { InvalidTokenError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js' import { metadataHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/metadata.js' import { z } from "zod" const ISSUER = "https://your-tenant.jp.auth0.com/" // MCP Server const server = new McpServer({ name: "Test MCP Server", version: "1.0.0", }) server.tool("add", { a: z.number(), b: z.number() }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }) ) // Auth Settings const mcpMetadataRouter = (): RequestHandler => { const router = express.Router() router.use("/.well-known/oauth-authorization-server", metadataHandler({ issuer: ISSUER, authorization_endpoint: new URL("/authorize", ISSUER).href, token_endpoint: new URL("/oauth/token", ISSUER).href, registration_endpoint: new URL("/oidc/register", ISSUER).href, response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["client_secret_post"], grant_types_supported: ["authorization_code", "refresh_token"], })) return router } const requireAuth = (): RequestHandler => { return async (req, res, next) => { try { const header = req.headers.authorization; if (!header) { throw new InvalidTokenError("Missing Authorization header"); } const [type, token] = header.split(' '); if (type.toLowerCase() !== 'bearer' || !token) { throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } // TODO: add validation here if you need to verify access token next() } catch (error) { if (error instanceof InvalidTokenError) { res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); res.status(401).json(error.toResponseObject()); } else { console.error("Unexpected error authenticating bearer token:", error); res.status(500).json(new ServerError("Internal Server Error").toResponseObject()) } } } } const transports: {[sessionId: string]: SSEServerTransport} = {} const app = express() app.use(mcpMetadataRouter()) app.get("/sse", requireAuth(), async (_: Request, res: Response) => { const transport = new SSEServerTransport('/messages', res) transports[transport.sessionId] = transport res.on("close", () => { delete transports[transport.sessionId] }) await server.connect(transport) }) app.post("/messages", requireAuth(), async (req: Request, res: Response) => { const sessionId = req.query.sessionId as string const transport = transports[sessionId] if (transport) { await transport.handlePostMessage(req, res) } else { res.status(400).send('No transport found for sessionId') } }) app.listen(3001) console.log('Server is running on port 3001')
mcpMetadataRouter
でメタデータ取得エンドポイントを提供して認証先となる Auth0 の URL を提供しています。これで MCP クライアントが認証先を知ることができるようになります。
なお、上記コードではトークンの検証を行わず TODO コメントを残すに留めていますが、これは Auth0 が持つトークンの制約に起因します。具体的には /authorize
を呼び出す際に audience
パラメータを付与しなかった場合、Auth0 は不透明なトークンを返却するため、リソースサーバ側でのトークンの検証ができなくなります。今回は MCP Inspector がクライアントなので、追加のパラメータ付与を指示することができず諦めました。なお、audience
を付与した場合はトークンとして JWT が返却されるため問題なく検証可能です。
MCP SDK の認証機能周辺は実装とドキュメントの両面で色々と未成熟な感があり、上記のコードに到達して MCP の認証を通すまでに結構な時間がかかってしまいました。この実装が皆様の参考になれば幸いです。
まとめ
今回のポイントをおさらいすると以下のようになりそうです。
- MCP の認証は以下のいずれかで行う形になる
- 環境変数へのクレデンシャルの差し込み
- OAuth 2.1 による認証
- MCP の OAuth は以下の点が通常の Web 認証と異なる
- リダイレクトではなくメタデータ取得によって認証先を決める
- IDP に DCR への準拠が推奨される
- MCP SDK は未成熟で苦労が多い
- 特に OAuth プロキシ機能は絶対に使うべきではない
OAuth への準拠ということでもっと簡単にできるかと思いましたが、実際に取り組んでみると実装面での苦労がかなり多かったです。苦労の要因の大部分が MCP の公式 SDK の品質であるというのがよくわかりませんが、インフラの成熟を待てばこの点は解消されると思います。淘汰されてくれ。
とはいえ、MCP に認証機能を提供したいという需要がある点については間違いないので、早いうちに実現方法を確立できたのはよかったように思います。この記事がエンジニアの方々の労を除く一助になれば幸いです。
以上です。最後までお読みいただき、ありがとうございました。