教程

MCP 客户端如何钉死 OAuth 登录方?Python SDK 1.30 与 2.2 升级后还要写上 issuer

Server 端接上 OAuth 只解决「谁可以调工具」。9 月 28 日这则公告问的是客户端:发现结果指过来的登录方,你认不认。

2026 年 9 月 28 日,官方 MCP Python SDK 的维护者确认:有缺陷的 HTTP 客户端可能把本该交给真实登录方的 client secret、authorization code 和 PKCE 证明,送到对方控制的 token 端点。修复版是 1.x 的 1.30.0 和 2.x 的 2.2.0,9 月 7 日就已经发出,当时发行说明把它写成行为变更。公告本身落在 9 月 28 日,Cycode 同日公开了研究,次日多家安全媒体转述。经过见 The Hacker News 的转述,客户端现在的正确接法见 OAuth clients。Server 怎么要求 token,仍是站内的 MCP OAuth 2.1。你连接的是自己不完全掌控的远程服务时,部署形状见 Remote MCP 进生产。

公告修的是客户端,不是你已经接好的 Server

受影响的是用官方 Python SDK 当 HTTP 客户端、并且走 OAuth 的进程。它连上一个自己并不完全信任的 MCP Server,手里又握着真实登录服务的凭证。维护者的说明是:部分发现路径没有先确定期望的登录方,就采用了 Server 指过来的那一方。修复版改成先算定期望的 issuer,再去取授权服务器元数据。元数据里的 issuer 必须就是这一方,否则拒绝。存下来的凭证也会标上登录方,标给 A 的记录不会拿去跟 B 交换 token。

这和「给 MCP Server 加上 OAuth 2.1」不是同一层。Server 侧决定谁能调用工具、令牌是不是发给这台资源。客户端侧决定发现文档指过来的授权服务器,是不是你当初发 client secret 的那一台。两层都要有。只做 Server,客户端仍可能在发现阶段跟错对象。只升客户端、Server 仍不校验令牌受众,也盖不住另一类问题。

修复进入主线的说明在 PR 3398。授权服务器元数据必须自称的 issuer 与获取地址一致,依据是 RFC 8414 第 3.3 节。对不上就停,不要在客户端里加「忽略 issuer」的开关。

你跑的是什么 这则公告 升级之后还要做的
HTTP 客户端,OAuthClientProvider1.9.1–1.29.1,或 2.0.0–2.1.1升到 1.30.0 或 2.2.0,清掉旧 client_info
ClientCredentials 或 PrivateKey JWT同样的版本区间升级之外必须传入 issuer
SDK 写的 Server、stdio、自带 token不在这则公告的影响面仍建议升到修复版,避免客户端依赖旧包

先分清谁在影响面里

1.x 从 1.9.1 到 1.29.1,2.x 从 2.0.0 到 2.1.1。用到的类包括 OAuthClientProvider、ClientCredentialsOAuthProvider、PrivateKeyJWTOAuthProvider,以及已经废弃、没有 issuer 参数的 RFC7523OAuthClientProvider。Cycode 将问题标为高危。本地 stdio、自己在请求上附加 token、以及只用 SDK 来写 Server 的部署,不在这则公告的影响面里。

影响面窄,不代表仓库可以留着旧 pin。同一个锁文件里,CLI、夜间作业和桌面客户端经常共用 mcp 这一个依赖。你以为只有 Server 在用 SDK,结果某个 HTTP 客户端也装了 1.29。升级按锁文件里的实际版本走,不按架构图上的角色走。

旧版本上,公告给出的临时办法只有一条:开了 OAuth 的客户端只连接你信任的 Server。已经连过不受信任的 Server,要在登录方那里轮换 client secret,并撤销已发出的 token。这一步针对的是可能已经离开你环境的凭证,不是再去复现发现过程。

升到 1.30 或 2.2 之后,机器对机器仍要写 issuer

浏览器流的 OAuthClientProvider 在修复版里会自己先确定期望的登录方,再取元数据。没有浏览器的两条路不一样。ClientCredentialsOAuthProvider 和 PrivateKeyJWTOAuthProvider 要你传入 issuer,值等于那台授权服务器在 /.well-known/oauth-authorization-server 文档里声明的 issuer。发现仍会跑,但换 token 只用这一方的元数据。Server 若指向别处,流程以 OAuthFlowError 停下。

不传 issuer 时,这两条路在修复版里仍会跟随发现结果。公告写明:不加上这个参数,升级等于没修到机器对机器。1.30.0 上的提醒是普通的 DeprecationWarning,Python 默认不显示,CI 日志里经常看不见。3.0 会把它变成必填。现在就写上,不要等大版本把作业跑红。

RFC7523OAuthClientProvider 没有 issuer 参数。继续用它,升级也钉不住登录方。迁到 ClientCredentialsOAuthProvider 或 PrivateKeyJWTOAuthProvider,再传入 issuer。密钥从环境变量或密钥管理器读,不要写进仓库。

你信任的那份元数据,issuer 必须等于获取地址

先在你已经信任的授权服务器上取文档,例如 https://auth.example.com/.well-known/oauth-authorization-server。文档里的 issuer 必须就是这台服务器。比较是字符串相等,不做规范化:末尾斜杠、主机大小写、www 前缀,都会变成另一个值。客户端传入的 issuer 用这份文档里的原文字符串,不要手写一个「看起来一样」的地址。

下面这份 JSON 是你信任的授权服务器元数据。issuer 与获取它的源站一致,token 端点也在同一台主机上。

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/authorize",
  "token_endpoint": "https://auth.example.com/token",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true
}

Protected Resource Metadata 只负责告诉客户端「哪一台授权服务器在保护这台 MCP」。它不是登录方身份的替代品。客户端要再去取那一台的元数据,并核对 issuer。资源元数据指向 A、授权服务器文档自称 B,就停。不要为了让集成先跑通,把 B 写进 pin。

2026-07-28 修订把动态注册降级,推荐 Client ID Metadata Document:你在稳定的 HTTPS 地址发布一份客户端 JSON,这个 URL 就是 client_id。SDK 用 client_metadata_url 接入。授权服务器声明支持时,不再向 /register 要一对新的 id 和 secret。URL 必须是带非根路径的 HTTPS,构造时就会检查。已存的 client_info 仍然优先于这份文档,所以旧记录不删,新钉的 issuer 不会生效。

清掉旧的 client_info,该轮换的密钥分开处理

修复版会把登录方标在存下来的注册记录上。升级前写入的 client_info 没有这道标记。公告要求清掉这些记录,让下一次注册按新规则重新生成,并绑到正确的登录方。只升包、留着旧文件,进程会继续拿出一份没有绑定的注册。

清记录和轮换密钥是两件事。清 client_info 是为了让新代码重新注册。轮换 client secret、在登录方撤销 token,是因为你可能已经把凭证交给过不受信任的 Server。没有这段历史,就不必把每个内部客户端的密钥都换一遍。有这段历史,就在登录方操作,不要在聊天记录里贴 secret。

回调里的 state 和 iss 要原样交回 Provider。Provider 用 state 对上自己生成的值,用 iss 对上发现到的 issuer。这两项是防混用的,不是调试字段。自己解析回调时丢掉 iss,等于拆掉修复版刚补上的一道核对。

把钉死的 issuer 放进仓库,当合同审

每个要连的 MCP 端点配一条记录:Server URL,以及你允许的 issuer。值从授权服务器元数据原文抄来。评审看的是格式化之后的这份 JSON,不是某个开发者笔记本上的域名。两次发布之间做对比,看谁把 issuer 改到了另一台主机。

仓库里留这份对照,不要留 client_secret。密钥在密钥管理器里,这里只钉登录方。

{
  "clients": [
    {
      "mcp_server": "https://orders.example.com/mcp",
      "issuer": "https://auth.example.com"
    }
  ]
}

机器对机器的构造参数和这份清单用同一个字符串。清单写 https://auth.example.com,代码里多一个斜杠,修复版会当成另一方并停下。这是你要的失败:作业红在 OAuthFlowError,而不是悄悄换了一台 token 端点。

提交前用JSON 格式化把元数据和 issuer 清单摊开,用JSON Schema 校验确认 issuer、token_endpoint 都是字符串,再用JSON 对比看这次谁改了登录方。

常见问题

只跑 stdio 的本地 Server,要不要处理这则公告?

stdio 客户端不在影响面里。同一个环境若还有 HTTP 客户端共用这个包,按 HTTP 客户端的版本升。锁文件里出现 1.9.1 到 1.29.1 或 2.0.0 到 2.1.1,就升到对应修复版,不要按「我们主要用 stdio」跳过。

升到 1.30.0 之后,日志里没有警告,是不是已经安全?

不能这么判断。ClientCredentialsOAuthProvider 和 PrivateKeyJWTOAuthProvider 没传 issuer 时,提醒是默认隐藏的 DeprecationWarning。打开 Python 的 deprecation 警告,或直接在构造参数里写上 issuer。看不见警告不等于参数已经补上。

动态注册和 Client ID Metadata Document 哪个更合适?

授权服务器已经声明 client_id_metadata_document_supported 时,用一份放在 HTTPS 上的客户端 JSON,URL 即 client_id,不再每次向 /register 申请 secret。对方还不支持时,SDK 会退回动态注册。无论哪条路,已存的 client_info 都更优先,升级后要清掉旧记录。

和 Server 侧的 OAuth 2.1 文章怎么配合?

Server 文章负责资源服务器如何要求 access token。这篇负责客户端不要跟错登录方。远程 Server 不在你自己的网关后面时,两篇一起看:Server 校验令牌,客户端把 issuer 钉在构造参数和仓库清单里。

总结与下一步

9 月 28 日的公告把「发现结果说谁是登录方,就信谁」从默认行为里拿掉了。HTTP 客户端升到 1.30.0 或 2.2.0。机器对机器再传入与元数据原文一致的 issuer。废弃的 RFC7523 Provider 换掉。

然后清掉旧的 client_info。连过不受信任 Server 的,在登录方轮换 secret 并撤销 token。仓库里只保留 Server URL 和 issuer 的对照,用格式化、校验和对比看住这个字符串。