廣瀬製紙株式会社

Employees' Blog 社員ブログ

その扉を開けるのは、誰の仕事か
連載「ReactでMCPクライアントを作る」第6回(最終回) — 認可の設計、legacy/modernの互換判定、そして生成SQL実行という問い

公開日: 2026.08.07 更新日: 2026.08.07
いくつも並んだ扉のうち、合図が灯る扉と、何の合図もなく閉ざされた扉が対比され、「扉は、開け方を語るか」という文字が入ったヒーロー挿絵

導入 — 六回目の扉

全6回の連載「ReactでMCPクライアントを作る」も、この記事で最後になります。第1回では、ホスト・クライアント・サーバーという三者の関係と、legacy(2025-11-25以前、initializeでセッションを張る時代)とmodern(2026-07-28以降、リクエストごとに名乗る時代)という二つの時代を確認しました。第2回はStreamable HTTPの実際のヘッダーとSSEの中身を、第3回はサーバー側のツールとリソースの設計を、第4回はReact側のホスト実装を、第5回は待たせる処理の扱いを、それぞれ掘り下げています。

最終回であるこの記事は、三つの柱で構成します。ひとつ目は、この連載を通してずっと後回しにしてきた認可の設計です。二つ目は、legacyとmodernが混在する現実の中で、クライアントが相手をどちらの時代だと見分けるかという判定の話です。

三つ目は少し毛色を変えて、このアプリそのものへの問いです。LLMが組み立てたSQLを、そのまま実行してしまってよいのでしょうか。

作ってきたアプリを思い出してください。ユーザーの自然言語の問い合わせをLLMがSQLに変換し、MCPサーバーが公開する「SQLを実行するTool」を呼び出して、結果を表にするというアプリでした。ここまでの回は、この窓口が「誰からのリクエストでも受け付ける」という前提で組み立ててきました。今回はその前提を外し、窓口の手前に「あなたは誰ですか」と尋ねる扉を置きます。

認可の登場人物 — 前提知識

認可の話に入る前に、この回で新しく登場する役割を整理します。第1回で見たホスト・クライアント・サーバーという三者に加えて、認可の文脈ではさらに二つの役割が登場します。

役割仕様上の定義このアプリでいうと
リソースサーバー(Resource Server)アクセストークンを検証し、保護されたリクエストに応答するサーバーデータベースへの窓口であるMCPサーバー自身
認可サーバー(Authorization Server)ユーザーとやり取りし、アクセストークンを発行するサーバーMCPの外側にあるサービス(今回は実物に接続していません)

仕様は、保護されたMCPサーバーの立場をこう定めています。

A protected MCP server acts as an OAuth 2.1 resource server, capable of accepting and responding to protected resource requests using access tokens.

日本語にすると、「保護されたMCPサーバーはOAuth 2.1のリソースサーバーとして振る舞い、アクセストークンを使った保護リソースリクエストを受け付け、応答する」という意味です。MCPクライアントの側はOAuth 2.1のクライアントとして振る舞います。

もう一つ最初に押さえておきたいのが、認可はMCPの必須要件ではないという点です。

Authorization is OPTIONAL for MCP implementations. When supported: Implementations using an HTTP-based transport SHOULD conform to this specification. Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment.

「認可はMCPの実装にとってOPTIONALである。対応する場合、HTTP系トランスポートを使う実装はこの仕様に従うべきで、stdioトランスポートを使う実装はこの仕様に従うべきではなく、代わりに環境から資格情報を取得するべきである」という規定です。stdio(クライアントが起動したサブプロセスの標準入出力を使う方式、第1回参照)はプロセスを起動できる時点である程度信頼された環境なので、OSの環境変数やキーチェーンから資格情報を取れば足ります。今回扱うのは、データベースの窓口をネットワーク越しに公開する場合、つまりHTTP系トランスポートのほうです。

柱1: 認可 — 401から辿る入口

検証したのは作り物の検証器です

ここから先の実測は、SDK同梱の認可ミドルウェア(requireBearerAuthと、保護リソースメタデータを返すmetadataHandler)を使ってMCPサーバーをOAuth 2.1のリソースサーバーとして立て、有効・スコープ不足・期限切れの3種類のトークンを手作業で用意した、最小構成の検証です。構成はおおむね次の形です。

app.use('/.well-known/oauth-protected-resource', metadataHandler({
  resource: BASE + '/mcp',
  authorization_servers: [BASE + '/as'],
  scopes_supported: ['files:read'],
}));

const auth = requireBearerAuth({
  verifier,
  requiredScopes: ['files:read'],
  resourceMetadataUrl: BASE + '/.well-known/oauth-protected-resource',
});

app.all('/mcp', auth, /* … MCPのハンドラ … */);

トークン検証器(verifier)は最小の作り物で、実在の認可サーバーには一切接続していません。 環境はNode.js v22.12.0、@modelcontextprotocol/sdkのバージョン1.30.0、実行日は2026-08-06です。

401に載るWWW-Authenticateが入口になる

トークンを付けずにMCPサーバーを叩くと、次の応答が返りました。

401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", scope="files:read", resource_metadata="http://127.0.0.1:3801/.well-known/oauth-protected-resource"

{"error":"invalid_token","error_description":"Missing Authorization header"}

WWW-Authenticateヘッダーの中にはerror、error_description、scope、resource_metadataが並んでいます。このうちresource_metadataのURLが、クライアントにとって「次にどこへ行けばいいか」の手がかりになります。実際にこのURLを取得すると、次のJSONが返りました。

200 OK
{"resource":"http://127.0.0.1:3801/mcp","authorization_servers":["http://127.0.0.1:3801/as"],"scopes_supported":["files:read"]}

authorization_serversが、トークンを発行してくれる認可サーバーの場所です。仕様はこのメタデータ形式をRFC 9728(OAuth 2.0 Protected Resource Metadata)として参照し、MCPサーバーにMUSTで実装を求め、クライアントにも認可サーバーを見つける手段としてMUSTで使うことを求めています。ここから先——実際の認可サーバーへ辿り着いて、認可コードフローやPKCE、Dynamic Client Registrationを進める部分は、今回検証していません。 作り物の検証器はauthorization_serversに値を返すだけで、その先に本物の認可サーバーは立っていないためです。

MCPクライアントが送ったリクエストに対してサーバーが「401」を返し、そこに載った「WWW-Authenticate」ヘッダーから「resource_metadata」を辿って「authorization_servers」を発見するまでの経路を示す図
図 1: 401 に載る WWW-Authenticate が、認可サーバーを見つけるための入口になる

401と403の使い分け

仕様はエラーコードの使い分けを次のように定めています。

ステータス意味使いどころ
401Unauthorized認可が必要、またはトークンが不正
403Forbiddenスコープ不足・権限不足
400Bad Request不正な形式のリクエスト

実測でも、この分かれ方は仕様どおりでした。期限切れのトークン、スコープが足りないトークン、有効なトークンをそれぞれ送った結果です。

401  WWW-Authenticate: Bearer error="invalid_token", error_description="Token has expired", scope="files:read", resource_metadata="http://127.0.0.1:3801/.well-known/oauth-protected-resource"
403  WWW-Authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope", scope="files:read", resource_metadata="http://127.0.0.1:3801/.well-known/oauth-protected-resource"
200  event: message
     data: {"result":{"content":[{"type":"text","text":"hello"}]},"jsonrpc":"2.0","id":1}

403のほうにはscope="files:read"という必要なスコープが載っており、クライアントはこれを見て「何を足せば通るか」を判断できます。401は「あなたが誰なのか分からない」、403は「あなたが誰かは分かったが、その権限が足りない」という区別です。

検証器が投げる例外の型ひとつで、入口が消える

ここが、この回でいちばん実務的に効く落とし穴です。同じ「認識できないトークン」を渡しても、検証器のコードの書き方によって応答がまったく変わります。検証器がInvalidTokenErrorを投げれば、次のように仕様どおりの401になります。

401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Token not recognised", scope="files:read", resource_metadata="http://127.0.0.1:3801/.well-known/oauth-protected-resource"

{"error":"invalid_token","error_description":"Token not recognised"}

ところが検証器が素のErrorを投げると、次のように500になります。

500 Internal Server Error

{"error":"server_error","error_description":"Internal Server Error"}

WWW-Authenticateヘッダーが付いていません。仕様は不正なトークンに対して401をMUSTで要求していますが、この応答は仕様から外れています。そしてクライアント側から見ると、401なら「認可が必要らしい」と分かりますが、500ではそれすら分かりません。

WWW-Authenticateという道しるべが消え、認可フローへ入る入口そのものが見えなくなります。これはMCPのミドルウェアの落ち度ではなく、検証器を書く側がどの例外の型を投げるかという選択によって起きています。認可の仕組みをSDKに任せたつもりでも、トークンを検証するコード自体は結局アプリケーション側が書くことになり、その一行の書き方次第で401にも500にもなる、という具体例です。

「401」と「403」の扉には「WWW-Authenticate」という道しるべが灯っている一方、「500」の扉だけ何の合図もなく閉ざされている様子を示す図
図 2: 401 と 403 には道しるべが付くが、500 には何も残らない

トークンはヘッダのみ、クエリ文字列には乗せない

仕様はアクセストークンの運び方も限定しています。

MCP client MUST use the Authorization request header field… Access tokens MUST NOT be included in the URI query string

「MCPクライアントはAuthorizationリクエストヘッダーフィールドを使わなければならない……アクセストークンはURIのクエリ文字列に含めてはならない」という意味です。実際にクエリ文字列へトークンを付けて送ってみると、次のようにヘッダーが無いのと同じ扱いになりました。

POST /mcp?access_token=good-token

401 Unauthorized
{"error":"invalid_token","error_description":"Missing Authorization header"}

実装はヘッダーしか見ていないため、クエリ文字列に有効なトークンを付けても401のままです。仕様の禁止と同じ向きに、実装も動いています。

audience検証は仕様のMUSTですが、実証していません

仕様は、サーバーがトークンを「自分向けに発行されたものかどうか」——audience——を検証しなければならないと定めています。

MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2… MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.

「MCPサーバーは、RFC 8707 Section 2に従い、アクセストークンが自分を意図した対象者として発行されたことを検証しなければならない……MCPサーバーは自分のリソース用に有効なトークンだけを受け入れなければならない。MCPサーバーはそれ以外のトークンを受け取ったり、素通ししたりしてはならない」という意味です。これはOAuth 2.1の要件の中でもとりわけ重要なものですが、今回の検証では実証していません。

使ったトークン検証器は定数表を引いているだけの作り物で、audienceのクレームを見て判定する仕組みそのものを持っていないためです。同じ理由で、次の項目も今回はまったく検証しておらず、仕様の記述として引用するにとどめます。

  • RFC 8707が定めるresourceパラメータが、認可リクエストとトークンリクエストに正しく載るか
  • PKCE(認可コード横取り対策)の実際の往復
  • 認可サーバーのメタデータ探索、issの検証(RFC 9207)
  • Dynamic Client Registration(RFC 7591)
  • リフレッシュトークンの取得と更新
  • スコープが足りないときの段階的な再認可(ステップアップ認可)
  • 混同代理(confused deputy)攻撃やトークン転送に対する防御

柱2: legacyとmodernの混在 — 「動いた」をどう判定するか

互換性マトリクスを思い出す

第1回で、legacyとmodernという二つの時代があり、公式SDKの最新版はまだlegacyの枠組みでしか動かないことを確認しました。仕様の互換性マトリクス(Compatibility Matrix)は、modernなクライアントがlegacyなサーバーに接続すると失敗すると述べたうえで、その失敗の形についても踏み込んで書いています。

Modern | Legacy | Fails. The server may reject the request with an implementation-defined error, stay silent, or even process an era-ambiguous method under legacy semantics.

「modernなクライアントがlegacyなサーバーに接続すると失敗する。サーバーは実装依存のエラーでリクエストを拒否するかもしれないし、無言のままかもしれないし、時代があいまいなメソッドをlegacyの意味づけで処理してしまうことさえある」という意味です。つまり「失敗する」とひとことで言っても、その中身は実装によってばらつく、というのが仕様自身の但し書きです。

エラー判定は「400が来たか」ではなく「本文が既知のmodernエラーか」

では、両対応(dual-era)のクライアントは、相手がどちらの時代かをどう見分けるのでしょうか。仕様はHTTPでの見分け方を次のように定めています。

Streamable HTTP: attempt a modern request and inspect the body of a 400 Bad Request before falling back. … a recognized modern JSON-RPC error (such as UnsupportedProtocolVersionError) identifies a modern server: the client retries with a supported version rather than falling back. Anything else identifies a legacy server.

「Streamable HTTPでは、modern風のリクエストを試し、フォールバックする前に400 Bad Requestの本文を検査する。……認識できるmodernのJSON-RPCエラー(UnsupportedProtocolVersionErrorなど)であればmodernサーバーだと分かり、クライアントはフォールバックせず対応バージョンで再試行する。それ以外はlegacyサーバーだと判定する」という意味です。つまり判定条件は「400が返ってきたかどうか」ではなく、「400の本文が、仕様が定めるmodernのエラー語彙(コード-32022のUnsupportedProtocolVersionErrorなど)に一致するかどうか」です。

第1回・第2回で使ったのと同じSDKへ、modern風のリクエスト(MCP-Protocol-Version: 2026-07-28を含む)を送ると、次の400が返ります。

400 Bad Request
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"},"id":null}

エラーコードは-32000で、仕様が定める-32022ではありません。 仕様の判定基準に当てはめると「既知のmodernエラーではない」ため、両対応のクライアントはこれをlegacyサーバーと判定して、initializeによるハンドシェイクへフォールバックすることになります。実際にフォールバックしてみると、次のように通ります。

POST(initialize, protocolVersion: 2025-11-25)
200 OK
event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"sandbox-server","version":"0.0.1"}},"jsonrpc":"2.0","id":9}
サーバーから返った「400」の本文を読み、「-32022」なら「modern」、「-32000」のような見慣れないコードなら「legacy」と判定して枝分かれする図
図 3: 400 の本文を読んで、modern か legacy かを決める

server/discoverの不在も判定材料になる

modernが必須とするメソッドserver/discoverを同じサーバーへ投げてみると、次のように返ってきました。

{"jsonrpc":"2.0","id":10,"error":{"code":-32601,"message":"Method not found"}}

-32601 Method not foundです。仕様はstdioでの見分け方として、server/discoverを先に叩いて既知のmodernエラー以外が返ったらlegacyとみなす、という手順を定めています。今回HTTP越しに同じメソッドを試した結果も、同じ材料——「modernが必須とするメソッドが実装されていない」——を示しており、legacy判定を裏づける形になりました。

柱3: 生成したSQLを、そのまま実行してよいか

ここからは実測ではなく、仕様の記述と一般論として書きます。実データベースには接続しておらず、この節に実測はありません。

ツールは任意コード実行である

仕様の「Security and Trust & Safety」は、Toolという機能をこう位置づけています。

Tools represent arbitrary code execution and must be treated with appropriate caution. In particular, descriptions of tool behavior such as annotations should be considered untrusted, unless obtained from a trusted server. Hosts must obtain explicit user consent before invoking any tool. Users should understand what each tool does before authorizing its use.

「Toolは任意コード実行を表しており、適切な注意をもって扱わなければならない。とりわけ、アノテーションのようなツールの挙動の説明は、信頼できるサーバーから得たのでない限り、信頼できないものとして扱うべきである。ホストはいかなるToolを呼び出す前にも、明示的なユーザーの同意を得なければならない。ユーザーは、その使用を許可する前に、各ツールが何をするのかを理解しているべきである」という意味です。

作ってきたアプリに当てはめてみましょう。LLMが組み立てたSQLは、MCPサーバーが公開する「SQLを実行するTool」への引数として渡り、そのTool呼び出しはデータベースに対する任意コード実行そのものです。仕様が言う「ホストは呼び出し前に明示的な同意を得なければならない」を素直に読めば、ホストはこのTool呼び出しのたびに、ユーザーへ「このSQLを実行してよいですか」と確認を求めるべきだ、ということになります。

ツールの説明やアノテーションは信用できない

仕様が名指しで警告しているのが「ツールのアノテーション」です。MCPのTool定義には、その操作が読み取り専用かどうかや破壊的かどうかを申告するヒントを持たせられますが、仕様は「信頼できるサーバーから得たのでない限り、そうした申告は信頼できないものとして扱うべきだ」と明記しています。このアプリのSQL実行Toolが「これは読み取り専用です」と自己申告していたとしても、その申告をホストが実行判断にそのまま使ってよいかどうかは、Toolを提供するサーバーをどれだけ信頼しているか次第だ、ということです。

読者への問い

この節は結論を出す節ではなく、自分の設計を点検するための問いを並べます。

  • 生成されたSQLを実行前にユーザーへ見せているか。見せているとして、それは毎回か、セッションに1回か。
  • 同意を1回だけ取ってキャッシュする設計なら、次にLLMが組み立てるSQLが最初に見せたものと違っていても、同じ同意の範囲として扱ってよいか。
  • Toolの説明やアノテーション(読み取り専用・破壊的といった申告)を、実行の可否判断に使っているか。使っているなら、それはどのサーバーから得た申告か。
  • 生成されたSQLが実際に何をするか(更新なのか削除なのか)を、ホスト側でSQLの文面から判定しているか、それともToolの自己申告だけに頼っているか。
  • 同意を取る単位は「1回のTool呼び出し」か、それとも「1つの自然言語の問い合わせ」か。後者なら、途中でLLMが計画を変えたとき、最初に取った同意はまだ有効だと言えるか。

これらの問いに唯一の正解はありません。仕様が定めているのは「ホストは呼び出し前に明示的な同意を得なければならない」という最低限のMUSTだけで、その同意をどの粒度で、どんなUIで取るかは実装側の設計判断に委ねられています。その設計判断を誰が引き受けるのかを、あらかじめ決めておく価値があります。

LLMが組み立てた「SQL」の紙片がデータベースへ向かう手前に閉じた門があり、「同意」の合図を待っている様子を描いた挿絵
図 4: 生成された SQL は、確認の前に実行されない仕組みになっているか

全6回の地図をもう一度描く

最後に、全6回を振り返ります。

回扱ったこと
第1回ホスト・クライアント・サーバーの三者構成、JSON-RPC 2.0、legacyとmodernという二つの時代
第2回Streamable HTTPの実体——必須ヘッダー、Accept、SSEの生の形、Origin/Host検証
第3回サーバー側の設計——ツールとリソース、inputSchema/outputSchema
第4回React側の実装——ブラウザからの直接アクセス、SSEの読み方、キャンセル
第5回待てない処理——進捗通知、購読、Tasks拡張、そして「キャンセルしても処理は止まらない」
第6回認可の設計、legacyとmodernのデュアルエラ互換、生成SQLをそのまま実行してよいか
「第1回」「第2回」「第3回」「第4回」「第5回」「第6回」という道のりを俯瞰し、最後の「第6回」に旗が立っている連載全体の地図を示す挿絵
図 5: 全6回を通って、たどり着いた場所

この地図を通して、繰り返し現れた性質が四つあります。

仕様と実装のあいだには、いつも時間差がある

第1回で、仕様の現行リビジョンが2026-07-28である一方、公式SDKの最新版はまだ2025-11-25までしか実装していないことを確認しました。この時間差は今回の柱にもそのまま現れています。audience検証は仕様が定める最重要級のMUSTですが、作り物の検証器ではその検証を実証できませんでした。仕様が「こうあるべきだ」と定めた地点と、手元で実際に確かめられる地点のあいだには、いつも距離があります。

プロトコルは、お願いはするが強制はしない

第4回・第5回で、キャンセルが協調的であり、ハンドラが信号を見なければ処理は止まらないことを確認しました。認可についても同じ構造があります。仕様自身が次のように書いています。

While MCP itself cannot enforce these security principles at the protocol level, implementors SHOULD: Build robust consent and authorization flows into their applications…

「MCPそのものはこれらのセキュリティ原則をプロトコルレベルで強制できないが、実装者は、堅牢な同意と認可のフローをアプリケーションに組み込むべきである……」という意味です。ホストがキャンセル信号を無視できてしまうのと同じように、ホストは同意を求めるフローを省略することもできてしまいます。プロトコルが定めるのは形と語彙であり、それを守るかどうかは実装側の判断に委ねられている、という構造がここでも繰り返されています。

失敗が「成功」の顔で返ってくることがある

第2回では、バッチの応答順序が要求順と逆になっても仕様上は正常な挙動であること、GETストリームが200のまま何秒待っても終端しないことを確認しました。第3回では、ツールの失敗がisErrorに倒れ、JSON-RPCエラーにならないことも見ています。今回の500——WWW-Authenticateが付かず、認可が必要だという事実そのものが伝わらない応答——も同じ列に並びます。共通しているのは、応答の「見た目」だけでは、次に何をすべきかが分からない場面があるという点です。

次に何を調べればよいか

この連載で意図的に扱わなかった論点を最後に挙げておきます。実在の認可サーバーとのPKCE付きフロー、audience検証、issの検証、Dynamic Client Registration、そしてmodern era(2026-07-28)に対応した実装が登場したときのHeaderMismatchやserver/discoverの実挙動です。これらはいずれも、今回「仕様文の引用」として紹介したにとどまり、実測はしていません。

もう一つ、この連載全体を通じて確かめなかったのが、自分のホストが生成SQLへの同意をどの粒度で取っているか、という問いです。仕様が答えを与えてくれるのはここまでで、その先は読者それぞれの設計に委ねられています。

まとめ

第1回はMCPという三者構成の地図を描くところから始まり、この最終回は認可・互換性・生成SQLの実行という、実装のいちばん際どいところで終わります。認可では、401に載るWWW-Authenticateが入口になる一方、検証器がどんな例外を投げるかという一行の書き方次第で、その入口ごと500に消えることを実測で確認しました。legacyとmodernの混在では、判定を決めるのが「400が返ったか」ではなく「400の本文が既知のmodernエラーか」だと分かりました。

生成したSQLの実行については、仕様が「明示的な同意」と「アノテーションを信用しない」という最低限のMUSTを定めているだけで、その先の設計は誰かが引き受けなければなりません。この連載が描いたのは、MCPという一つのプロトコルの設計図というより、仕様と実装のあいだに立つ人が、そのつど下してきた判断の記録です。

この連載の記事

第1回から順に読むと、仕様の地図を描くところから、ブラウザで動くコードまでが一本の線でつながります。どの回からでも単体で読めるようには書いていますが、前後を行き来したくなったらここから飛んでください。

参考にした一次情報

この記事を書いた人

情報企画チーム 松村 晶(まつむら あき)

2024年11月廣瀬製紙株式会社入社。

書店・福祉・飲食業などを経験したのち、システム開発畑に転向。

転職をきっかけに生まれの地である高知市に移住し、現在は社内SEとして、社内のDBシステム開発やDX関連のシステム開発を担当している。

この著者の記事を見る →