12.1 OAuth 2.1 授权体系
🎯 学习目标:把"客户端如何安全地访问一个受保护的远程 MCP 服务器"这条链路说清楚,并写出能跑的资源服务器与客户端 ⏱️ 预计时间:50 分钟(含动手部分 25 分钟) 📊 难度等级:⭐⭐⭐⭐(需了解 HTTP 与 OAuth 基础)
📌 一句话结论:MCP 的授权不在核心 JSON-RPC 协议里,而是挂在 HTTP 传输层——MCP 服务器扮演 OAuth 2.1 资源服务器(Resource Server),MCP 客户端扮演 OAuth 2.1 客户端(Client)。链路是:401 挑战 → 发现授权服务器 → 注册客户端 → 授权码 + PKCE → 带令牌访问。
🧭 先划清边界:什么时候需要 OAuth
官方规范对授权的态度很明确:授权是可选的(OPTIONAL),但一旦涉及 HTTP 传输,就有强约定。
| 你的传输方式 | 规范怎么说 |
|---|---|
| HTTP 类传输(Streamable HTTP) | SHOULD 遵循本授权规范 |
| stdio 传输 | SHOULD NOT 走本规范,改为从环境变量读取凭据 |
| 其他自定义传输 | MUST 遵循该协议既有的安全最佳实践 |
这条分界线很重要:本地 stdio 服务器不需要 OAuth。它跑在用户自己的机器上,凭据通过环境变量注入即可。只有当你把服务器部署成别人要远程连的受保护端点时,OAuth 才登场。
🎭 三个角色
| 角色 | 在 OAuth 里是什么 | 职责 |
|---|---|---|
| MCP 服务器 | OAuth 2.1 资源服务器 | 接受并校验访问令牌,保护自己的资源;对外声明"我的授权服务器在哪" |
| MCP 客户端 | OAuth 2.1 客户端 | 代表资源所有者发起受保护请求;负责发现、注册、授权、带令牌 |
| 授权服务器 | Authorization Server | 与用户交互、签发访问令牌;其实现细节不在 MCP 规范范围内,可与资源服务器同址或独立部署 |
📐 规范建立在哪些标准之上
MCP 没有重新发明授权,而是从既有标准里选了一个子集,以保证安全与互操作性、同时保持简单:
| 标准 | 作用 |
|---|---|
| OAuth 2.1(IETF DRAFT) | 授权主框架 |
| RFC 6750 | Bearer 令牌的使用与错误响应 |
| RFC 8414 | 授权服务器元数据(OAuth AS Metadata) |
| RFC 9728 | 受保护资源元数据(Protected Resource Metadata) |
| RFC 8707 | Resource Indicators —— 把令牌绑定到目标资源 |
| RFC 9207 | 授权服务器签发者标识(iss) |
| RFC 7591 | 动态客户端注册(已废弃,仅向后兼容) |
| OIDC Discovery 1.0 | 备选发现机制 |
| Client ID Metadata Documents(draft) | 推荐的客户端注册机制 |
🔄 完整授权流程
🔍 第一步:发现授权服务器
客户端要先知道"令牌该去哪要"。这一步分两跳:先发现资源服务器声明的授权服务器位置,再发现授权服务器自己的端点。
1.1 受保护资源元数据(PRM)
MCP 服务器 MUST 实现 OAuth 2.0 Protected Resource Metadata(RFC 9728)来声明授权服务器位置。返回的元数据 MUST 含 authorization_servers 字段,至少列一个授权服务器。
服务器必须至少提供一种发现机制:
WWW-Authenticate头:在返回401 Unauthorized时,用resource_metadata参数给出元数据 URL。- Well-known URI:在约定路径直接提供元数据,可以是
- 挂在 MCP 端点路径上:
https://example.com/.well-known/oauth-protected-resource/public/mcp - 或挂在根上:
https://example.com/.well-known/oauth-protected-resource
- 挂在 MCP 端点路径上:
客户端 MUST 同时支持这两种机制:优先用 WWW-Authenticate 里的 URL;拿不到时才按上面的顺序回退到 well-known URI 探测。
1.2 授权服务器元数据发现
MCP 复用标准后缀 oauth-authorization-server(RFC 8414),不定义自己的后缀。为兼容 OAuth 与 OIDC 的不同 issuer 格式,客户端 MUST 按顺序尝试多个端点:
issuer 带路径(如 https://auth.example.com/tenant1):
https://auth.example.com/.well-known/oauth-authorization-server/tenant1https://auth.example.com/.well-known/openid-configuration/tenant1https://auth.example.com/tenant1/.well-known/openid-configuration
issuer 不带路径(如 https://auth.example.com):
https://auth.example.com/.well-known/oauth-authorization-serverhttps://auth.example.com/.well-known/openid-configuration
⚠️ 取回元数据后必须校验 issuer:文档里的
issuerMUST 与用来构造 well-known URL 的 issuer 完全一致,否则不得使用该元数据。例如从https://attacker.example/...取回、却写着自己"issuer": "https://honest.example"的文档,必须拒绝。这是防"混淆攻击"的关键一环。
📝 第二步:客户端注册
发起授权前,客户端 MUST 通过三种机制之一拿到 client ID:
| 机制 | 规范用词 | 适用场景 |
|---|---|---|
| Client ID Metadata Documents(CIMD) | SHOULD | 客户端与服务器无既有关系(最常见) |
| 预注册(Pre-registration) | SHOULD 支持 | 客户端与服务器已有关系 |
| 动态客户端注册(DCR / RFC 7591) | MAY,已废弃 | 仅为兼容不支持 CIMD 的老授权服务器 |
支持全部选项的客户端 SHOULD 按此优先级选择:
- 有该服务器的预注册信息 → 直接用
- 授权服务器声明支持 CIMD(
client_id_metadata_document_supported: true)→ 用 CIMD - 授权服务器有
registration_endpoint→ 回退到 DCR - 都不行 → 提示用户手动填入客户端信息
2.1 Client ID Metadata Documents(推荐)
思路很巧:用 HTTPS URL 当 client_id,这个 URL 指向一份描述客户端元数据的 JSON 文档。授权服务器遇到 URL 形式的 client_id 时,去把这份文档取回来验证。
对客户端的要求:
- 元数据文档 MUST 托管在 HTTPS URL 上,且 URL MUST 带路径部分(如
https://example.com/client.json) - 文档 MUST 至少包含
client_id、client_name、redirect_uris - 文档里的
client_idMUST 与文档 URL 完全一致 - MAY 用
private_key_jwt做客户端认证
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"redirect_uris": [
"http://127.0.0.1:3000/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}对授权服务器的要求:
- 遇到 URL 形式的
client_idSHOULD 去取文档 - MUST 校验文档里的
client_id与 URL 完全一致 - MUST 校验授权请求里的
redirect_uri在文档声明的列表内 - SHOULD 按 HTTP 缓存头缓存该文档
2.2 动态客户端注册的坑
DCR 已废弃,但现实中还会遇到。一个高频坑点是 application_type:
- OIDC 下省略该参数会默认成
"web",与原生客户端常用的 loopback 重定向 URI 冲突 - 原生应用(桌面、移动、CLI、
localhost访问的本地 Web 应用)SHOULD 用application_type: "native" - Web 应用(非本地托管的浏览器应用)SHOULD 用
application_type: "web"
2.3 凭据绑定到授权服务器
多授权服务器场景下,client id 是每个授权服务器各自唯一的。客户端 MUST:
- 按授权服务器的
issuer分别保存注册状态(凭据、令牌),MUST NOT 假设一个授权服务器的凭据在另一个上有效 - 授权服务器变了(通过更新的受保护资源元数据察觉)MUST 重新注册
💡 CIMD 的 client id 是可移植的(自托管 HTTPS URL),换授权服务器不需要重新注册;预注册和 DCR 的凭据则必须绑定。
🔑 第三步:PKCE 与 resource 参数
授权请求这一步有两个不能省的动作。
3.1 Resource Indicators(RFC 8707)
客户端 MUST 实现 Resource Indicators,用 resource 参数显式声明"这个令牌是给哪个资源用的":
- MUST 同时出现在授权请求和令牌请求里
- MUST 标识客户端打算使用该令牌的 MCP 服务器
- MUST 使用 MCP 服务器的规范 URI(canonical URI)
合法的规范 URI:
https://mcp.example.com/mcphttps://mcp.example.comhttps://mcp.example.com:8443https://mcp.example.com/server/mcp(路径用于区分具体服务器时)
非法:
mcp.example.com(缺 scheme)https://mcp.example.com#fragment(含 fragment)
💡 建议统一用不带尾斜杠的形式,除非尾斜杠对该资源有语义。客户端 MUST 无论授权服务器是否支持该参数,都要发送它。
3.2 PKCE 与 iss 校验
- 客户端生成 PKCE 参数(
code_challenge),把code_verifier与预期 issuer记录在同一条按请求记录的存储里 - 授权服务器 SHOULD 在授权响应(含错误响应)里带
iss;带的话 MUST 在元数据里把authorization_response_iss_parameter_supported设为true - 客户端在把授权码发给任何令牌端点之前 MUST 按 RFC 9207 §2.4 校验:
元数据声明支持 iss | 响应含 iss | 客户端动作 |
|---|---|---|
true | 有 | 与记录的 issuer 做简单字符串比较 |
true | 无 | 拒绝该响应 |
false / 缺省 | 有 | 仍与记录的 issuer 比较(兼容提前发 iss 的服务器) |
false / 缺省 | 无 | 放行 |
⚠️ 比较时 MUST NOT 做 scheme/host 大小写折叠、默认端口省略、去尾斜杠或百分号解码归一化。校验失败时,客户端 MUST NOT 执行或展示响应里的
error/error_description/error_uri——防止钓鱼提示。
🎫 第四步:使用访问令牌
| 要求 | 内容 |
|---|---|
| 传递方式 | MUST 用请求头 Authorization: Bearer <access-token>,且每个 HTTP 请求都要带 |
| 禁止位置 | 访问令牌 MUST NOT 放进 URI 查询字符串 |
| 服务器校验 | MUST 校验令牌是专门签发给它自己的(audience 绑定,RFC 8707),失败按 401 处理 |
| 跨服务器 | 客户端 MUST NOT 把一个服务器的令牌发给另一个;服务器 MUST NOT 接受或转发其他令牌 |
GET /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...🔁 刷新令牌
| 角色 | 要求 |
|---|---|
| 客户端 | MUST 在传输与存储中保密;SHOULD 在 grant_types 里声明 refresh_token;MUST NOT 假设一定会签发(授权服务器有裁量权) |
| 服务器 | WWW-Authenticate 的 scope 与 PRM 的 scopes_supported SHOULD NOT 包含 offline_access——刷新令牌不是资源需求 |
需要刷新令牌时,若授权服务器元数据的 scopes_supported 含它,可在授权与令牌请求的 scope 里加 offline_access。
⚠️ 错误处理
状态码语义
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 401 | Unauthorized | 需要授权,或令牌无效/过期 |
| 403 | Forbidden | scope 不合法或权限不足 |
| 400 | Bad Request | 授权请求格式错误 |
Scope 选择策略
服务器 SHOULD 在 WWW-Authenticate 里带 scope 参数,告诉客户端"这次操作需要什么权限":
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"客户端首次授权时 SHOULD 按此优先级选 scope:
- 用 401 响应
WWW-Authenticate里的scope - 没有就用 PRM 里的
scopes_supported;若scopes_supported未定义,则干脆不加scope参数
运行时权限不足与 Step-Up 授权
已经拿到令牌、但某个操作权限不够时,服务器 SHOULD 返回 403 + WWW-Authenticate:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="File write permission required for this operation"客户端的 step-up(增量授权) 流程:
- 从响应或
WWW-Authenticate里解析错误信息 - 计算所需 scope = 之前已请求的 scope 集合 ∪ 当前挑战里的 scope(保留已有权限)
- 用新的 scope 集合发起(重新)授权
- 带新授权重试原请求,限制重试次数,超出即视为永久失败
💡 服务器 SHOULD 一次性把当前操作所需的 scope 全放进同一个挑战里,不要"挤牙膏"式一个个要——那会逼出多轮往返,用户体验很差。前端应当把"累积 scope"当作客户端职责。
🛡️ 安全要点速查
- 令牌混淆防范:
resource参数 + 服务器侧 audience 校验,缺一不可 - 授权码保护:PKCE 必做;授权码只发给校验过
iss后确认的令牌端点 - 发现过程防篡改:issuer 必须与 well-known URL 一致,否则丢弃元数据
- 凭据隔离:client 凭据按授权服务器隔离,不得跨服务器复用
- HTTPS:所有涉及令牌的通信必须走 TLS(loopback 重定向除外)
- token 不入 URL:避免出现在日志、Referer、浏览器历史里
⚠️ Authorization 扩展是独立仓库:官方把补充授权机制(如 OAuth Client Credentials、企业托管授权)放在 ext-auth 仓库,见 12.3 官方扩展体系。
🧪 落地提示:我需要写多少代码?
| 你要做的 | 说明 |
|---|---|
| 写 MCP 客户端 | 推荐用官方 SDK 的 OAuth Provider 能力,它已封装发现、CIMD/DCR、PKCE、iss 校验;你主要负责配置与 UI 交互 |
| 写 MCP 服务器 | 你实现的是资源服务器侧:实现 PRM 文档、返回正确的 401/403 挑战、校验令牌 audience。不实现授权服务器(那是独立组件,可用现成 IdP) |
| 只想本地跑 | 用 stdio + 环境变量,跳过整节 |
🛠️ 动手:两个可跑的最小示例
下面两个示例都在 Python SDK v2(mcp 2.2.0) 上实跑验证过,不是示意代码。
# 依赖
pip install "mcp>=2.2" pyjwt # 资源服务器侧示例用 PyJWT 签名/校验令牌示例 A:把 MCP 服务器变成受保护的资源服务器
这是「服务器侧」要写的全部代码——注意它有多短:
# server.py —— 受保护的远程 MCP 服务器(资源服务器)
import jwt # PyJWT
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
SECRET = "dev-only-secret" # 生产环境从密钥服务读取,不要硬编码
CANONICAL = "https://mcp.example.com/mcp" # 规范化资源 URI(RFC 8707 的 resource)
ISSUER = "https://auth.example.com" # 授权服务器 issuer
class JWTVerifier(TokenVerifier):
"""校验 Bearer 令牌。签名之外,audience 与 issuer 一并交给 JWT 库校验。"""
async def verify_token(self, token: str) -> AccessToken | None:
try:
claims = jwt.decode(
token,
SECRET,
algorithms=["HS256"],
audience=CANONICAL, # 令牌必须是签发给「我这个资源」的
issuer=ISSUER,
)
except jwt.PyJWTError:
return None # 返回 None 即视为无效令牌
return AccessToken(
token=token,
client_id=claims.get("client_id", "unknown"),
scopes=str(claims.get("scope", "")).split(),
expires_at=claims.get("exp"),
resource=claims.get("aud"),
subject=claims.get("sub"),
)
server = MCPServer(
name="Protected Docs",
auth=AuthSettings(
issuer_url=ISSUER,
resource_server_url=CANONICAL, # 设了它,PRM 端点会自动挂上
required_scopes=["docs:read"],
validate_token_resource=True, # 由中间件强制 audience 绑定
),
token_verifier=JWTVerifier(),
)
@server.tool()
def search_docs(query: str) -> str:
"""Search the internal docs."""
return f"results for {query!r}"
if __name__ == "__main__":
server.run(transport="streamable-http", host="127.0.0.1", port=8931)你不用手写协议层的那三件事,SDK 都替你做了:
| 规范要求 | SDK 行为 |
|---|---|
| 服务器 MUST 提供 PRM | 设了 resource_server_url 就自动注册 PRM 路由,内容由 issuer_url + required_scopes 填充 |
未授权时返回 401 + resource_metadata 定位 | 自动挂 RequireAuthMiddleware,挑战头里的 URL 用 RFC 9728 §3.1 的规则生成 |
| 令牌必须 audience 绑定 | validate_token_resource=True 时中间件比对 AccessToken.resource 与 resource_server_url |
示例 B:客户端接入
# client.py —— 接入受保护的远程 MCP 服务器
import asyncio
from mcp.client import Client
from mcp.client.auth.oauth2 import (
AuthorizationCodeResult,
OAuthClientProvider,
TokenStorage,
)
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
from mcp.shared.auth import OAuthClientMetadata
SERVER = "https://mcp.example.com/mcp"
class MemoryStorage(TokenStorage):
"""令牌与注册状态的内存实现,生产环境换成 keyring / 加密文件。
注意:注册状态必须按授权服务器 issuer 分别保存(见「2.3 凭据绑定到授权服务器」)。
"""
def __init__(self) -> None:
self._tokens = None
self._client_info = None
async def get_tokens(self):
return self._tokens
async def set_tokens(self, tokens):
self._tokens = tokens
async def get_client_info(self):
return self._client_info
async def set_client_info(self, info):
self._client_info = info
async def open_browser(url: str) -> None:
"""拿到授权 URL:调 webbrowser.open 打开系统浏览器。"""
print("请在浏览器中完成授权:\n", url)
async def wait_for_callback() -> AuthorizationCodeResult:
"""在本地 loopback 端口等授权服务器重定向回来,解析出 code / state / iss。
这是唯一必须自己写的一块(要真的起一个本地 HTTP 服务接回调)。
"""
raise NotImplementedError("在 http://127.0.0.1:3000/callback 上接住回调")
async def main() -> None:
oauth = OAuthClientProvider(
server_url=SERVER,
client_metadata=OAuthClientMetadata(
client_name="Meetcoding Docs Client",
redirect_uris=["http://127.0.0.1:3000/callback"],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
),
storage=MemoryStorage(),
redirect_handler=open_browser,
callback_handler=wait_for_callback,
)
# OAuth 是挂在 HTTP 客户端上的,不是挂在 MCP 客户端上
http_client = create_mcp_http_client(auth=oauth)
async with http_client:
transport = streamable_http_client(SERVER, http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
print([t.name for t in result.tools])
asyncio.run(main())这段代码里没有一行是发现、注册或 PKCE——OAuthClientProvider 全包了。实跑时在服务器端能看到它严格的请求顺序:
POST /mcp ← 先试探一次,拿到 401 + 挑战头
GET /.well-known/oauth-protected-resource/mcp ← 按挑战头里的 resource_metadata 取 PRM
GET /.well-known/oauth-authorization-server ← 再发现授权服务器(RFC 8414)
GET /.well-known/openid-configuration ← 拿不到,回退 OIDC Discovery
POST /register ← 授权服务器元数据仍拿不到,回退 DCR(这个 demo 服务器只做资源服务器、没有授权服务器端点,所以后两步必然 404。这恰好把回退链暴露出来了。)
这与「第一步/第二步」写的优先级顺序完全一致:挑战头 URL → well-known 回退 → CIMD 优先、DCR 兜底。
实测:服务器对六种令牌的反应
拿示例 A 逐个打,这是真实输出:
| 令牌情形 | HTTP | WWW-Authenticate 里的 error |
|---|---|---|
| aud / iss / scope 全对 | 200 | ——(返回 mcp-session-id) |
aud 指向另一个资源(令牌混淆) | 401 | invalid_token |
iss 不是预期的授权服务器 | 401 | invalid_token |
| 签名伪造 / 密钥不对 | 401 | invalid_token |
| 已过期 | 401 | invalid_token |
缺 docs:read scope | 403 | insufficient_scope |
「aud 不对 → 401」这一条最值得记住:它证明 RFC 8707 的 audience 绑定真的在拦令牌混淆,而不是只写在规范里。
两个实测踩到的坑
⚠️ 坑 1:PRM 不一定在根路径。 资源 URI 是
https://mcp.example.com/mcp时,PRM 挂在/.well-known/oauth-protected-resource/mcp,根路径/.well-known/oauth-protected-resource返回 404。 客户端如果只探根路径会直接失败。这也是规范要求客户端同时支持挑战头与 well-known 两种机制的原因。
⚠️ 坑 2:SDK 的 403 挑战里目前不带
scope。 本机实测mcp2.2.0,insufficient_scope的响应只有error/error_description/resource_metadata,没有scope参数:httpHTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", error_description="Required scope: docs:read", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"而「运行时权限不足与 Step-Up 授权」那一节说,客户端要靠挑战里的
scope算出累积权限集。 所以如果你想支持完整的 step-up,需要自己补这个头(或让服务器在业务层返回所需的 scope), 不能指望中间件默认给全。
📖 官方资料
- 📋 Authorization 规范总览
- 🔍 Authorization Server Discovery
- 📝 Client Registration
- 🛡️ Authorization Security Considerations
- 🎓 官方教程 · Authorization
- 🧩 Authorization 扩展仓库 ext-auth