博客技术

远程 MCP 服务器的 OAuth 实战:上线经验总结

一个以 Supabase Auth 为授权服务器、已在生产环境运行的 MCP 服务器的实践笔记:规范要求什么,我们哪里做错了,还有哪些尚未测试。

先说结论

远程 MCP 服务器是一个 OAuth 受保护资源。请求不带令牌时,它返回 401,并指向自己的 RFC 9728 元数据;客户端顺着元数据找到授权服务器,完成注册,用 PKCE 和 resource 参数让用户登录,最后带着为该服务器 URL 签发的 Bearer 令牌回来。我们在生产环境中用 Supabase Auth 跑这套流程。真正费工夫的是三个细节:401 要带 scope 提示而不带错误码;用令牌钩子把我们的受众和 scope 写进 Supabase 签发的令牌;还有动态客户端注册,因为我们的授权服务器不支持客户端 ID 元数据文档。

MCP 服务器接入 OAuth 时,各方分别负责什么?

MCP 服务器的活最少:只负责接受或拒绝 Bearer 令牌。MCP 授权规范(2025-11-25 版)把它定为 OAuth 2.1 资源服务器,把 AI 助手定为 OAuth 客户端,并允许负责用户登录和签发令牌的授权服务器是一个独立的服务。

我们的授权服务器就是独立的。所有令牌都由 Supabase Auth 的 OAuth 2.1 服务器签发,登录和授权同意页面由我们的网站提供,https://mcp.scorestarling.com/mcp 这个端点只负责校验令牌。它以 Streamable HTTP 方式运行,不使用 MCP 会话,每个请求都会校验令牌。ScoreStarling 能把录音变成可编辑的乐谱,AI 助手要调用它,走的就是这个端点;用户要做的操作见连接指南。

MCP 客户端连接时会发生什么?

客户端一开始只有一个 URL。我们服务器的两次响应(以下为 2026年10月3日的实际内容)告诉它去哪里登录,之后再经过六步拿到令牌。首先,不带令牌的请求会收到 401,其 WWW-Authenticate 响应头写明了元数据的位置和要申请的 scope:

$ curl -si -X POST https://mcp.scorestarling.com/mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"

{"error_description": "Authentication required"}

然后,客户端获取这份元数据。按照 RFC 9728,元数据地址是在主机名和路径之间插入 /.well-known/oauth-protected-resource 得到的;它还要求 resource 与客户端正在使用的 URL 完全一致,否则客户端必须停止。下面我们把 Supabase 项目 ID 换成了占位符:

{
  "resource": "https://mcp.scorestarling.com/mcp",
  "authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
  "scopes_supported": ["openid", "email", "profile"],
  "bearer_methods_supported": ["header"]
}

其余六步:

  1. 客户端读取授权服务器的元数据。Supabase 的 issuer 带路径,所以按照 RFC 8414,well-known 段要放在路径前面:https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1。客户端会检查 issuer 是否完全一致,以及 code_challenge_methods_supported 里是否有 S256;MCP 规范规定,缺少这个字段就必须停止。

  2. 客户端把自己的元数据 POST 到元数据中公布的 registration_endpoint(动态客户端注册,RFC 7591),拿到一个 client_id。我们的测试客户端以公共客户端身份注册,不带密钥;Anthropic 的文档说 Claude 也是这样。

  3. 客户端在浏览器中打开授权端点,带上 PKCE S256 challenge、401 里给出的 scope,以及 resource=https://mcp.scorestarling.com/mcp(RFC 8707)。Supabase 把浏览器转交给我们的授权同意页面:用户需要时先登录,再选择批准或拒绝这个客户端。

  4. 客户端用 PKCE verifier 和同一个 resource 兑换授权码,拿到一个有效期一小时的访问令牌和一个刷新令牌。

  5. 之后的每个 MCP 请求都带上 Authorization: Bearer …。我们先用 issuer 公布的公钥校验签名,再检查 issuer、受众、过期时间和写入 scope,最后确认账号没有被停用。

  6. 令牌过期,或者我们返回带 invalid_token 的 401 时,客户端会刷新令牌(同样带上 resource),拿到新的访问令牌和刷新令牌。

即使授权服务器会忽略 resource,客户端也必须在授权请求和令牌请求中带上它。Supabase 的 OAuth 流程文档没有提到这个参数(2026年10月3日查看)。在我们所有的线上实测中,Supabase 都接受了它,而我们令牌里的受众来自钩子。

401 响应的 WWW-Authenticate 头应该包含什么?

请求没带令牌时:写明元数据在哪里、该申请哪些 scope,不带错误码。RFC 6750 §3.1 指出,完全不带认证信息的请求不应返回错误码。invalid_token 用于带了令牌但校验失败的情况,告诉客户端可以换个新令牌再重试。MCP 规范还补充说,401 应带上 scope 提示,客户端必须把它当作要申请的 scope。

我们是在写连接指南时,发现自己的服务器在这一点上做错了。在 MCP Python SDK 1.30.0 中,RequireAuthMiddleware 对所有未认证的请求都返回 error="invalid_token",而且不带 scope,结果一个从没发过令牌的客户端被告知“令牌无效”。现在我们在 MCP 路由外面包了一层小小的 ASGI 中间件,只改写 SDK 返回的 401;放不放行仍由 SDK 决定,它返回的 403 insufficient_scope 也原样保留:

# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Authentication required",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp"

# No token, now
WWW-Authenticate: Bearer
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
    error_description="Invalid or expired access token",
    resource_metadata="…/.well-known/oauth-protected-resource/mcp",
    scope="openid email profile"

提示里写的是 openid email profile,也就是 Supabase 能授予的 scope,与 scopes_supported 一致。现在只要无令牌时的质询里带了错误码,我们的部署检查就会失败。这个修复在 2026年10月2日上线,距离合并不到四分钟。Anthropic 的连接器文档还为 Claude 补充了两条规则:登录只会由 401 触发(Claude 会忽略 200 响应里的 WWW-Authenticate),而且只使用 authorization_servers 中的第一项。

怎样把 MCP 受众和 scope 写进 Supabase 的令牌?

用自定义访问令牌钩子。MCP 规范要求服务器只接受为自己签发的令牌,实际上就是要检查 aud;我们还想要一个自己的写入 scope,而 Supabase 的文档说它的 OAuth 服务器不支持自定义 scope(2026年10月3日查看)。这个钩子是一个 Postgres 函数,Supabase Auth 在签发令牌前运行它,它可以改写令牌中的 claims。下面是我们的版本,在迁移文件的基础上略有简化:

create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
  -- Tokens issued to an OAuth client carry its client_id; website sessions don't.
  if coalesce(claims->>'client_id', '') <> '' then
    claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
    claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
  end if;
  return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;

MCP 服务端用 PyJWT 校验这些 claims,然后再执行两条自己的规则:

claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
                    issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
                    options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
    return None  # the client gets 401 invalid_token
# then: has this account been turned off?

ACCEPTED_AUDIENCES 是 MCP 的 URL,迁移期间还会加上旧 URL。对 client_id 的判断把网站自己的登录挡在了外面:同一个 Supabase 项目签发的浏览器会话令牌,issuer 和密钥都一样,但没有 client_id,所以永远拿不到 MCP 受众。

这个钩子有两个代价。第一,它写入的是一个固定的受众,而不是跟随客户端传来的 resource;只要一个授权服务器只服务一个 MCP 服务器,这就没问题。第二,我们的授权同意页面只能显示客户端申请的 scope,所以写入 scope 实际上是在用户批准客户端时授予的。

有一个尚未关闭的问题报告 supabase/auth#2820(2026年9月20日提交,截至 10月3日无人回复)称,对于公共客户端、offline_access 或 resource,Supabase 获取授权详情的请求会返回 400。这几种情况我们都没能复现;在我们读过的 Supabase Auth 源代码里,只有授权已不处于待处理状态时(比如同一个授权被获取了两次),才会出现这个 400。

动态客户端注册,还是客户端 ID 元数据文档?

授权服务器声明支持哪种就用哪种;目前用 Supabase 的话,就是动态客户端注册(DCR)。2025-11-25 版 MCP 规范建议,在客户端和服务器素不相识时使用客户端 ID 元数据文档(CIMD),保留 DCR 是为了向后兼容。客户端应先尝试预先分配的客户端 ID,然后是 CIMD,再然后是 DCR,都不行才去问用户。

MCP 客户端如何获得客户端 ID
方式客户端如何表明身份服务器元数据中的标志Claude 表单中的选项我们的服务器(2026年10月3日)
预注册预先创建的客户端 ID,手动填写或内置在客户端中无需Use your own OAuth client(使用自己的 OAuth 客户端)不提供
客户端 ID 元数据文档client_id 是一个 HTTPS URL,指向客户端托管的 JSON 文件,由服务器去获取client_id_metadata_document_supportedUse Claude’s published identity(推荐,使用 Claude 已发布的身份)未声明支持
动态客户端注册把自己的元数据 POST 到注册端点,拿到 client_idregistration_endpointRegister automatically(自动注册)已声明支持

我们在 2026年10月3日重新读取了 Supabase 的元数据:其中列出了注册端点、S256 和令牌端点认证方式 none,没有 CIMD 标志;它的 MCP 指南会让你在控制台里开启动态注册。

这决定了用户在 Claude 自定义连接器表单里该怎么选。Anthropic 的文档把推荐选项,也就是 Claude 已发布的身份(published identity),描述为由 Anthropic 托管、需要服务器支持的 CIMD。所以我们的指南要求选择“Sign in now”(我们的工具没有一个能在不登录时使用)和“Register automatically”。Anthropic 的开发者文档说,服务器没有声明支持 CIMD 时,Claude 会回退到 DCR(CIMD 还要求 token_endpoint_auth_methods_supported 中包含 none);我们没有测试过保留表单默认值的情况,所以要求明确选择。我们在 2026年10月2日读过的客户端文档显示,ChatGPT 开发者模式、Cursor、VS Code 和 Gemini CLI 都使用 DCR。

DCR 也有代价:Anthropic 提到,Claude 每次全新连接都会注册一个新客户端;Supabase 也提醒,开启动态注册后,任何 MCP 客户端都能在你的项目中注册。不过,注册本身不授予任何权限。用户仍然要登录并批准这个客户端,令牌校验逻辑也会在每个请求上检查账号。由于任何客户端都能以任意名称注册,从 2026年10月6日起,我们的授权同意页面会显示批准后用户将被带往哪里;如果某个应用自称 ChatGPT 或 Claude,回调地址却不是对应助手的地址,页面就会发出警告。

访问令牌过期后,MCP 客户端还能保持登录吗?

能。访问令牌的有效期是一小时,客户端用刷新令牌续期。在依赖这一点之前,我们读了 Supabase Auth 的源代码(master 分支,截至 2026年9月22日):

  • 每次授权码兑换都会返回刷新令牌,无论是否申请了 offline_access;
  • 刷新必须由该会话所属的客户端发起,刷新时会轮换刷新令牌,并保留 client_id claim;
  • 刷新时钩子同样会运行(此时 authentication_method 为 token_refresh),所以每次都会重新写入我们的受众和 scope。

2026年10月2日的线上实测与此一致:刷新令牌发生了轮换,续期后的访问令牌可以正常使用。Anthropic 的文档说,Claude 会在收到 401 后以及令牌到期前五分钟内刷新令牌;如果授权服务器在 scopes_supported 中列出了 offline_access,Claude 还会申请它。Supabase 的元数据列出了它,我们额外申请 offline_access 的检查也通过了。

以下情况仍会结束会话:

  • 账号被停用,或用户被删除、被封禁(在我们的线上实测中,停用账号后,浏览器会话和两个 MCP 令牌都被拒绝了);
  • 旧的刷新令牌在 Supabase 的重用间隔(默认 10 秒)之后再次被使用,这会吊销整个会话;
  • 开启了会话时长上限、闲置超时或“single session per user”(每个用户只保留一个会话)。最后这项一定要关掉,否则在网站上登录一次,助手的会话就会结束;
  • 用户用 Supabase 默认的 logout 退出网站登录,这会结束该账号的所有会话,包括助手的会话。我们的网站在 2026年10月6日之前就是这样,这很可能就是某个 ChatGPT 连接无法再续期令牌的原因;现在“退出登录”会发送 scope=local,只结束当前浏览器的会话。

Supabase 没有公布令牌吊销端点(2026年10月3日查看),所以我们切断访问的办法就是停用账号。

issuer 末尾多一个斜杠,为什么会出问题?

因为 issuer 是逐字符比较的。RFC 8414 要求元数据中的 issuer 与客户端用来查找它的那个值完全相同,RFC 9728 对 resource 也有同样的要求。对严格的客户端来说,https://auth.example.com 和 https://auth.example.com/ 是两个不同的 issuer。

MCP Python SDK 用 pydantic 的 AnyHttpUrl 存储 issuer,而我们 lockfile 里锁定的 pydantic 2.13.5 会把 https://auth.example.com 变成 https://auth.example.com/。用它生成的元数据,写的是一个授权服务器从未公布过的 issuer,所以我们的服务器自己生成受保护资源元数据,issuer 与配置的值完全一致。Supabase 的 issuer 以路径结尾,pydantic 不会改动它,所以我们在生产环境中从没碰到这个问题;但如果 issuer 只是一个不带路径的源(origin),就会中招。现在,只要授权服务器的 issuer 和我们的不完全一致,部署检查就会失败。

哪些已经验证,哪些还没有

最初的证据来自一个按 MCP 规范编写的脚本化客户端。2026年10月2日,我们对生产环境跑了一轮测试,覆盖了无令牌时的 401、公共客户端的动态注册、带 resource 的授权同意和授权码兑换、带轮换的令牌刷新、用两个令牌分别调用 tools/list,以及停用账号(停用后两个令牌和浏览器会话都被拒绝)。第二轮额外申请了 offline_access,两轮都通过了。那次测试用的还是我们之前的地址;迁移到 mcp.scorestarling.com 的过程另有一篇文章。

此后,真实客户端也连上了。2026年10月3日,ChatGPT 开发者模式下的应用连上 mcp.scorestarling.com,完成登录并调用了一个工具;10月6日,Claude 也成功连接并运行了工具。尚未验证的有:

  • 令牌真正过期后的刷新:脚本里的刷新是在登录后立即执行的,我们还没有追踪过真实客户端的刷新;
  • Supabase 托管端的会话设置,我们还没有回读核对。

参考资料

  1. MCP 规范(2025-11-25 版):授权 — Model Context Protocol
  2. RFC 9728:OAuth 2.0 受保护资源元数据 — IETF
  3. RFC 6750:OAuth 2.0 授权框架:Bearer 令牌的使用,§3.1 — IETF
  4. RFC 8707:OAuth 2.0 资源指示符 — IETF
  5. RFC 7591:OAuth 2.0 动态客户端注册协议 — IETF
  6. RFC 8414:OAuth 2.0 授权服务器元数据 — IETF
  7. OAuth 客户端 ID 元数据文档(互联网草案) — IETF OAuth 工作组
  8. 连接器的身份验证 — Anthropic
  9. 添加不在目录中的连接器 — Anthropic
  10. OAuth 2.1 流程 — Supabase
  11. 自定义访问令牌钩子 — Supabase
  12. Model Context Protocol(MCP)身份验证 — Supabase
  13. 用户会话 — Supabase
  14. supabase/auth 的 issue #2820 — GitHub

常见问题

远程 MCP 服务器需要自己的授权服务器吗?

不需要。MCP 授权规范允许授权服务器是一个独立的服务。MCP 服务器发布 RFC 9728 元数据来指明授权服务器,再校验它签发的令牌,包括令牌的受众。我们的服务器指向 Supabase Auth,自己只负责校验令牌。

请求没带令牌时,MCP 服务器的 401 该带 invalid_token 错误吗?

不该。RFC 6750 §3.1 指出,不带凭据的请求不应返回错误码。应该返回 resource_metadata 和 scope 提示,把 invalid_token 留给带了令牌却被拒绝的情况,用来通知客户端刷新令牌。

Supabase Auth 能为 MCP 服务器签发自定义 scope 吗?

截至 2026年10月3日还不能:Supabase 的 OAuth 2.1 文档写明不支持自定义 scope。我们请求的是 openid email profile,再通过自定义访问令牌钩子(custom access token hook)加上我们自己的 scope 和 MCP 受众,每次刷新令牌时这个钩子也会运行。

服务器不支持 CIMD 时,在 Claude 的自定义连接器表单里该怎么选?

在“OAuth client”下选“Register automatically”,也就是动态客户端注册;如果所有工具都要求账号,再在“Authentication”下选“Sign in now”。Claude 推荐的选项“Use Claude’s published identity”是一份客户端 ID 元数据文档,需要服务器支持。我们的 Claude 连接步骤对这两项选择都有说明。

要从 Supabase 拿到刷新令牌,必须申请 offline_access scope 吗?

在我们的测试中不需要。不申请它,Supabase 在每次授权码兑换时也都返回了刷新令牌:从源代码看是这样,2026年10月2日的线上实测也是这样。有些客户端还是会申请它;Anthropic 的文档说,授权服务器列出了它时 Claude 就会申请,我们带上它的检查也通过了。

带来任何音乐带走一份乐谱