前言

给远程 MCP 增加企业用户登录时,最容易产生误判的场景不是“登录页打不开”,而是:

  • WorkBuddy 能发现 MCP;
  • 浏览器能打开登录页;
  • 用户输入正确账号和密码;
  • 页面显示“授权已提交,请返回 WorkBuddy”;
  • 但 WorkBuddy 一直没有连接成功,工具列表也没有加载。

表面上看用户已经登录,实际上 OAuth 可能只完成了前半段。本文记录一套通用排障方法,重点覆盖 OAuth discovery、动态客户端注册、Authorization Code + PKCE、桌面回环地址、自定义 Deep Link 以及重复提交问题。

本文使用的服务端是 Python/FastMCP,但排障思路同样适用于其他语言。

配套代码、测试与上线清单:workbuddy-mcp-oauth-guide

一、先区分 MCP 请求和 OAuth 请求

远程 MCP 首次连接通常包含两条相互关联但职责不同的链路:

  1. MCP 资源服务器负责 /mcp,接收工具发现和工具调用。
  2. OAuth 授权服务器负责 /authorize/token/register 和登录页。

首次连接时,客户端没有 Access Token,因此下面的请求是正常的:

POST /mcp -> 401 Unauthorized
GET /.well-known/oauth-protected-resource/mcp -> 200
GET /.well-known/oauth-authorization-server -> 200

这里的 401 不是最终故障,而是资源服务器告诉客户端“这个资源需要 OAuth,以及去哪里授权”。

同样,探活程序或客户端如果执行:

GET /mcp -> 405 Method Not Allowed
HEAD /mcp -> 405 Method Not Allowed

也不代表 Streamable HTTP MCP 已损坏。关键要看客户端是否随后继续 discovery、注册和授权流程。

二、正确的完整成功链路

一次完整的 OAuth 连接至少应该在服务端日志中留下以下阶段:

1. POST /mcp                                      -> 401
2. GET  /.well-known/oauth-protected-resource/mcp -> 200
3. GET  /.well-known/oauth-authorization-server   -> 200
4. POST /register                                 -> 201
5. GET  /authorize                                -> 302 登录页
6. GET  /oauth/login                              -> 200
7. POST /oauth/login                              -> 302 回调 URI
8. POST /token                                    -> 200
9. POST /mcp (Bearer Access Token)                -> 200

如果日志停在第 7 步,说明账号密码校验成功,但授权结果没有回到桌面客户端,或者客户端没有继续换 Token。

所以页面上显示“登录成功”只能证明业务身份校验成功,不能证明 OAuth 完成。

三、问题一:动态客户端注册响应不兼容

桌面 MCP 客户端通常使用 OAuth Dynamic Client Registration。客户端把自己的回调 URI 发到 /register,服务端返回 client_id

成功响应应使用 2xx 状态码,并包含客户端后续授权所需的字段。例如:

{
  "client_id": "generated-client-id",
  "redirect_uris": [
    "http://127.0.0.1:18484/oauth/callback"
  ],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}

常见错误包括:

  • 把“客户端已注册”作为异常返回 400;
  • 返回的 redirect_uris 与客户端提交值不一致;
  • 公共桌面客户端却要求 client_secret
  • 注册成功但服务重启后丢失客户端,旧 client_id 直接变成 unauthorized_client
  • 客户端数量无上限,长期运行后内存持续增长。

修复时应做到:

  • 新注册返回标准 201;
  • token_endpoint_auth_method 对 PKCE 桌面客户端使用 none
  • 保存精确的回调 URI;
  • 为动态客户端设置 TTL 和容量上限;
  • 只回收没有活跃授权请求的旧客户端;
  • 如需恢复客户端缓存,只恢复格式和回调路径都严格匹配的记录。

四、问题二:桌面回调没有真正回到 WorkBuddy

桌面客户端常见两种回调方式。

1. Loopback 回调

http://127.0.0.1:<随机端口>/oauth/callback

WorkBuddy 在本机启动临时 HTTP listener。登录结束后,浏览器访问该地址,客户端接收 codestate

服务端必须原样返回注册时的主机、端口和路径,不能擅自固定端口,也不能把 127.0.0.1 改成 localhost

2. 自定义 Deep Link

workbuddy://workbuddy/mcp/<connector-reference>/oauth/callback

操作系统把这个 URI 交给 WorkBuddy。服务端同样必须使用客户端注册的完整 URI,不能返回另一个“看起来差不多”的地址。

如果页面显示授权成功但 WorkBuddy 没反应,应检查:

  • /authorize 中的 redirect_uri
  • /register 保存的回调;
  • 登录成功响应的 Location
  • 三者是否逐字一致;
  • 回调是否带上原始 state
  • Deep Link 是否真的被操作系统交给 WorkBuddy;
  • loopback 端口是否有客户端监听。

五、问题三:第一次点击没反应,第二次才提示提交

登录按钮的重复点击问题容易掩盖真实故障。用户第一次点击后请求可能仍在处理,第二次点击会并发提交同一个 request_id

如果服务端实现为“读取 pending 请求 -> 调后端登录 -> 删除 pending 请求”,两个并发请求可能同时读到 pending 状态,导致:

  • 生成两个授权码;
  • 一个请求删除状态后,另一个请求报“授权已失效”;
  • 页面展示成功,但客户端收到的是另一个请求的回调;
  • 后端登录被调用两次。

修复目标不是简单禁用前端按钮,而是让服务端完成原子化和幂等处理:

  • 同一 request_id 同时只允许一个请求执行登录;
  • 第一个请求生成唯一授权码;
  • 重复请求不能再次调用业务登录接口;
  • 授权码只能消费一次;
  • 页面不得在回调真正发出前显示“已完成”。

前端禁用按钮可以改善体验,但服务端幂等才是安全边界。

六、PKCE 和 Token 交换不能省略

桌面客户端是 public client,不适合保存 Client Secret,因此需要 PKCE。

客户端在 /authorize 提交:

code_challenge=<S256 challenge>
code_challenge_method=S256

换 Token 时提交:

grant_type=authorization_code
code=<one-time authorization code>
code_verifier=<original verifier>
client_id=<registered client>
redirect_uri=<exact registered redirect URI>
resource=<MCP resource URI>

服务端必须校验:

  • 授权码未过期且未被消费;
  • client_id 一致;
  • redirect_uri 完全一致;
  • code_verifier 能生成原 code_challenge
  • Token 的 resource/audience 与 MCP 资源一致;
  • scope 不超过用户授权范围。

如果日志没有 POST /token,不要继续排查 MCP 工具。此时问题仍在浏览器回调或桌面客户端接收回调的阶段。

七、回调 URI 兼容不能以牺牲安全为代价

为了让 Deep Link 能工作,有些实现会允许所有自定义 scheme,这是危险的。攻击者可以注册恶意 URI,把授权码导向自己的应用。

推荐策略:

  • 公网 Web 回调只允许 HTTPS;
  • HTTP 只允许 127.0.0.1localhost::1 的 loopback;
  • 自定义 scheme 使用精确 allowlist;
  • 禁止 URI fragment;
  • 禁止 username/password userinfo;
  • /authorize 的回调必须与 /register 保存值精确匹配;
  • state 必须原样返回;
  • 授权码设置短 TTL 并单次消费。

本仓库的 examples/redirect_policy.py 展示了一个最小策略实现。

八、登录身份不能等同于业务权限

OAuth 解决的是“当前用户是谁”和“是否允许进入 MCP”。它不能自动保证用户只看到自己的业务数据。

正确做法是:

  1. 用户在 OAuth 页面完成企业身份校验;
  2. 服务端把 OAuth Token 绑定到可信的用户 ID 或后端 Session;
  3. 每次 MCP 工具调用都携带当前用户身份访问业务后端;
  4. 由业务后端继续执行数据范围过滤;
  5. MCP 不设置共享管理员身份,也不在用户身份丢失时回退到管理员 Token。

如果 HR A 的 Token 可以查询 HR B 的数据,那么即使 OAuth 协议完全正确,系统仍然存在越权漏洞。

至少要完成以下交叉测试:

  • A 能查询 A 的数据;
  • B 能查询 B 的数据;
  • A 查询 B 的对象返回 404 或 403;
  • B 查询 A 的对象返回 404 或 403;
  • 管理员不能通过仅面向 HR 的 OAuth 入口登录;
  • 非白名单用户在密码校验前就被拒绝;
  • Token 过期、撤销或服务端 Session 失效后不能回退到共享身份。

九、如何读取日志

以下状态通常是正常的:

POST /mcp -> 401
GET /.well-known/oauth-protected-resource/mcp -> 200
GET /.well-known/oauth-authorization-server -> 200
POST /register -> 201
GET /authorize -> 302
GET /oauth/login -> 200
POST /token -> 200

以下组合值得重点检查:

/register -> 400

检查动态注册请求格式、重复注册处理、回调 URI 校验以及容量限制。

/authorize -> 302,登录页正常,但没有 /token

检查最终 Location、Deep Link、loopback listener、state 和客户端回调接收。

登录接口被请求两次

检查按钮重复提交、并发请求以及 request_id 幂等处理。

/token -> 400 invalid_grant

检查授权码是否重复消费、PKCE verifier、redirect_uriclient_id 和有效期。

带 Token 的 /mcp -> 401

检查 Token 是否过期、scope、resource/audience、issuer 和 Bearer Header。

十、本地最小化诊断方法

不要一开始就在生产环境同时调试业务登录、数据库、反向代理和 WorkBuddy。

建议先启动一个只监听本机的诊断 MCP:

  • 地址使用 http://127.0.0.1:<port>/mcp
  • 只创建一个本地虚拟用户;
  • 只提供一个 whoami 工具;
  • 不连接生产数据库、Redis 和外部业务系统;
  • 日志记录 OAuth 阶段,但不记录密码、完整 Token 和授权码。

如果本地诊断服务能连接,问题通常位于生产 HTTPS、Nginx 或业务登录接口;如果本地也失败,应优先检查 WorkBuddy 注册和回调兼容。

十一、生产上线检查

上线前至少完成:

  • OAuth 和 MCP 公网入口使用可信 HTTPS;
  • discovery metadata 中的 issuer、resource 和端点地址一致;
  • /register/authorize、登录和 /token 分别限流;
  • pending request、动态客户端、授权码和 Token 都有 TTL 与容量上限;
  • 密码、Token、授权码、Cookie 和业务 Session 不写日志;
  • Access Token 短期有效,Refresh Token 支持轮换与撤销;
  • 多实例部署时把 OAuth 状态放入 Redis 或数据库;
  • 功能有独立开关和明确回滚方式;
  • 至少使用两个真实权限不同的账号做交叉越权测试。

总结

WorkBuddy 页面显示“登录成功”后仍然连接失败,通常不是密码问题,而是 OAuth 后半段没有完成。最有效的排查方式,是按顺序验证:

discovery
-> dynamic registration
-> authorize
-> login
-> registered callback
-> token exchange
-> authenticated MCP request
-> per-user business authorization

只要逐段确认请求和状态码,就能避免在“401 是否正常”“是不是账号密码错了”“为什么第二次点击才有效”之间反复猜测。

兼容桌面客户端时,可以增加针对回调格式和客户端缓存的适配,但不能放开任意回调、跳过 PKCE、共享管理员 Token,或者把账号密码写入客户端配置。协议链路完整与业务权限隔离缺一不可。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐