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

返回博客