AI 技术
#MCP#OAuth 2.1#动态客户端注册#RFC 7591

MCP 远程服务器动态客户端注册:重装后为何在授权服务器留下孤儿记录

远程 MCP 服务器接入 OAuth 2.1 时,客户端常通过 RFC 7591 动态注册获取 client_id。本文解释注册端点未鉴权、凭据未持久化与重装重复注册如何叠加成孤儿记录和配额耗尽,并给出清理策略、可观测指标及与静态预注册的取舍。

一次重装之后,授权服务器多了一条没人认领的记录

假设你维护一个远程 MCP 服务器,它用 OAuth 2.1 保护工具调用接口。用户在桌面客户端里添加这个服务器,点一次“连接”,浏览器弹出授权页,授权完成后工具就能用。整个过程没有让用户填 client_id,也没有让管理员提前在授权服务器后台建应用。这条路径依赖 OAuth 2.0 动态客户端注册(Dynamic Client Registration,RFC 7591):客户端先向授权服务器的注册端点发一个请求,带上自己的元数据,换回一个 client_id,再用这个身份走授权码流程。

问题出现在用户卸载重装客户端之后。重装后的客户端本地没有旧凭据,于是它像第一次接入一样再注册一次,拿到一个新的 client_id。旧的那条注册记录还留在授权服务器上,没有任何客户端会再使用它。一次重装留一条,一个团队几十台机器反复升级,注册表就会持续膨胀。注册端点若没有鉴权,任何能访问它的人都能写入记录;授权服务器若按租户或按来源 IP 设了注册配额,这些孤儿记录最终会把配额占满,让真正的新用户注册失败。

下面围绕这个场景,拆开三个叠加因素:注册端点为什么常常是开放的、客户端为什么留不住凭据、重装为什么必然产生新记录。然后再讨论清理手段、可观测指标,以及静态预注册和客户端元数据文档这两种替代方案各自适合什么条件。

RFC 7591 解决了什么,又留下了什么

RFC 7591 要解决的问题很具体:OAuth 客户端在接入某个授权服务器之前,必须先有一个该服务器认可的 client_id。传统做法是开发者到授权服务器后台手工建应用,填回调地址、选择授权类型,拿到 client_id 和 client_secret。当客户端要接入的服务器数量事先无法枚举时,手工流程就走不通。MCP 规范正是这种情形:客户端不可能预先知道用户会添加哪些远程 MCP 服务器,每个服务器背后又可能挂着不同的授权服务器。

RFC 7591 定义的流程很短。客户端向注册端点发一个 JSON 请求,请求体是一组客户端元数据,例如重定向 URI 列表、授权类型、客户端名称。授权服务器校验后返回一个客户端信息响应,其中包含分配好的 client_id,以及服务器实际采纳的元数据。此后客户端就用这个 client_id 参与 OAuth 流程。MCP 授权规范把动态注册列为客户端和授权服务器“应该支持”的能力,理由写得很直接:客户端无法提前知道所有服务器,手工注册会给用户带来摩擦。

规范同时把注册端点本身的安全策略留给部署方决定。RFC 7591 的用例附录区分了开放注册与受保护注册:开放注册不要求请求方证明身份,受保护注册则要求某种初始访问凭据。规范没有规定授权服务器必须选哪一种,也没有规定注册记录的生命周期。这两处空白,正是孤儿记录得以积累的制度前提。

注册请求里流动的到底是什么

把一次注册拆成状态变化,会更容易看清凭据在哪一步丢失。

客户端首次连接某个远程 MCP 服务器时,先按 MCP 规范做受保护资源元数据发现,从服务器拿到授权服务器地址,再读取授权服务器元数据文档,找到注册端点。接着客户端构造注册请求,请求体里通常包含重定向 URI、客户端名称、grant_types 和 response_types 等字段。对桌面或本地客户端而言,它一般是公共客户端,没有能力安全保存 client_secret,因此注册请求往往声明 token_endpoint_auth_method 为 none,也就是不依赖客户端密钥,改用 PKCE 保护授权码交换。

授权服务器处理请求后写入一条注册记录,返回 client_id。客户端把 client_id 存在本地,然后生成 PKCE 参数,打开浏览器走授权码流程,最后用授权码加 code_verifier 换到访问令牌。此后每次调用 MCP 工具都带 Bearer 令牌。

flowchart TD
    A[客户端首次连接远程 MCP 服务器] --> B[发现受保护资源元数据]
    B --> C[读取授权服务器元数据]
    C --> D[向注册端点提交客户端元数据]
    D --> E[授权服务器写入注册记录并返回 client_id]
    E --> F[客户端本地保存 client_id]
    F --> G[PKCE 授权码流程换取令牌]
    G --> H[携带令牌调用 MCP 工具]
    H --> I{客户端被卸载或重装}
    I --> J[本地 client_id 丢失]
    J --> D
    E --> K[旧记录无人引用,成为孤儿]

流程图里有一个关键转折:从“本地保存 client_id”回到“提交注册请求”的那条边。只要客户端在重装后无法恢复旧 client_id,这条边就必然被再走一次,而授权服务器一侧没有任何机制知道新旧两条记录属于同一台机器、同一个用户。

三个因素如何叠加成记录膨胀

注册端点缺少鉴权,写入成本极低

开放注册的注册端点接受任何来源的合法元数据请求。RFC 7591 把它列为一种合法部署形态,适用于客户端完全无法预先获得凭据的场景。代价是授权服务器无法区分“一个真实的新客户端”和“同一客户端换了个本地状态再来一次”。每次请求都产生一条独立记录,服务器没有天然的合并依据,因为请求里没有稳定的客户端实例标识。

客户端把 client_id 当成会话状态而非持久身份

很多客户端把注册结果和当前登录会话绑在一起:会话结束、缓存被清、应用被卸载,client_id 随之消失。RFC 7591 的用例附录专门讨论过“每个客户端实例一个 client_id”与“同一客户端软件的所有实例共享一个 client_id”两种模式。前者隔离性好,但要求客户端把凭据当作长期身份持久化;后者能天然抑制记录数量,但需要客户端软件在发布时就携带一个可复用的身份,或者由开发者统一注册。选择前者却没有做持久化,就会在每次重装时退化成重复注册。

授权服务器没有回收机制

RFC 7591 只定义注册,不定义注销。RFC 7592 补充了客户端配置端点,支持读取、更新和删除注册记录,但它的状态是 Experimental,且规范明确说明并非所有支持动态注册的授权服务器都会实现这些管理方法。即使实现了删除接口,也需要客户端持有注册时获得的注册访问令牌才能调用。客户端本地状态已丢,就没有凭据去删除旧记录。授权服务器侧若没有基于最后使用时间的过期策略,孤儿记录会一直保留。

三个因素单独看都不算缺陷:开放注册是规范允许的形态,会话级存储是常见实现选择,缺少注销接口是规范范围之外的事。叠在一起,重装就成了稳定的记录生产动作。

配额耗尽时,故障表现是什么样的

注册记录膨胀不会立刻表现为错误。它先消耗存储,再消耗配额,最后才在注册端点暴露出来。假设授权服务器按租户设置注册记录上限,或者按来源网段限制单位时间内的注册次数。孤儿记录占满上限后,新客户端第一次连接就会收到注册错误响应,RFC 7591 为此定义了 error 字段,常见取值包括 invalid_redirect_uri、invalid_client_metadata 等。此时用户看到的是“连接失败”,而授权服务器日志里只有一条普通的注册拒绝,看不出根因是历史记录堆积。

更隐蔽的一种表现是授权服务器变慢。注册表变大后,按重定向 URI 或客户端名称做的查询、后台的清理任务、管理界面的列表加载都会变重。工程上通常先注意到注册端点的尾延迟上升,而不是错误率上升。

还有一种失败模式值得单独提:如果客户端在注册成功后、保存 client_id 之前崩溃,它会认为注册失败并重试,一次连接尝试可能产生多条记录。注册请求本身不是幂等的,重试语义由客户端实现决定,规范没有提供去重键。

清理与可观测:先让孤儿可见

清理的前提是能识别孤儿。授权服务器可以从三个信号入手。

(1)最后使用时间

记录注册时间不够,还要记录该 client_id 最近一次成功换取令牌的时间。长期没有令牌交换、也没有授权请求的记录,基本可以判定为未被使用。注意区分“注册后从未使用”和“曾经使用但已停用”,前者可以较短窗口回收,后者需要更长窗口,避免误删低频但有效的客户端。

(2)授权码与令牌的关联

如果授权服务器能按 client_id 统计授权码签发量和令牌签发量,就能把注册记录分成活跃、沉寂、从未激活三类。从未激活的记录是最安全的清理对象。

(3)注册请求的来源特征

同一来源在短时间内提交多条元数据高度相似的注册请求,往往意味着客户端在重试或重装。这个信号不适合直接用来删除,但适合用来告警,提示客户端侧存在持久化缺陷。

可观测指标建议至少覆盖以下几项,它们都能从授权服务器现有数据中导出,不需要改动协议。

指标含义异常时的判断
注册记录总数与日增量注册表规模及增长速度日增量持续高于活跃客户端增量,说明孤儿在积累
从未激活记录占比注册后没有换过令牌的比例占比升高通常指向客户端未持久化凭据
注册端点拒绝率因配额或策略被拒的请求比例拒绝率突升往往意味着配额已被历史记录占满
每 client_id 令牌交换次数记录的实际使用强度大量记录长期为零,可作为清理依据
注册请求元数据重复度相似请求的聚集程度高重复度提示重装或重试循环

清理策略上,比较稳妥的做法是分级:从未激活且超过一定窗口的记录直接回收;长期无令牌交换的记录先标记,再经过更长窗口回收;活跃记录不自动删除。如果授权服务器实现了 RFC 7592 的删除接口,也可以给客户端提供主动注销路径,但不能把它当作主要手段,因为丢失本地状态的客户端恰恰调用不了它。

与静态预注册、客户端元数据文档的对比

静态预注册是最直接的替代方案:开发者在授权服务器后台为客户端建一个固定 client_id,客户端把这个值编译进程序或写进配置。记录数量等于客户端软件种类数,与安装次数无关,孤儿问题从根上消失。代价是每接入一个新的授权服务器都要人工协调,MCP 场景下服务器数量不可枚举,这个成本会迅速变得不可接受。MCP 规范也把“硬编码 client_id”列为不支持动态注册时的退路,而非首选。

客户端元数据文档是另一条路。RFC 7591 定义了软件声明(software statement)机制:客户端把元数据打包成一个由可信方签发的断言,注册时随请求提交,授权服务器校验签发方后再决定是否采纳。它没有消除注册记录,但把“谁在注册”从匿名请求变成了可验证的声明,授权服务器可以据此实施策略,例如限制同一软件声明的注册频率,或对可信软件放宽配额。实现复杂度高于开放注册,需要引入签发方和密钥管理。

方案记录数量增长接入成本安全边界适用条件
开放动态注册随安装与重装次数增长最低,无需人工注册端点对匿名请求开放服务器集合不可枚举、可接受后台清理
受保护动态注册或软件声明仍随注册次数增长,但可按签发方约束中等,需要签发与校验注册请求需可验证凭据需要限制匿名写入、又有动态接入需求
静态预注册与安装次数无关最高,每次接入需人工凭据预先分发客户端与授权服务器组合固定、数量有限

三种方案不是互斥的。生产环境中常见的组合是:对已知的大型授权服务器使用静态预注册,对长尾服务器使用动态注册,并在动态注册路径上启用软件声明或至少启用来源限流。

客户端侧能做的两件事

授权服务器侧的清理是补救,客户端侧的持久化才是止血。第一件事是把 client_id 存到卸载后仍可能保留的位置,或者提供“重新连接时复用已有注册”的路径。不同操作系统的卸载行为不同,能否保留取决于具体实现,这里不能一概而论,但至少应避免把 client_id 只放在进程内存或易失缓存里。

第二件事是让注册请求可被去重。客户端可以在元数据中携带稳定的软件标识和版本,授权服务器据此识别“同一软件再次注册”,从而选择复用旧记录或至少把新记录与旧记录关联起来,便于后续清理。这需要双方约定字段语义,属于实现层面的扩展,不是 RFC 7591 的强制要求。

这套机制在什么条件下会退化

动态注册加清理的组合,成立前提有三个:客户端愿意持久化凭据、授权服务器能记录最后使用时间、清理窗口与业务节奏匹配。任何一个不成立,孤儿记录就会重新积累。

如果客户端是浏览器内的临时应用,本地存储随标签页关闭而清空,持久化就无从谈起,此时只能依赖授权服务器侧的短窗口回收。如果授权服务器是多租户部署,各租户的配额策略不同,统一的清理窗口可能误删低频租户的有效记录。如果业务本身存在大量一次性客户端,例如自动化测试或临时演示环境,从未激活记录占比会天然偏高,按这个指标清理会伤及正常流量。

还有一个未解决的问题:注册记录与用户身份之间没有标准关联。授权服务器知道某个 client_id 被哪些用户授权过,但不知道这个 client_id 对应哪台设备、哪次安装。缺少这层关联,清理只能基于使用信号做概率判断,无法精确判定某条记录是否真的无人认领。在 RFC 7591 和 RFC 7592 的现有范围内,这个问题没有标准答案,只能靠部署方在元数据字段和日志上自行补足。

资料来源

  1. RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol
  2. RFC 7592: OAuth 2.0 Dynamic Client Registration Management Protocol
  3. MCP Specification: Authorization
  4. 授权 | MCP 中文文档