Cloudflare 新增了可选 OAuth scopes:客户端所有者可以明确标记哪些权限允许用户在授权页面取消选择。这个变化主要针对 MCP 服务器和代理场景,因为代理通常需要请求它可能执行的全部操作,最终形成一组权限的并集;用户却未必愿意一次性授予全部能力。
为什么 MCP 更需要可选权限
传统 OAuth 授权流程通常围绕一组 scopes 展开。客户端请求 read:profile、read:issues 和 write:issues,授权服务器再让用户整体同意或拒绝。部分实现允许用户取消某些权限,但开发者往往无法声明“哪些权限必须保留、哪些权限可以放弃”。
MCP 服务器把这个问题放大了。一个代理可能需要读取文档、搜索工单、创建任务,甚至修改配置。由于代理的实际任务在运行时才确定,客户端往往只能提前请求所有潜在操作所需的权限。权限越多,用户越难判断风险,也越可能直接拒绝整个授权请求。
可选 scope 提供了一个更明确的契约:核心能力必须获得批准,辅助能力可以由用户逐项关闭。这样既保留了代理的通用性,也把权限选择权交还给用户。
这项变化解决了什么
Cloudflare 的设计重点不只是支持“部分同意”,而是让客户端开发者控制哪些 scope 可以被放弃。两者的差别很重要:
requiredscope:用户不能取消;缺少它时,客户端无法完成基本工作。optionalscope:用户可以取消;客户端必须能够在权限缺失时继续运行或降级。- 未请求的 scope:客户端根本不能使用对应能力。
这种模型让授权界面更接近真实的产品能力,而不是简单地把一长串权限名称展示给用户。对于 MCP 客户端,开发者可以把“搜索数据”设为必需,把“写入数据”或“删除数据”设为可选,让用户先启用低风险能力。
不过,可选 scope 不是安全边界的替代品。服务端仍然必须在每次请求中检查 access token 的实际权限,不能因为客户端曾经请求过某个 scope,就默认该权限已经存在。
一个可改造的授权请求示例
下面的 Bash 示例使用通用 OAuth 参数构造授权 URL。authorization.example.com 和 client_id 是占位值,实际接入时请替换为身份提供商给出的地址和客户端 ID。示例把读取权限设为必需,把写入和删除权限设为可选;具体的可选 scope 参数名称需要按照服务端的实现调整。
#!/usr/bin/env bash
set -euo pipefail
AUTH_ENDPOINT="https://authorization.example.com/oauth/authorize"
CLIENT_ID="replace-with-your-client-id"
REDIRECT_URI="https://mcp-client.example.com/oauth/callback"
# 这是一个示意性的 scope 约定:读取能力必需,写入和删除能力可选。
REQUIRED_SCOPES="profile data:read"
OPTIONAL_SCOPES="data:write data:delete"
python3 - <<'PY'
from urllib.parse import urlencode
params = {
"response_type": "code",
"client_id": "replace-with-your-client-id",
"redirect_uri": "https://mcp-client.example.com/oauth/callback",
"scope": "profile data:read data:write data:delete",
"optional_scope": "data:write data:delete",
"state": "replace-with-a-random-csrf-value",
"code_challenge": "replace-with-pkce-challenge",
"code_challenge_method": "S256",
}
print("https://authorization.example.com/oauth/authorize?" + urlencode(params))
PY
生产环境需要补上真正随机的 state 和 PKCE 参数,并校验回调中的 state。如果用户取消了 data:write,客户端不应把它当成异常终止,而应在工具列表或代理上下文中明确标记写入能力不可用。
服务端的权限检查可以保持简单直接。以下是一个可改造的 Python 伪实现,重点是说明降级行为,而不是绑定某个具体 Web 框架:
from dataclasses import dataclass
@dataclass
class Token:
scopes: set[str]
def can_call(token: Token, required_scope: str) -> bool:
return required_scope in token.scopes
def create_ticket(token: Token, title: str) -> dict:
if not can_call(token, "data:write"):
return {
"ok": False,
"error": "insufficient_scope",
"required_scope": "data:write",
"message": "当前授权未包含创建工单权限",
}
# 在这里调用真实的工单系统。
return {"ok": True, "title": title}
read_only_token = Token(scopes={"profile", "data:read"})
print(create_ticket(read_only_token, "检查同步失败"))
真正的服务端还应返回符合 OAuth 约定的错误信息,例如 insufficient_scope,并在 MCP 工具描述、API 响应或用户界面中说明缺少哪个权限以及如何重新授权。
接入时需要重新审视的地方
把 scope 和能力一一对应。 一个 scope 最好代表清晰的动作或资源范围。admin 这类过于宽泛的权限很难让用户做出有意义的选择。
定义可用的降级路径。 可选权限只有在缺失时仍能提供合理体验才有价值。读取成功、写入失败时,代理可以改为生成草稿或请求用户确认,而不是直接报出无上下文的 403。
不要只依赖授权页面。 Token 的 scope 才是服务端最终应信任的事实。每个敏感操作都需要独立检查,并记录失败原因,方便排查代理行为。
谨慎处理代理的自动重试。 当用户明确拒绝写入权限时,代理不应反复发起同样的授权请求。可以在会话中缓存拒绝状态,并在用户主动触发写入操作时提供重新授权入口。
结语
Cloudflare 的可选 OAuth scopes 把“用户能否部分同意”推进了一步:开发者可以声明哪些权限不可缺少,哪些权限允许用户放弃。对 MCP 和其他代理系统而言,这种区分尤其重要,因为它们天然会提前请求未来可能用到的能力。
采用这类模型时,建议从权限设计开始,而不是只修改授权 URL:列出每项工具需要的 scope,标记核心与增强能力,定义缺权时的降级行为,再用服务端检查保证授权结果真正生效。这样,部分授权才会从界面上的选项变成系统中可验证、可维护的安全契约。