博客 • 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 時也升到 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 時同樣不在裏面。這兩處都不要靠模型判斷要不要發憑證。

這和已有的兩篇有什麼差別?

爲什麼需要 SchemaMCP 安全本篇
回答輸出爲什麼要有形狀權限該由誰來守issuer 不一致時,日誌在哪一步停住 Tool Call
不寫不寫這次 OAuth 公告的停點不寫出事之後的日誌順序不寫可復現的請求,也不再講 Schema 夠不夠

在 JSONNote 裏怎樣覈對這條停點?

  1. 1
    選對操作

    打開 JSON Schema。操作選「用 Schema 校驗」。第一欄貼上面的停點,第二欄貼 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。第一欄貼 deny 那份停點。第二欄只把 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 的差別。

返回博客