MCP 协议设计与实现
第15章 OAuth 2.1 认证框架
前三章我们完成了 MCP 传输层的全部分析——STDIO 解决本地通信,Streamable HTTP 解决远程通信,SSE 与 WebSocket 则已退场。但远程通信立刻带来一个不可回避的问题:认证与授权。
当 MCP Server 部署在公网上,任何人都能向它发送请求。一个管理 GitHub 仓库的 MCP Server,总不能让任何人都能通过它删除代码仓库。一个连接企业 CRM 的 MCP Server,总不能让未授权的用户读取客户数据。
本章从 MCP 规范和 TypeScript/Python SDK 源码出发,深入分析 MCP 为什么选择 OAuth 2.1、完整的授权流程如何运转、客户端注册的三种策略、以及认证与 Streamable HTTP 传输的集成方式。
15.1 为什么是 OAuth 2.1 而非 API Key
15.1.1 API Key 的致命缺陷
最直觉的认证方案是 API Key:Server 生成一个密钥,Client 每次请求带上这个密钥。简单、直接、容易实现。
但在 MCP 的场景下,API Key 方案有三个根本性问题。
第一,MCP Client 和 Server 之间通常没有预先关系。 一个用户在 Claude Desktop 里输入一个 MCP Server 的 URL,Claude Desktop 就需要连接这个 Server。两者之间没有任何预注册关系,API Key 从哪里来?让用户去 Server 的网站注册、复制密钥、粘贴到 Client——这个流程对普通用户来说是不可接受的。
第二,API Key 无法区分权限范围。 一个 API Key 要么有效、要么无效。但 MCP Server 可能暴露多种工具,有些工具只需要读权限,有些需要写权限。一个文件管理 Server 的用户可能只想授权"读取文件",而不想授权"删除文件"。API Key 无法实现这种细粒度的权限控制。
第三,API Key 一旦泄露就是灾难性的。 API Key 通常是长期有效的静态凭据。一旦被中间人截获或从客户端存储中泄露,攻击者就获得了与合法用户完全相同的权限,而且没有简单的方式来"部分撤销"权限。
15.1.2 OAuth 2.1 解决的核心问题
OAuth 2.1 是 OAuth 2.0 的安全增强版本(目前仍是 IETF 草案,MCP 2025-11-25 规范引用的是 draft-ietf-oauth-v2-1-13),它的核心思想是委托授权:用户不是把凭据交给 Client,而是通过 Authorization Server 授权 Client 代表自己访问资源。
这正好解决了 MCP 面临的三个问题:
- 无预先关系:OAuth 支持动态客户端注册和 Client ID Metadata Document,Client 可以在首次连接时自动完成注册
- 细粒度权限:OAuth 的 scope 机制允许用户精确控制授权范围——只授权"读文件"而不授权"删文件"
- 短期令牌:Access Token 可以设置较短的过期时间,即使泄露也只在有限时间内有效;Refresh Token 支持轮换,进一步降低风险
MCP 规范明确规定了角色映射关系:
| OAuth 角色 | MCP 角色 |
|---|---|
| Resource Server | MCP Server(受保护的资源服务器) |
| Client | MCP Client(代表用户访问资源) |
| Authorization Server | 独立的认证服务(可以与 MCP Server 同域或独立部署) |
| Resource Owner | 终端用户(授权 Client 访问自己的数据) |
15.1.3 认证是可选的
MCP 规范中,认证是 OPTIONAL 的。具体规则如下:
- HTTP 传输:SHOULD 遵循 MCP 的这份授权规范(基于 OAuth 2.1)
- STDIO 传输:SHOULD NOT 使用 OAuth,而应该从环境变量中获取凭据(因为 STDIO 是本地通信,进程间已经有操作系统层面的安全隔离)
- 其他传输:MUST 遵循该协议自身的安全最佳实践
这个设计很务实。本地调试时不需要走 OAuth 流程,部署到生产环境时又有完整的安全保障。
15.2 授权服务器发现
在 Client 能够发起 OAuth 流程之前,它首先需要知道一个关键信息:授权服务器在哪里? MCP Server 本身是资源服务器(Resource Server),但负责认证和发放令牌的是授权服务器(Authorization Server),二者可能部署在完全不同的域名上。
15.2.1 RFC 9728 Protected Resource Metadata
MCP 规范要求 Server 实现 RFC 9728(OAuth 2.0 Protected Resource Metadata)来公布自己关联的授权服务器(2025-06-18 版引入;2025-11-25 版按 SEP-985 让 WWW-Authenticate 与 well-known 两种发现方式二选一)。
发现流程从一个未认证的请求开始:
Client → MCP Server: 发送不带 Token 的请求
MCP Server → Client: 返回 HTTP 401 Unauthorized
401 响应中的 WWW-Authenticate 头部包含了关键信息:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"
Client 从这个头部提取两个信息:
resource_metadata:Protected Resource Metadata 文档的 URLscope:访问该资源所需的权限范围
TypeScript SDK 中,extractWWWAuthenticateParams 函数负责解析这个头部:
export function extractWWWAuthenticateParams(res: Response): {
resourceMetadataUrl?: URL;
scope?: string;
error?: string;
} {
const authenticateHeader = res.headers.get('WWW-Authenticate');
if (!authenticateHeader) return {};
const [type, scheme] = authenticateHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !scheme) return {};
const resourceMetadataMatch =
extractFieldFromWwwAuth(res, 'resource_metadata') || undefined;
// ... 解析 URL、scope、error
}
如果 WWW-Authenticate 头部中没有 resource_metadata,Client 必须回退到 Well-Known URI 探测,依次尝试:
https://example.com/.well-known/oauth-protected-resource/mcp(路径感知的发现)https://example.com/.well-known/oauth-protected-resource(根路径发现)
15.2.2 授权服务器元数据发现
获得 Protected Resource Metadata 后,Client 从中提取 authorization_servers 字段,得到授权服务器的 URL。接下来需要获取授权服务器自身的元数据(支持哪些端点、哪些认证方式等)。
MCP 规范要求 Client 同时支持两种发现机制,按优先级依次尝试:
对于有路径的 issuer URL(如 https://auth.example.com/tenant1):
- OAuth 2.0:
https://auth.example.com/.well-known/oauth-authorization-server/tenant1 - OIDC(路径插入):
https://auth.example.com/.well-known/openid-configuration/tenant1 - OIDC(路径追加):
https://auth.example.com/tenant1/.well-known/openid-configuration
对于无路径的 issuer URL(如 https://auth.example.com):
- OAuth 2.0:
https://auth.example.com/.well-known/oauth-authorization-server - OIDC:
https://auth.example.com/.well-known/openid-configuration
TypeScript SDK 中 buildDiscoveryUrls 函数构建了这些候选 URL:
export function buildDiscoveryUrls(
authorizationServerUrl: string | URL
): { url: URL; type: 'oauth' | 'oidc' }[] {
const url = typeof authorizationServerUrl === 'string'
? new URL(authorizationServerUrl) : authorizationServerUrl;
const hasPath = url.pathname !== '/';
const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = [];
if (!hasPath) {
urlsToTry.push(
{ url: new URL('/.well-known/oauth-authorization-server', url.origin), type: 'oauth' },
{ url: new URL('/.well-known/openid-configuration', url.origin), type: 'oidc' }
);
return urlsToTry;
}
// ... 有路径的情况,构建三个候选 URL
}
15.2.3 完整发现流程
将上述步骤串联起来,完整的授权服务器发现流程如下:
sequenceDiagram
participant C as MCP Client
participant M as MCP Server
participant A as Authorization Server
C->>M: MCP 请求(不带 Token)
M-->>C: 401 Unauthorized + WWW-Authenticate
alt WWW-Authenticate 中包含 resource_metadata
C->>M: GET resource_metadata URL
M-->>C: Protected Resource Metadata(含 authorization_servers)
else 回退到 Well-Known URI
C->>M: GET /.well-known/oauth-protected-resource/path
alt 找到
M-->>C: Protected Resource Metadata
else 未找到
C->>M: GET /.well-known/oauth-protected-resource
M-->>C: Protected Resource Metadata
end
end
Note over C: 从 metadata 中提取 authorization_servers URL
C->>A: GET /.well-known/oauth-authorization-server
alt OAuth 2.0 元数据可用
A-->>C: 授权服务器元数据
else 回退到 OIDC
C->>A: GET /.well-known/openid-configuration
A-->>C: OpenID Provider 元数据
end
Note over C: 已获得所有端点信息,可以开始 OAuth 流程
TypeScript SDK 中,discoverOAuthServerInfo 函数将上述整个流程封装为一次调用:
export async function discoverOAuthServerInfo(
serverUrl: string | URL,
opts?: { resourceMetadataUrl?: URL; fetchFn?: FetchLike }
): Promise<OAuthServerInfo> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | undefined;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, ...);
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl = resourceMetadata.authorization_servers[0];
}
} catch (error) {
if (error instanceof TypeError) throw error; // 网络错误直接抛出
// RFC 9728 不支持 —— 回退
}
if (!authorizationServerUrl) {
authorizationServerUrl = String(new URL('/', serverUrl));
}
const authorizationServerMetadata =
await discoverAuthorizationServerMetadata(authorizationServerUrl, ...);
return { authorizationServerUrl, authorizationServerMetadata, resourceMetadata };
}
值得注意的是这里的容错设计:当 TypeError(DNS 解析失败、连接被拒绝等网络层错误)发生时直接抛出,因为这是真正的网络故障;而其他错误(如 404)则被静默处理,回退到把 MCP Server URL 的源站根路径(new URL('/', serverUrl))当作授权服务器。这条回退是 2025-03-26 版规范的旧行为,用来兼容还没实现 RFC 9728 的老 Server;按 2025-11-25 版规范,Server MUST 提供 Protected Resource Metadata,Client 也 MUST 用它来发现授权服务器。
用本地假授权服务器实跑 TS Client(pin 版本地构建),Protected Resource Metadata 里的 authorization_servers 写成 http://localhost:8765/tenant1、只在 OIDC 路径追加位置提供元数据时,服务器收到的请求依次是:
POST /mcp ← 401 + WWW-Authenticate
GET /.well-known/oauth-protected-resource/mcp ← 取自 resource_metadata
GET /.well-known/oauth-authorization-server/tenant1 ← 404
GET /.well-known/openid-configuration/tenant1 ← 404
GET /tenant1/.well-known/openid-configuration ← 200
POST /tenant1/register
Python Client 在同一场景下的请求顺序完全相同。两步是串联关系:先拿 RFC 9728 元数据得到授权服务器地址,再对这个地址做 RFC 8414 / OIDC 探测,后一步并不是前一步失败后的回退。
15.3 客户端注册的三种策略
OAuth 流程要求 Client 持有一个 client_id,用来标识自己。但 MCP 的场景中,Client 和 Server 之间往往没有任何预先关系。MCP 规范提供了三种注册机制,按优先级从高到低排列。
15.3.1 预注册(Pre-registration)
最传统的方式:Client 的开发者预先在 Authorization Server 上注册,获得固定的 client_id 和 client_secret。
适用场景:Client 和 Server 有明确的合作关系,比如一家公司的内部 MCP Client 连接自家的 MCP Server。
15.3.2 Client ID Metadata Document(推荐)
这是 MCP 规范推荐的方式(2025-11-25 版按 SEP-991 引入),也是最常见场景的解决方案。核心思想是:Client 的 client_id 就是一个 HTTPS URL,这个 URL 指向一个 JSON 文档,描述了 Client 的元数据。
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"redirect_uris": ["http://127.0.0.1:3000/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
Authorization Server 收到一个 URL 格式的 client_id 时,会去获取这个 URL 对应的 JSON 文档,验证其中的 redirect_uris 等信息。这样就实现了"无预先关系"的客户端注册。
TypeScript SDK 中的相关逻辑(节选):
// authInternal() 内部,provider.clientInformation() 为空时才走到这里
const supportsUrlBasedClientId =
metadata?.client_id_metadata_document_supported === true;
const clientMetadataUrl = provider.clientMetadataUrl;
// (省略:clientMetadataUrl 不是带非根路径的 https URL 时抛 InvalidClientMetadata)
const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientMetadataUrl;
if (shouldUseUrlBasedClientId) {
// 直接使用 URL 作为 client_id
clientInformation = { client_id: clientMetadataUrl };
await provider.saveClientInformation?.(clientInformation);
}
Authorization Server 通过在其元数据中包含 client_id_metadata_document_supported: true 来声明支持这种方式。
15.3.3 动态客户端注册(RFC 7591)
当 Authorization Server 不支持 Client ID Metadata Document 时,Client 可以通过 RFC 7591 动态注册。这种方式是向 Authorization Server 元数据里的 registration_endpoint 发送 POST 请求:
export async function registerClient(
authorizationServerUrl: string | URL,
{ metadata, clientMetadata, scope, fetchFn }: { ... }
): Promise<OAuthClientInformationFull> {
let registrationUrl: URL;
if (metadata) {
if (!metadata.registration_endpoint) {
throw new Error('Incompatible auth server: does not support dynamic client registration');
}
registrationUrl = new URL(metadata.registration_endpoint);
} else {
registrationUrl = new URL('/register', authorizationServerUrl);
}
const response = await (fetchFn ?? fetch)(registrationUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...clientMetadata, ...(scope === undefined ? {} : { scope }) })
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return OAuthClientInformationFullSchema.parse(await response.json());
}
只有完全拿不到授权服务器元数据时,TS SDK 才退回猜 /register;拿到了元数据却没有 registration_endpoint,就直接抛错(实跑报 Incompatible auth server: does not support dynamic client registration)。Python SDK 在同样情况下不抛错,而是向 MCP Server 源站的 /register 发 POST(create_client_registration_request,src/mcp/client/auth/utils.py;实跑打到 POST /register、得到 404 后抛 OAuthRegistrationError)。
2026-07-28 版规范已把动态客户端注册标为弃用、推荐改用 Client ID Metadata Document,保留它只为兼容尚不支持 CIMD 的授权服务器(该版 changelog,PR #2858)。
15.3.4 三种策略的选择逻辑
flowchart TD
A[Client 需要 client_id] --> B{已有预注册信息?}
B -->|是| C[使用预注册的 client_id]
B -->|否| D{AS 支持 Client ID Metadata Document?}
D -->|是| E{Client 有 clientMetadataUrl?}
E -->|是| F[使用 URL 作为 client_id]
E -->|否| G{AS 支持动态注册?}
D -->|否| G
G -->|是| H[POST /register 动态注册]
G -->|否| I[提示用户手动输入 client_id]
style F fill:#e8f5e9
style C fill:#e8f5e9
style H fill:#fff3e0
style I fill:#ffebee
15.4 授权码流程与 PKCE
15.4.1 为什么必须用 PKCE
MCP 规范依据 OAuth 2.1 要求 Client MUST 实现 PKCE(Proof Key for Code Exchange)。在 MCP 场景中,Client 通常是桌面应用或 CLI 工具(即 OAuth 术语中的"公共客户端"),无法安全地保存 client_secret。
没有 PKCE 的情况下,授权码流程存在一个攻击窗口:攻击者如果拦截了从 Authorization Server 重定向回 Client 的授权码(通过恶意应用注册相同的 URI scheme 等方式),就能用这个授权码换取 Access Token。
PKCE 通过引入一对动态生成的值来关闭这个攻击窗口:
- code_verifier:Client 生成的随机字符串,保存在本地
- code_challenge:code_verifier 的 SHA-256 哈希再做 base64url 编码(即
S256方法),随授权请求发送给 Authorization Server
只有持有原始 code_verifier 的 Client 才能完成令牌交换,即使授权码被截获也无法使用。
15.4.2 完整的授权码流程
sequenceDiagram
participant U as 用户浏览器
participant C as MCP Client
participant M as MCP Server
participant A as Authorization Server
Note over C: 1. 发现阶段(见 15.2 节)
C->>M: 请求(无 Token)
M-->>C: 401 + WWW-Authenticate
C->>M: 获取 Protected Resource Metadata
C->>A: 获取 Authorization Server Metadata
Note over C: 2. 客户端注册(见 15.3 节)
alt 需要注册
C->>A: 注册客户端
A-->>C: client_id(+ client_secret)
end
Note over C: 3. 生成 PKCE 参数
Note over C: code_verifier = random(43~128 字符)
Note over C: code_challenge = BASE64URL(SHA256(code_verifier))
C->>U: 打开浏览器,跳转到授权 URL
Note right of U: URL 包含:<br/>response_type=code<br/>client_id=...<br/>code_challenge=...<br/>redirect_uri=...<br/>scope=...<br/>resource=...
U->>A: 用户登录并授权
A->>U: 重定向到 redirect_uri?code=AUTH_CODE
U->>C: Client 接收授权码
Note over C: 4. 用授权码换取令牌
C->>A: POST /token<br/>grant_type=authorization_code<br/>code=AUTH_CODE<br/>code_verifier=原始验证码<br/>resource=...
A-->>C: access_token + refresh_token
Note over C: 5. 使用令牌访问资源
C->>M: MCP 请求 + Authorization: Bearer token
M-->>C: MCP 响应
15.4.3 SDK 中的 PKCE 实现
TypeScript SDK 使用 pkce-challenge 库(锁定 5.0.1,不传长度时 verifier 默认 43 字符)生成 PKCE 参数,Python SDK 则手动实现、固定生成 128 字符的 verifier(实跑两边打到 /token 的 code_verifier 分别是 43 与 128 字符):
# src/mcp/client/auth/oauth2.py:56 的类,generate 在 :63
# Python SDK: PKCEParameters.generate()
class PKCEParameters(BaseModel):
code_verifier: str = Field(..., min_length=43, max_length=128)
code_challenge: str = Field(..., min_length=43, max_length=128)
@classmethod
def generate(cls) -> "PKCEParameters":
code_verifier = "".join(
secrets.choice(string.ascii_letters + string.digits + "-._~")
for _ in range(128)
)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return cls(code_verifier=code_verifier, code_challenge=code_challenge)
TypeScript SDK 的 startAuthorization 函数构建完整的授权 URL(节选,省略了 state、scope 与 offline_access 时追加 prompt=consent 的几行):
export async function startAuthorization(
authorizationServerUrl: string | URL,
{ metadata, clientInformation, redirectUrl, scope, state, resource }: { ... }
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
let authorizationUrl: URL;
if (metadata) {
authorizationUrl = new URL(metadata.authorization_endpoint);
// 验证 AS 支持 authorization_code 响应类型和 S256 挑战方法
if (!metadata.response_types_supported.includes('code')) {
throw new Error('Incompatible auth server: does not support response type code');
}
if (metadata.code_challenge_methods_supported &&
!metadata.code_challenge_methods_supported.includes('S256')) {
throw new Error('Incompatible auth server: does not support code challenge method S256');
}
} else {
authorizationUrl = new URL('/authorize', authorizationServerUrl);
}
// 生成 PKCE 挑战
const challenge = await pkceChallenge();
const codeVerifier = challenge.code_verifier;
// 构建授权 URL
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', clientInformation.client_id);
authorizationUrl.searchParams.set('code_challenge', challenge.code_challenge);
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
authorizationUrl.searchParams.set('redirect_uri', String(redirectUrl));
if (resource) {
authorizationUrl.searchParams.set('resource', resource.href);
}
return { authorizationUrl, codeVerifier };
}
注意 MCP 规范要求 Client MUST 使用 S256 挑战方法,并且 MUST 在授权前确认 Authorization Server 支持 PKCE。如果元数据中的 code_challenge_methods_supported 不包含 S256,Client 就无法按规范继续。如果该字段完全缺失,规范明确要求拒绝继续——这意味着 Authorization Server 不支持 PKCE。不过要注意上面 startAuthorization 的实现:它只在 code_challenge_methods_supported 存在时才校验是否包含 S256,字段缺失时(以及根本没拿到元数据时)并不拒绝继续,这是 TypeScript SDK 与规范的一处偏差。Python SDK 客户端则完全不检查这个字段(src/mcp/client/ 下没有任何代码读取 code_challenge_methods_supported)。用去掉该字段的假授权服务器实跑,两个 SDK 都照常发出 code_challenge_method=S256 并完成授权。
15.4.4 服务端 PKCE 验证的真实实现
PKCE 的另一半——服务端在 /token 端点收到 code_verifier 后的验证——容易被文档忽略,但实际决定了 PKCE 的安全性真不真。打开 src/mcp/server/auth/handlers/token.py:162-173:
# Verify PKCE code verifier
sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest()
hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=")
if hashed_code_verifier != auth_code.code_challenge:
# see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
return self.response(
TokenErrorResponse(
error="invalid_grant",
error_description="incorrect code_verifier",
)
)
三处值得看的细节:
1. 哈希和编码必须和客户端完全一致——sha256(verifier) → raw digest → urlsafe_b64encode → rstrip("=")。任何一步算法走偏都会让合法验证失败。特别是 rstrip("=")——标准 base64 会有末尾 = 填充、RFC 7636 明确要求去掉填充字符。客户端实现时也要严格遵守(Python SDK src/mcp/client/auth/oauth2.py:67 的 .rstrip("=") 和这里对称)。
2. 错误码是 invalid_grant 而非 invalid_request——引自 RFC 7636 Section 4.6。区分很重要:invalid_request 表示"请求格式错了"(比如漏参数),invalid_grant 表示"grant material 本身无效"。PKCE 失败是后者——code_verifier 对不上 challenge,本质是这张授权码已经失效(不再是"有效的 grant")。客户端收到 invalid_grant 的正确反应是放弃当前流程、重新走完整授权;收到 invalid_request 才是"修 bug 再试"。
3. 授权码校验和 PKCE 校验是两道独立门——看紧邻上方 token.py:141-160 的 redirect_uri 验证:
# src/mcp/server/auth/handlers/token.py:141
# verify redirect_uri doesn't change between /authorize and /tokens
# see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6
if auth_code.redirect_uri_provided_explicitly:
authorize_request_redirect_uri = auth_code.redirect_uri
...
if token_redirect_str != auth_redirect_str:
return self.response(TokenErrorResponse(
error="invalid_request",
error_description="redirect_uri did not match ...",
))
PKCE 之外另一道安全门:redirect_uri 必须和当初 /authorize 请求里一致——否则攻击者拿到授权码后用自己的 redirect_uri 换 token 的攻击也要被挡。RFC 6749 Section 10.6 专门讲这个威胁模型。注意 auth_code.redirect_uri_provided_explicitly 这个 flag——它记录的是 /authorize 请求里有没有显式传 redirect_uri(src/mcp/server/auth/handlers/authorize.py:204)。显式传了,/token 必须带上完全相同的值;没传(客户端只注册了一个 redirect_uri、由服务端取默认值),比较基准就是 None,/token 请求也不能带 redirect_uri,带了反而判为不匹配。这并不是跳过检查,而是要求两次请求的写法保持一致,兼容"单 URI 客户端可省略 redirect_uri"的用法。
两门合在一起的威胁覆盖矩阵:
| 攻击者持有 | 没 PKCE 也没 redirect 检查 | 有 redirect 检查但无 PKCE | 有 PKCE | 两者都有 |
|---|---|---|---|---|
| 仅授权码 | ✗ 即可换 token | ✗ 仍能换(照抄原 redirect_uri 即可) | ✓ 阻断(没 verifier) | ✓ 阻断 |
| 授权码+自己 redirect | ✗ | ✓ 阻断 | ✓ 阻断 | ✓ 阻断 |
| 全套(码+verifier+redirect) | — | — | — | — |
从表里看,单是 PKCE 就能挡住前两行;redirect_uri 一致性检查针对的是 RFC 6749 §10.6 描述的"篡改 redirect_uri 骗取授权码",两者引用不同 RFC、互为纵深。MCP Python SDK 实现里这两道校验连续排在一起,顺序是 redirect_uri 在前、PKCE 在后。
15.5 令牌管理
15.5.1 Access Token 的使用
获取到 Access Token 后,Client 在每一个 HTTP 请求中通过 Authorization 头部携带它:
GET /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
MCP 规范中有两个重要的安全要求:
- Token MUST NOT 放在 URL 查询参数中(防止被日志记录或 Referrer 头泄露)
- 每个 HTTP 请求都必须包含 Authorization 头,即使是同一个逻辑会话中的多个请求
15.5.2 令牌刷新
Access Token 通常有较短的有效期。当 Token 过期时,Client 使用 Refresh Token 获取新的 Access Token,而不需要用户重新授权。
TypeScript SDK 中的刷新逻辑:
// packages/client/src/client/auth.ts:1580
export async function refreshAuthorization(
authorizationServerUrl: string | URL,
{ metadata, clientInformation, refreshToken, resource, ... }: { ... }
): Promise<OAuthTokens> {
const tokenRequestParams = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken
});
const tokens = await executeTokenRequest(authorizationServerUrl, {
metadata, tokenRequestParams, clientInformation, resource, ...
});
// 如果 AS 没有返回新的 refresh_token,保留原来的
return { refresh_token: refreshToken, ...tokens };
}
注意最后一行的设计:{ refresh_token: refreshToken, ...tokens } 使用展开运算符,如果 tokens 中包含新的 refresh_token,它会覆盖前面的默认值;如果不包含,则保留原始的 refresh_token。这种写法既简洁又正确。
Python SDK 中的令牌有效性检查同样值得关注:
def is_token_valid(self) -> bool:
return bool(
self.current_tokens
and self.current_tokens.access_token
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
)
15.5.3 Scope 选择策略与步进授权
MCP 规范定义了明确的 scope 选择优先级:
- 使用 401 响应中
WWW-Authenticate头部的scope参数 - 如果没有,使用 Protected Resource Metadata 中的
scopes_supported - 如果都没有,省略
scope参数
TypeScript SDK 中的 determineScope 函数实现了这个逻辑,并在规范的两级之后加了一级 SDK 自己的兜底 clientMetadata.scope;同时还处理了 offline_access scope 的自动追加——当 Authorization Server 声明支持 offline_access 且 Client 的 grant_types 包含 refresh_token 时,自动将 offline_access 加入请求的 scope,以便拿到 refresh_token(授权服务器仍可不发)。这条追加规则来自 SEP-2207,2025-11-25 版规范正文里没有,2026-07-28 版规范才写成 MAY 条款:
export function determineScope(options: {
requestedScope?: string;
resourceMetadata?: OAuthProtectedResourceMetadata;
authServerMetadata?: AuthorizationServerMetadata;
clientMetadata: OAuthClientMetadata;
}): string | undefined {
const { requestedScope, resourceMetadata, authServerMetadata, clientMetadata } = options;
let effectiveScope = requestedScope
|| resourceMetadata?.scopes_supported?.join(' ')
|| clientMetadata.scope;
// 自动追加 offline_access
if (effectiveScope
&& authServerMetadata?.scopes_supported?.includes('offline_access')
&& !effectiveScope.split(' ').includes('offline_access')
&& clientMetadata.grant_types?.includes('refresh_token')) {
effectiveScope = `${effectiveScope} offline_access`;
}
return effectiveScope;
}
当 Client 在运行时遇到权限不足的情况,Server 会返回 403 并携带 insufficient_scope 错误:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="files:read files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
Client 收到这个响应后,应该使用 Server 指定的新 scope 重新发起授权流程(步进授权),让用户授予额外权限,然后用新令牌重试原始请求。TS SDK 的 StreamableHTTPClientTransport 会记住上一次触发升级的 WWW-Authenticate 头,同一个头再次返回 403 就抛 SdkError(ClientHttpForbidden),不会无限重试(packages/client/src/client/streamableHttp.ts:593-629)。
15.6 Resource Indicator 与令牌绑定
15.6.1 为什么需要 Resource Indicator
OAuth 令牌默认没有限定受众。一个 Authorization Server 可能服务多个 Resource Server,如果 Client 拿到的令牌可以用于任意 Resource Server,就存在令牌被滥用的风险。
RFC 8707(Resource Indicators for OAuth 2.0)通过 resource 参数解决这个问题:Client 在授权请求和令牌请求中指定目标资源的 URI,Authorization Server 将这个信息绑定到令牌中,Resource Server 在验证令牌时确认自己是预期的受众。
MCP 规范自 2025-06-18 版起要求 Client MUST 实现 RFC 8707。2025-11-25 版的 Resource Parameter Implementation 一节写明:resource 参数 MUST 同时出现在授权请求和令牌请求中,MUST 标识 Client 打算使用该令牌的那个 MCP Server,并且 MUST 使用 MCP Server 的规范 URI(canonical URI)。
15.6.2 资源 URL 的选择
TypeScript SDK 中 selectResourceURL 函数的逻辑体现了优先使用 Protected Resource Metadata 中声明的资源标识:
export async function selectResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,
resourceMetadata?: OAuthProtectedResourceMetadata
): Promise<URL | undefined> {
const defaultResource = resourceUrlFromServerUrl(serverUrl);
// provider 自定义了校验逻辑时,整件事交给它
if (provider.validateResourceURL) {
return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource);
}
// 只有拿到 Protected Resource Metadata 时才带 resource 参数
if (!resourceMetadata) return undefined;
// 验证 metadata 中的 resource 与我们的请求兼容
if (!checkResourceAllowed({
requestedResource: defaultResource,
configuredResource: resourceMetadata.resource
})) {
throw new Error(`Protected resource ${resourceMetadata.resource} does not match...`);
}
// 优先使用 metadata 中的 resource
return new URL(resourceMetadata.resource);
}
实跑中,TS 与 Python 两个 Client 发往 /authorize 和 /token 的请求都带上了 resource=http://localhost:8765/mcp。要注意 !resourceMetadata 那一行:Server 没提供 Protected Resource Metadata 时,TS SDK 根本不发 resource 参数;Python SDK 在这种情况下只看请求头里的协议版本,不低于 2025-06-18 才发(should_include_resource_param,src/mcp/client/auth/oauth2.py:160)。规范写的是"无论授权服务器支不支持都 MUST 发送",不过 2025-11-25 版同时要求 Server MUST 提供这份元数据,所以这个缺口只会在老 Server 上出现。
15.7 客户端认证方法
令牌请求需要客户端认证。TS SDK 内置三种方法(定义见 RFC 6749 §2.3.1 与 §2.1):
| 方法 | 描述 | 适用场景 |
|---|---|---|
client_secret_basic |
HTTP Basic 认证,client_id:client_secret Base64 编码放入 Authorization 头 |
有 client_secret 的机密客户端(SDK 首选;RFC 6749 要求授权服务器必须支持) |
client_secret_post |
client_id 和 client_secret 放在 POST 请求体中 |
有 client_secret 但 AS 不支持 Basic 的场景 |
none |
只发送 client_id,不发送 secret |
公共客户端(桌面应用、CLI 工具) |
TypeScript SDK 的 selectClientAuthMethod 函数实现了智能选择:
export function selectClientAuthMethod(
clientInformation: OAuthClientInformationMixed,
supportedMethods: string[]
): ClientAuthMethod {
const hasClientSecret = clientInformation.client_secret !== undefined;
// 优先使用注册时服务器指定的方法
if ('token_endpoint_auth_method' in clientInformation
&& clientInformation.token_endpoint_auth_method
&& isClientAuthMethod(clientInformation.token_endpoint_auth_method)
&& (supportedMethods.length === 0
|| supportedMethods.includes(clientInformation.token_endpoint_auth_method))) {
return clientInformation.token_endpoint_auth_method;
}
// 元数据没给 token_endpoint_auth_methods_supported:按 RFC 8414 §2 的默认值 client_secret_basic
if (supportedMethods.length === 0) {
return hasClientSecret ? 'client_secret_basic' : 'none';
}
// 按源码注释的"most secure first"顺序尝试
if (hasClientSecret && supportedMethods.includes('client_secret_basic'))
return 'client_secret_basic';
if (hasClientSecret && supportedMethods.includes('client_secret_post'))
return 'client_secret_post';
if (supportedMethods.includes('none'))
return 'none';
return hasClientSecret ? 'client_secret_post' : 'none';
}
15.8 认证与 Streamable HTTP 的集成
OAuth 认证工作在传输层。在 Streamable HTTP 传输中,认证集成的关键在于 AuthProvider 接口。
15.8.1 AuthProvider 抽象
TypeScript SDK 定义了一个极简的 AuthProvider 接口:
export interface AuthProvider {
token(): Promise<string | undefined>;
onUnauthorized?(ctx: UnauthorizedContext): Promise<void>;
}
对于简单场景(API Key、网关管理的令牌),只需要实现 token() 方法:
const authProvider: AuthProvider = {
token: async () => process.env.API_KEY
};
对于完整的 OAuth 流程,传入 OAuthClientProvider,SDK 通过 adaptOAuthProvider 自动适配:
export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
return {
token: async () => {
const tokens = await provider.tokens();
return tokens?.access_token;
},
onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx)
};
}
isOAuthClientProvider 类型守卫在构造时区分两种 provider,传输层无需关心具体是哪种认证方式。
15.8.2 请求-响应循环中的认证
每一个从 Client 到 Server 的 HTTP 请求都会经过以下流程:
- 传输层调用
authProvider.token()获取当前令牌 - 将令牌放入
Authorization: Bearer <token>头部 - 发送请求
- 如果收到 401 响应,调用
authProvider.onUnauthorized(ctx);没有实现这个方法就直接抛UnauthorizedError onUnauthorized尝试刷新令牌或重新授权;对 OAuth provider 来说,如果需要跳转浏览器(auth()返回'REDIRECT'),它会抛UnauthorizedError,由应用在用户授权后调用transport.finishAuth(code)再重新连接- 拿到新令牌时,用它重试请求一次
- 如果重试仍然 401,抛出
SdkError(ClientHttpAuthentication,消息为Server returned 401 after re-authentication)
以上各步都用本地假授权服务器实跑过:首次连接走到第 5 步的跳转分支、抛出 UnauthorizedError;预置过期 access token 加 refresh token 时,第 5 步用 grant_type=refresh_token 换到新令牌、第 6 步重试成功;让授权服务器发回一个 MCP Server 不认的令牌,就得到第 7 步的 SdkError。
这种设计让传输层完全不感知 OAuth 的复杂性。传输层只知道"问 provider 要 token"和"告诉 provider token 失效了"。
15.9 错误处理与安全恢复
15.9.1 分级错误恢复
auth 函数(TypeScript SDK)实现了分级的错误恢复策略:
// packages/client/src/client/auth.ts:540(签名在源码里是多行带类型)
export async function auth(provider, options): Promise<AuthResult> {
try {
return await authInternal(provider, options);
} catch (error) {
if (error instanceof OAuthError) {
if (error.code === OAuthErrorCode.InvalidClient
|| error.code === OAuthErrorCode.UnauthorizedClient) {
// 客户端凭据失效 → 清除所有凭据并重试
await provider.invalidateCredentials?.('all');
return await authInternal(provider, options);
} else if (error.code === OAuthErrorCode.InvalidGrant) {
// 令牌失效 → 只清除令牌并重试
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
}
}
throw error; // 其他错误直接抛出
}
}
注意 invalidateCredentials 的 scope 参数设计——'all'、'client'、'tokens'、'verifier'、'discovery'——允许精确控制清除哪些缓存的凭据,避免不必要的重新注册或重新发现。
15.9.2 令牌窃取防护
MCP 规范要求以下安全措施:
- Authorization Server SHOULD 签发短期 Access Token
- 对公共客户端,Authorization Server MUST 轮换 Refresh Token(每次使用后作废旧的、签发新的)
- Client 和 Server MUST 实现安全的令牌存储
- 所有授权端点 MUST 通过 HTTPS 服务
- 所有重定向 URI MUST 是 localhost 或 HTTPS
15.9.3 令牌传递的禁令
MCP 规范明确禁止令牌传递(Token Passthrough):MCP Server 收到 Client 的 Access Token 后,MUST NOT 将这个 Token 转发给上游 API。如果 MCP Server 需要调用上游 API,它可以作为上游的 OAuth Client,使用由上游授权服务器签发的另一枚独立 Token。与之配套,MCP Server MUST 校验收到的 Token 确实是签发给自己的(audience 校验,RFC 8707 §2),不是签给自己的一律拒绝。
这个禁令针对的是"困惑的代理"(Confused Deputy)风险——如果 MCP Server 将收到的 Token 原样转发给下游 API,下游 API 可能误以为这枚 Token 来自 MCP Server 自身,或以为它已经被上游校验过而予以信任。
15.9.4 TS SDK 与 Python SDK 在 OAuth 实现上的不对称:一个被忽视的工程现实
把两个官方 SDK 里文件名带 auth 的源码按规模实测(wc -l 物理行,pin 提交上重算与原数一致)——
TS SDK 实测(mcp-typescript-sdk)——
| 路径 | 行 | 角色 |
|---|---|---|
packages/core/src/shared/auth.ts |
252 | Zod schemas: OAuthProtectedResourceMetadataSchema / OAuthMetadataSchema 等 RFC 9728/8414 元数据类型 |
packages/core/src/auth/errors.ts |
132 | OAuthErrorCode 枚举与单一的 OAuthError 类 |
packages/core/src/shared/authUtils.ts |
57 | URL 校验等小工具 |
packages/client/src/client/auth.ts |
1745 | 客户端 OAuth 流程主体(discovery + PKCE + token 交换 + refresh) |
packages/client/src/client/authExtensions.ts |
702 | 非交互式 provider:ClientCredentialsProvider、PrivateKeyJwtProvider / StaticPrivateKeyJwtProvider(RFC 7523)、CrossAppAccessProvider |
| 客户端 + shared 合计 | 2888 | — |
packages/server/**/auth* |
0 | 没有任何文件——TS SDK v2 不提供服务端 OAuth 实现 |
packages/middleware/*/src/middleware/* |
134 | 仅 hostHeaderValidation 三套(Express/Fastify/Hono,不含 .examples.ts),无 auth middleware |
表中不含文件名不带 auth 的 packages/client/src/client/crossAppAccess.ts(303 行)。它和 CrossAppAccessProvider 一起实现 SEP-990 企业托管授权:先用 RFC 8693 token exchange 把 IdP 的 ID Token 换成 ID-JAG(JWT Authorization Grant),再用 RFC 7523 的 jwt-bearer grant 拿 ID-JAG 向 MCP 授权服务器换 access token。
Python SDK 实测(mcp-python-sdk)——
| 路径 | 行 | 角色 |
|---|---|---|
src/mcp/shared/auth.py |
170 | OAuth Pydantic 模型(同 TS 的 Zod schemas) |
src/mcp/shared/auth_utils.py |
80 | 工具 |
src/mcp/client/auth/oauth2.py |
646 | 客户端 OAuth 流程 |
src/mcp/client/auth/utils.py |
339 | 客户端工具 |
src/mcp/client/auth/extensions/client_credentials.py |
485 | client_credentials grant 扩展 |
src/mcp/client/auth/__init__.py + exceptions.py |
30 | 导出与异常类型 |
| shared + 客户端合计 | 1750 | shared 250(170 + 80)+ 客户端 client/auth/* 1500(646 + 339 + 485 + 30) |
src/mcp/server/auth/provider.py |
293 | 服务端 OAuthAuthorizationServerProvider 抽象 |
src/mcp/server/auth/routes.py |
245 | 完整 OAuth 端点路由(/authorize、/token、/register、/revoke、/.well-known/*) |
src/mcp/server/auth/handlers/authorize.py |
225 | /authorize handler |
src/mcp/server/auth/handlers/token.py |
219 | /token handler |
src/mcp/server/auth/handlers/register.py |
133 | RFC 7591 dynamic client registration |
src/mcp/server/auth/middleware/bearer_auth.py |
124 | Bearer token 验证中间件 |
src/mcp/server/auth/middleware/client_auth.py |
114 | 客户端认证中间件 |
src/mcp/server/auth/handlers/revoke.py |
87 | /revoke handler |
src/mcp/server/auth/middleware/auth_context.py |
46 | request-scoped auth context |
其余 server/auth 小文件(settings 30 / metadata 29 / json_response 10 / errors 5 / 三个 __init__.py) |
77 | 小工具 |
| 服务端合计 | 1563 | server/auth/** 十项:293 + 245 + 225 + 219 + 133 + 124 + 114 + 87 + 46 + 77 |
| 两段总计 | 3313 | 1750 与 1563 相加 |
两条值得记住的工程现实——
- TS SDK 没有服务端 OAuth、Python SDK 有完整服务端实现——这不是 TS SDK 一直缺,而是 v2 主动删掉的:
docs/migration.md:133-137("Server auth removed")列出mcpAuthRouter、OAuthServerProvider、requireBearerAuth、ProxyOAuthServerProvider等 v1 导出全部移除,建议改用专门的认证库(文中举的是better-auth)或完整的授权服务器,仓库examples/里的演示也是用 better-auth 搭的。这意味着用 TypeScript v2 写的 MCP Server 如果想做 OAuth,必须自己实现或接入授权服务器;用 Python 的话SDK 直接给一个 ASGI 路由套件(create_auth_routes(),src/mcp/server/auth/routes.py:64-142),实现好OAuthAuthorizationServerProvider(客户端、授权码、令牌的存取)后挂上去即可。注意本章 §15.8 的AuthProvider是 TS SDK 客户端侧的抽象(packages/client/src/client/auth.ts:67)——服务端侧 TS SDK 没有任何等价物 - TS 客户端
auth.ts1745 行 vs Python 客户端oauth2.py646 行——按物理行是 2.7 倍,但两边切分方式不同,不能直接比:auth.ts把 RFC 9728 / RFC 8414 发现逻辑也放在同一个文件里,Python 把这部分拆到src/mcp/client/auth/utils.py(339 行);auth.ts还导出一整套可单独调用的底层函数(含已标@deprecated的discoverOAuthMetadata、extractResourceMetadataUrl),并配了大量 JSDoc——以行首为//、/*、*粗略统计,注释约 600 行、空行 179 行。把 Python 的oauth2.py与utils.py合起来(985 行)对比,差距约 1.8 倍
这条不对称对部署架构的直接影响——本章 §15.3 讨论的"三种客户端注册策略"中第 3 种"动态客户端注册(RFC 7591)"——服务端只有 Python SDK 直接提供(src/mcp/server/auth/handlers/register.py 133 行);TS SDK v2 用户要想暴露 /register 端点,必须自己写或交给外部授权服务器。服务端 OAuth 因此成为两个 SDK 之间最大的能力差异。
15.9.5 OAuth 实现的两条主线:发现与持久化
OAuth 在 MCP 里最容易被误读成“拿到 token 后给 HTTP 请求加 Authorization 头”。SDK 的真实实现更像两条主线:先发现正确的授权服务器,再把客户端信息、PKCE verifier 和 token 状态持久化到会话存储里。TypeScript 侧的 OAuthClientProvider 接口说明了这一点:packages/client/src/client/auth.ts:143-181 要求 provider 提供 client metadata、clientInformation、可选的 saveClientInformation;auth.ts:183-210 又要求 tokens、saveTokens、redirectToAuthorization、saveCodeVerifier 与 codeVerifier。也就是说,OAuth provider 不是一个“token getter”,而是一个小型状态机。
401 触发的流程也不是直接跳登录页。auth.ts:103-113 的 handleOAuthUnauthorized() 会先从 WWW-Authenticate 里提取 resource metadata URL 与 scope,再调用 auth orchestrator;auth.ts:666-681 在没有可用缓存时执行完整发现,并把 authorization server URL、resource metadata 和 authorization server metadata 保存回 provider。客户端注册阶段还有三种分叉:auth.ts:703-741 先读取已有 clientInformation;如果授权服务器支持 URL-based client id 且 provider 提供 clientMetadataUrl,就直接用该 URL 作为 client_id;否则才回退到动态客户端注册。
PKCE 是第二条主线的安全核心。TypeScript 的 startAuthorization() 在 packages/client/src/client/auth.ts:1364-1430 生成 code verifier 与 code challenge,把 response_type=code、client_id、code_challenge、challenge method、redirect_uri、可选 scope 与 resource 写入授权 URL;packages/client/src/client/auth.ts:1444-1454 的 prepareAuthorizationCodeRequest() 再把 authorization code、code_verifier 和 redirect_uri 组装成 token 请求参数,交给 executeTokenRequest() 发往 token endpoint。Python 侧也做同一件事:src/mcp/client/auth/oauth2.py:56-68 生成 128 字符 verifier,再用 SHA-256 和 base64url 得到 challenge;src/mcp/client/auth/oauth2.py:179-214 根据 client_secret_basic、client_secret_post 或 none 准备 token 请求认证。
Python 的 401 处理更适合看完整顺序。src/mcp/client/auth/oauth2.py:529-557 先从 401 的 WWW-Authenticate 中提取 resource_metadata,按优先级发现 Protected Resource Metadata,并把第一个 authorization server 作为 auth_server_url;src/mcp/client/auth/oauth2.py:561-585 再发现 Authorization Server Metadata 并选择 scope;src/mcp/client/auth/oauth2.py:587-610 决定使用 URL-based client id 还是动态注册;src/mcp/client/auth/oauth2.py:612-621 完成授权、换 token、保存 token 后重试原请求。这个顺序说明“认证失败”在 MCP 远程传输里不是简单异常,而是一次可恢复的协议外流程。
服务端也有对应边界。src/mcp/server/auth/routes.py:64-142 创建 .well-known/oauth-authorization-server、/authorize、/token、可选 /register 与 /revoke 路由;src/mcp/server/auth/routes.py:145-182 的 build_metadata() 明确声明 authorization_code、refresh_token、client_secret_post/basic 与 S256;src/mcp/server/auth/routes.py:185-245 又按 RFC 9728 为 resource server 构造 /.well-known/oauth-protected-resource... 路由。客户端和服务端共同形成的事实是:MCP OAuth 的重点不在“有没有 token”,而在 token 是否绑定正确资源、scope 是否来自可信元数据、client id 是否能跨会话稳定复用、PKCE verifier 是否只属于当前授权会话。
这也解释了为什么 API Key 在远程 MCP 里只能作为网关级简化方案,而不能替代 OAuth 语义。API Key 通常缺少资源绑定、scope 升级、用户重定向、refresh token、撤销端点和客户端注册流程;一旦 server 需要区分“谁在访问哪个资源、授权服务器是否认可这个 client、token 是否还能刷新”,API Key 就会把所有问题塞回业务代码。OAuth 的复杂度看似高,实质是在协议和 SDK 层提前支付安全成本。
落地时要把三类存储分开:clientInformation 是客户端身份,应跨会话复用;code verifier 是一次授权会话的临时秘密,不能跨会话复用;access/refresh token 是用户授权结果,必须按用户、server、resource 隔离。把这三类数据混在同一个“token cache”里,是 CLI 与桌面客户端容易踩的安全坑。
错误恢复也要分层。401 表示当前请求缺少有效授权,客户端可以发现 metadata、走授权、换 token 后重试;403 加 insufficient_scope 更像权限升级,客户端应重新计算 scope 并让用户确认;token endpoint 返回错误,则不能无限重试,应清理临时授权状态并把错误展示给用户。把三者都处理成“重新登录一次”,会造成循环授权、scope 越拿越大,以及用户无法判断到底是凭证过期还是权限不足。
最后,OAuth 日志要脱敏但不能失明。可以记录 issuer、resource、scope 来源、grant type、注册方式和错误码;不要记录 access token、refresh token、authorization code 或 code verifier。没有这些结构化日志,远程 MCP 的认证问题会全部退化成“连不上”;有了它们,才能回答是哪个资源、哪个授权服务器、哪个 scope、哪个注册方式、在哪一步失败。
15.10 本章小结
MCP 的 OAuth 2.1 认证框架是整个协议中最复杂的子系统。这种复杂性是必要的——它解决了远程 MCP Server 面临的认证与授权难题,同时兼顾了"Client 和 Server 之间没有预先关系"这一核心场景。
回顾本章的关键设计决策:
- 选择 OAuth 2.1 而非 API Key,因为 MCP 需要委托授权、细粒度权限控制和短期令牌
- 基于 RFC 9728 的授权服务器发现,让 Client 无需硬编码任何认证端点
- 三种客户端注册策略(预注册 > Client ID Metadata Document > 动态注册),覆盖从企业内部到完全开放的各种场景
- 强制 PKCE,保护公共客户端免受授权码截获攻击
- Resource Indicator 令牌绑定,防止令牌在多个资源服务器之间被滥用
- 认证与传输层解耦,通过
AuthProvider接口让传输层无需了解 OAuth 细节