博客 • 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 的差别。