博客 • 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 就不該出現。
- 比較 savedIssuer 和 MCP 服務器指出的 namedAuthorizationServer。
- issuerMatch 爲 false 時,decision 寫成 deny,credentialsSent 寫成 false。
- 這一步已經是 deny,日誌裏不要再追加 tool_call。
- 升到 SDK 1.31.0 或 client 2.2.0 之後,捆綁的 provider 仍要寫上 expectedIssuer;舊存檔裏沒有 issuer 的憑證要補上,或清掉後重新登錄。
直接結論: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。
{
"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。它不證明這次該不該停。
{
"$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 或更高。只升包,下面三處不會自動完成。依據仍是同一份公告。
| 還要做的事 | 公告裏的要求 |
|---|---|
| 捆綁的 provider | ClientCredentialsProvider、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選對操作
打開 JSON Schema。操作選「用 Schema 校驗」。第一欄貼上面的停點,第二欄貼 Schema。step 只能是 credential_check。decision 只能是 allow、deny 或 ask。
- 2撞四條壞數據
decision 寫成 yes,或 credentialsSent 寫成字符串 false,或刪掉 decision,或把 step 寫成 tool_call。這四條都會失敗。上面的 deny 記錄會通過。通過只說明形狀對。
- 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 的差別。