Skip to content

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 6750Bearer 令牌的使用与错误响应
RFC 8414授权服务器元数据(OAuth AS Metadata)
RFC 9728受保护资源元数据(Protected Resource Metadata)
RFC 8707Resource 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)来声明授权服务器位置。返回的元数据 MUSTauthorization_servers 字段,至少列一个授权服务器。

服务器必须至少提供一种发现机制:

  1. WWW-Authenticate:在返回 401 Unauthorized 时,用 resource_metadata 参数给出元数据 URL。
  2. Well-known URI:在约定路径直接提供元数据,可以是
    • 挂在 MCP 端点路径上:https://example.com/.well-known/oauth-protected-resource/public/mcp
    • 或挂在根上:https://example.com/.well-known/oauth-protected-resource

客户端 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):

  1. https://auth.example.com/.well-known/oauth-authorization-server/tenant1
  2. https://auth.example.com/.well-known/openid-configuration/tenant1
  3. https://auth.example.com/tenant1/.well-known/openid-configuration

issuer 不带路径(如 https://auth.example.com):

  1. https://auth.example.com/.well-known/oauth-authorization-server
  2. https://auth.example.com/.well-known/openid-configuration

⚠️ 取回元数据后必须校验 issuer:文档里的 issuer MUST 与用来构造 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 按此优先级选择

  1. 有该服务器的预注册信息 → 直接用
  2. 授权服务器声明支持 CIMD(client_id_metadata_document_supported: true)→ 用 CIMD
  3. 授权服务器有 registration_endpoint → 回退到 DCR
  4. 都不行 → 提示用户手动填入客户端信息

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_idclient_nameredirect_uris
  • 文档里的 client_id MUST 与文档 URL 完全一致
  • MAYprivate_key_jwt 做客户端认证
json
{
  "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_id SHOULD 去取文档
  • 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 参数显式声明"这个令牌是给哪个资源用的":

  1. MUST 同时出现在授权请求令牌请求
  2. MUST 标识客户端打算使用该令牌的 MCP 服务器
  3. MUST 使用 MCP 服务器的规范 URI(canonical URI)

合法的规范 URI:

  • https://mcp.example.com/mcp
  • https://mcp.example.com
  • https://mcp.example.com:8443
  • https://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 接受或转发其他令牌
http
GET /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

🔁 刷新令牌

角色要求
客户端MUST 在传输与存储中保密;SHOULD 在 grant_types 里声明 refresh_tokenMUST NOT 假设一定会签发(授权服务器有裁量权)
服务器WWW-Authenticate 的 scope 与 PRM 的 scopes_supported SHOULD NOT 包含 offline_access——刷新令牌不是资源需求

需要刷新令牌时,若授权服务器元数据的 scopes_supported 含它,可在授权与令牌请求的 scope 里加 offline_access

⚠️ 错误处理

状态码语义

状态码含义使用场景
401Unauthorized需要授权,或令牌无效/过期
403Forbiddenscope 不合法或权限不足
400Bad Request授权请求格式错误

Scope 选择策略

服务器 SHOULDWWW-Authenticate 里带 scope 参数,告诉客户端"这次操作需要什么权限":

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"

客户端首次授权时 SHOULD 按此优先级选 scope:

  1. 用 401 响应 WWW-Authenticate 里的 scope
  2. 没有就用 PRM 里的 scopes_supported;若 scopes_supported 未定义,则干脆不加 scope 参数

运行时权限不足与 Step-Up 授权

已经拿到令牌、但某个操作权限不够时,服务器 SHOULD 返回 403 + WWW-Authenticate

http
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(增量授权) 流程:

  1. 从响应或 WWW-Authenticate 里解析错误信息
  2. 计算所需 scope = 之前已请求的 scope 集合 ∪ 当前挑战里的 scope(保留已有权限)
  3. 用新的 scope 集合发起(重新)授权
  4. 带新授权重试原请求,限制重试次数,超出即视为永久失败

💡 服务器 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) 上实跑验证过,不是示意代码。

bash
# 依赖
pip install "mcp>=2.2" pyjwt      # 资源服务器侧示例用 PyJWT 签名/校验令牌

示例 A:把 MCP 服务器变成受保护的资源服务器

这是「服务器侧」要写的全部代码——注意它有多短:

python
# 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.resourceresource_server_url

示例 B:客户端接入

python
# 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 全包了。实跑时在服务器端能看到它严格的请求顺序:

text
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 逐个打,这是真实输出:

令牌情形HTTPWWW-Authenticate 里的 error
aud / iss / scope 全对200——(返回 mcp-session-id
aud 指向另一个资源(令牌混淆)401invalid_token
iss 不是预期的授权服务器401invalid_token
签名伪造 / 密钥不对401invalid_token
已过期401invalid_token
docs:read scope403insufficient_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 本机实测 mcp 2.2.0,insufficient_scope 的响应只有 error / error_description / resource_metadata没有 scope 参数:

http
HTTP/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), 不能指望中间件默认给全。

📖 官方资料


👉 下一小节:12.2 SEP 协议改进流程

内容依据 MCP 官方规范整理 · 规范以 官方文档 为准