ブログ • Agent Security

MCP クライアントが OAuth 資格情報を誤った認可サーバーへ渡したあと、JSON ログはどの段階で Tool Call を止めるべきか?

2026 年 9 月 30 日、MCP TypeScript SDK は GHSA-6qxp-vccf-f47h を公開した。深刻度は High、CVSS 7.5 だ。影響を受ける版では、OAuth クライアントが保存済み資格情報を、本来の認可サーバーへ結び付けていなかった。接続中の MCP サーバーは別の認可サーバーを指し示せ、クライアントはユーザー操作なしに資格情報を送っていた。告知のあと残すのは、Tool Call を止める一行の JSON であり、再現できるリクエストではない。

MCP の権限を誰が持つかはMCP の安全の記事。Schema 通過のあとにまだ要る権限の決定は前の記事。本記事は告知のあとのログから書く。credential_check を通らなければ、そのあとの Tool Call はあってはならない。

結論:issuer が一致しなければ credential_check で止める。decision は deny、credentialsSent は false、そのあとに Tool Call は書かない。

告知のあと、ログの一行目には何を書くか?

影響を受けるのは、SDK を HTTP 上の OAuth クライアントとして使い、完全には信頼しない MCP サーバーへ繋がる可能性があり、正当な認可サーバーの資格情報を持っているアプリだ。入口はトランスポート上の authProvider、withOAuth ミドルウェア、auth または fetchToken の直接呼び出しだ。この SDK で作った MCP サーバー自体と、stdio クライアントは対象外だ。1.x は 1.12.0 から 1.30.1 までで、修正は 1.31.0。2.x の client は 2.0.0 から 2.1.0 までで、expectedIssuer のない同梱 provider、issuer のない保存資格情報、fetchToken の直接呼び出し、または OAuthTokensSchema と OAuthClientInformationSchema から保存を読み戻す場合だけが対象で、修正は 2.2.0。core を直接 import するなら、それも 2.2.0 にする。原文はGHSA-6qxp-vccf-f47h。

issuer が一致しないときに残す停止。二つのアドレスはプレースホルダーで、接続先ではない。
{
  "step": "credential_check",
  "savedIssuer": "https://auth.example.com",
  "namedAuthorizationServer": "https://other.example.net",
  "issuerMatch": false,
  "credentialsSent": false,
  "decision": "deny",
  "reason": "issuer_mismatch"
}

この一行は停止だけを記録する。savedIssuer は資格情報が本来属する認可サーバーだ。namedAuthorizationServer は、今回 MCP サーバーが指し示した方だ。二つの文字列が違えば issuerMatch は false。decision は deny、credentialsSent は false。欄に token も client secret も置かない。脆弱性情報では同じ問題が CVE-2026-104850 とされている。本記事は GitHub の告知にある版と修正に従う。

この JSON の形は、どの Schema で止めるか?

JSON Schema で列挙と必須項目を止める。これが credential_check であることを証明する。decision は allow、deny、ask だけ。reason は issuer_mismatch、issuer_match、awaiting_user だけ。今回止めるべきことまでは証明しない。

停止の形。issuerMatch が false で decision が allow でも、この列挙は通る。
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["step", "savedIssuer", "namedAuthorizationServer", "issuerMatch", "credentialsSent", "decision", "reason"],
  "properties": {
    "step": { "enum": ["credential_check"] },
    "savedIssuer": { "type": "string" },
    "namedAuthorizationServer": { "type": "string" },
    "issuerMatch": { "type": "boolean" },
    "credentialsSent": { "type": "boolean" },
    "decision": { "enum": ["allow", "deny", "ask"] },
    "reason": { "enum": ["issuer_mismatch", "issuer_match", "awaiting_user"] }
  }
}

形の通過は、項目が列挙の中にあることだけを意味する。issuerMatch が false、credentialsSent が true、decision が allow でも形は合法だ。送信を止めるのは次の節のアプリ規則で、この形の確認ではない。

形が通っても、まだ止める規則はどの三つか?

告知の修正はこうだ。クライアントは認可サーバーを資格情報の issuer として記録し、別のサーバーへは送らない。ユーザーは再ログインするか、その呼び出しが失敗する。比較はモデルの外で行う。モデルがあとから何を言っても、すでに deny と記録した段階を送信へは変えられない。

記録に見えるものSchemaアプリの規則
issuerMatch は false だが credentialsSent は true通る。true は真偽値だ。false に戻す。refresh_token も client_secret も署名済みアサーションも送らない。
issuerMatch は false だが decision は allow通る。allow は列挙にある。deny に変え、reason は issuer_mismatch のままにする。
credential_check はすでに deny なのに、後ろに tool_call の一行がある一行の Schema は次の行を見ない。step を tool_call にすると、この記録自体が失敗する。ログは credential_check で終わる。Tool Call は足さない。issuer がすでに一致している別の呼び出しだけ、別に権限の決定を書く。

版を上げたあと、まだ手で書くのはどの三箇所か?

1.x は @modelcontextprotocol/sdk 1.31.0 以上にする。2.x は @modelcontextprotocol/client 2.2.0 以上にする。パッケージを上げただけでは、下の三箇所は終わらない。根拠はやはり同じ告知。

まだやること告知の要求
同梱 providerClientCredentialsProvider、PrivateKeyJwtProvider、StaticPrivateKeyJwtProvider、CrossAppAccessProvider には expectedIssuer を渡す。例は https://auth.example.com。書かなければ、それらは MCP サーバーが指し示した認可サーバーをまだ使う。
issuer のない古い保存更新前にファイル、キーチェーン、データベースへ保存した token とクライアント情報は、最初の使用時に、そのとき指定された認可サーバーへまだ送られる。issuer を補うか、消してユーザーに再ログインさせる。自作の OAuthClientProvider は、saveTokens と saveClientInformation が渡す内容を issuer も含めてそのまま保存する。事前登録の資格情報は、clientInformation の戻り値に issuer を入れる。
新しい対話ログインこの修正は新しい対話ログインを覆わない。それは MCP サーバーが指し示した認可サーバーへまだ向かう。信頼するサーバーに対してだけログインを完了する。クライアントが信頼しない MCP サーバーへ繋がった可能性があるなら、client secret または署名鍵をローテーションし、token を失効させる。まだ更新できないときは、OAuth クライアントを信頼する MCP サーバーだけへ接続する。2.0.0 と 2.1.0 はすでに expectedIssuer を受け取る。

refreshAuthorization または exchangeAuthorization の直接呼び出しも、この修正の外だ。2.x で skipIssuerMetadataValidation を true にした場合も外だ。どちらも、資格情報を送るかどうかをモデルに判断させない。

既存の二本と何が違うか?

なぜ Schema が要るかMCP の安全本記事
答えること出力になぜ形が要るか権限を誰が守るかissuer が一致しないとき、ログのどの段階で Tool Call を止めるか
書かないこと今回の OAuth 告知の停止は書かない告知のあとのログ順は書かない再現できるリクエストは書かず、Schema が十分かどうかも繰り返さない

JSONNote でこの停止をどう確認するか?

  1. 1
    操作を選ぶ

    JSON Schema を開く。操作する を Schemaで確認してください にする。1つ目の欄に上の停止、2つ目の欄に Schema を貼る。step は credential_check だけ。decision は allow、deny、ask だけ。

  2. 2
    壊れたデータを四つぶつける

    decision を yes にする、credentialsSent を文字列 false にする、decision を消す、または step を tool_call にする。この四つは失敗する。上の deny の記録は通る。通過は形が正しいことだけを意味する。

  3. 3
    deny と、形は合法な allow を比べる

    JSON 比較Diff。1つ目の欄に deny の停止を貼る。2つ目の欄は credentialsSent を true、decision を allow にしただけにする。reason は issuer_mismatch のまま。両方とも Schema は通る。送信を止めるのはアプリの規則だ。issuer が一致しなければ deny であり、何も送らない。

よくある質問

stdio クライアントはこのログを書く必要があるか?

書かなくてよい。告知は、stdio クライアントと、この SDK で作った MCP サーバーは影響範囲の外だと書いている。credential_check を書くのは HTTP 上の OAuth クライアントで、完全には信頼しない MCP サーバーへ繋がる可能性があり、正当な認可サーバーの資格情報を持っている場合だ。

1.31.0 または 2.2.0 へ上げれば十分か?

十分ではない。同梱 provider には expectedIssuer を書く。issuer のない古い資格情報は補うか消す。新しい対話ログインは、信頼するサーバーに対してだけ完了する。refreshAuthorization または exchangeAuthorization の直接呼び出しと、skipIssuerMetadataValidation を true にすることは、この修正の外だ。

issuer が一致したら、Tool Call を実行してよいか?

それだけでは実行できない。issuer の一致は、資格情報が別の認可サーバーへ渡っていないことだけを意味する。Tool Call には別の権限の決定がまだ要る。ツールは許可リストにあるか、今回の grant にその能力があるか。それは前の記事の話だ。本記事では deny のあとにその呼び出しを書かない。

MCP の安全の記事と同じか?

同じ記事ではない。あちらは権限を誰が守るかを書く。こちらは 2026-09-30 の告知のあと、JSON ログのどの段階で Tool Call を止めるかを書く。どちらもツールへ繋がるが、切り口は違う。

ログに refresh_token を保存するか?

保存しない。停止が残すのは二つの issuer 文字列、一致したか、送ったか、decision、reason だけだ。token と client secret はこの JSON に入れない。信頼しないサーバーへ繋がった可能性があるなら、ログへ写すのではなく、鍵をローテーションし token を失効させる。

まとめ

資格情報は先に本来の issuer へ結び付ける。MCP サーバーが別の認可サーバーを指し示したら、ログは credential_check で止まる。deny であり、送らず、Tool Call も書かない。版の更新はこの比較をクライアントへ入れる。expectedIssuer と古い保存は、まだ自分で補う。

結論:先に issuer を比べる。一致しなければ deny。credentialsSent は false のまま。Tool Call は足さない。同梱 provider には expectedIssuer を書く。信頼しないサーバーへ繋がった可能性があるなら、鍵をローテーションし token を失効させる。

次:この停止を Schema で止め、そのあと Diff で、deny と、形は合法だが decision が allow の記録の差を見る。

ブログへ戻る