MCP 协议设计与实现

第15章 OAuth 2.1 认证框架

作者 杨艺韬 · 7,981 字

第15章 OAuth 2.1 认证框架

前两章我们完成了 MCP 传输层的全部分析——STDIO 解决本地通信,Streamable HTTP 解决远程通信。但远程通信立刻带来一个不可回避的问题:认证与授权

当 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 的安全增强版本,它的核心思想是委托授权:用户不是把凭据交给 Client,而是通过 Authorization Server 授权 Client 代表自己访问资源。

这正好解决了 MCP 面临的三个问题:

  1. 无预先关系:OAuth 支持动态客户端注册和 Client ID Metadata Document,Client 可以在首次连接时自动完成注册
  2. 细粒度权限:OAuth 的 scope 机制允许用户精确控制授权范围——只授权”读文件”而不授权”删文件”
  3. 短期令牌:Access Token 可以设置较短的过期时间,即使泄露也只在有限时间内有效;Refresh Token 支持轮换,进一步降低风险

MCP 规范明确规定了角色映射关系:

OAuth 角色MCP 角色
Resource ServerMCP Server(受保护的资源服务器)
ClientMCP Client(代表用户访问资源)
Authorization Server独立的认证服务(可以与 MCP Server 同域或独立部署)
Resource Owner终端用户(授权 Client 访问自己的数据)

15.1.3 认证是可选的

MCP 规范中,认证是 OPTIONAL 的。具体规则如下:

  • HTTP 传输:SHOULD 遵循 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)来公布自己关联的授权服务器。

发现流程从一个未认证的请求开始:

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 从这个头部提取两个信息:

  1. resource_metadata:Protected Resource Metadata 文档的 URL
  2. scope:访问该资源所需的权限范围

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 探测,依次尝试:

  1. https://example.com/.well-known/oauth-protected-resource/mcp(路径感知的发现)
  2. 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):

  1. OAuth 2.0:https://auth.example.com/.well-known/oauth-authorization-server/tenant1
  2. OIDC(路径插入):https://auth.example.com/.well-known/openid-configuration/tenant1
  3. OIDC(路径追加):https://auth.example.com/tenant1/.well-known/openid-configuration

对于无路径的 issuer URL(如 https://auth.example.com):

  1. OAuth 2.0:https://auth.example.com/.well-known/oauth-authorization-server
  2. 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?.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 当作授权服务器。

15.3 客户端注册的三种策略

OAuth 流程要求 Client 持有一个 client_id,用来标识自己。但 MCP 的场景中,Client 和 Server 之间往往没有任何预先关系。MCP 规范提供了三种注册机制,按优先级从高到低排列。

15.3.1 预注册(Pre-registration)

最传统的方式:Client 的开发者预先在 Authorization Server 上注册,获得固定的 client_idclient_secret

适用场景:Client 和 Server 有明确的合作关系,比如一家公司的内部 MCP Client 连接自家的 MCP Server。

15.3.2 Client ID Metadata Document(推荐)

这是 MCP 规范推荐的方式,也是最常见场景的解决方案。核心思想是: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 中的相关逻辑:

// auth() 函数内部
const supportsUrlBasedClientId =
  metadata?.client_id_metadata_document_supported === true;
const clientMetadataUrl = provider.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 的 /register 端点发送 POST 请求:

export async function registerClient(
  authorizationServerUrl: string | URL,
  { metadata, clientMetadata, scope, fetchFn }: { ... }
): Promise<OAuthClientInformationFull> {
  const registrationUrl = metadata?.registration_endpoint
    ? new URL(metadata.registration_endpoint)
    : new URL('/register', authorizationServerUrl);

  const response = await (fetchFn ?? fetch)(registrationUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...clientMetadata, ...(scope ? { scope } : {}) })
  });

  return OAuthClientInformationFullSchema.parse(await response.json());
}

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

OAuth 2.1 强制要求使用 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 哈希值,随授权请求发送给 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(128字符)
    Note over C: code_challenge = 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 库生成 PKCE 参数,Python SDK 则手动实现:

# 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:

export async function startAuthorization(
  authorizationServerUrl: string | URL,
  { metadata, clientInformation, redirectUrl, scope, state, resource }: { ... }
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
  // 验证 AS 支持 authorization_code 响应类型和 S256 挑战方法
  if (!metadata.response_types_supported.includes('code')) {
    throw new Error('Incompatible auth server: does not support response type code');
  }

  // 生成 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 挑战方法。如果 Authorization Server 的元数据中存在 code_challenge_methods_supported 但不包含 S256,Client 必须拒绝继续。如果该字段完全缺失,同样必须拒绝——这意味着 Authorization Server 不支持 PKCE。

15.4.4 服务端 PKCE 验证的真实实现

PKCE 的另一半——服务端在 /token 端点收到 code_verifier 后的验证——容易被文档忽略,但实际决定了 PKCE 的安全性真不真。打开 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 line 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 校验是两道独立门——看 line 140-160 紧邻上方的 redirect_uri 验证:

# 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 的情况下才做匹配检查;如果注册时只有一个 redirect_uri 并且用户没传、用的是默认,就跳过检查。这是对”单 URI 客户端可选省略 redirect_uri”的兼容。

两门合在一起的威胁覆盖矩阵

攻击者持有没 PKCE 也没 redirect 检查有 redirect 检查但无 PKCE有 PKCE两者都有
仅授权码✗ 即可换 token✗ 仍能换(换 redirect 即可)✓ 阻断(没 verifier)✓ 阻断
授权码+自己 redirect✓ 阻断✓ 阻断✓ 阻断
全套(码+verifier+redirect)

两个控制一个覆盖”重定向劫持”、一个覆盖”授权码中间人”——都必须存在才构成完整防护。MCP Python SDK 实现里这两道校验连续排在一起、顺序明确(redirect_uri 先、PKCE 后)、各自引用不同 RFC——是教科书级的 OAuth 2.1 token endpoint 实现范本。

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 规范中有两个重要的安全要求:

  1. Token MUST NOT 放在 URL 查询参数中(防止被日志记录或 Referrer 头泄露)
  2. 每个 HTTP 请求都必须包含 Authorization 头,即使是同一个逻辑会话中的多个请求

15.5.2 令牌刷新

Access Token 通常有较短的有效期。当 Token 过期时,Client 使用 Refresh Token 获取新的 Access Token,而不需要用户重新授权。

TypeScript SDK 中的刷新逻辑:

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 选择优先级:

  1. 使用 401 响应中 WWW-Authenticate 头部的 scope 参数
  2. 如果没有,使用 Protected Resource Metadata 中的 scopes_supported
  3. 如果都没有,省略 scope 参数

TypeScript SDK 中的 determineScope 函数实现了这个逻辑,同时还处理了 offline_access scope 的自动追加——当 Authorization Server 声明支持 offline_access 且 Client 的 grant_types 包含 refresh_token 时,自动将 offline_access 加入请求的 scope,确保能获取到 refresh_token:

export function determineScope(options: {
  requestedScope?: string;
  resourceMetadata?: OAuthProtectedResourceMetadata;
  authServerMetadata?: AuthorizationServerMetadata;
  clientMetadata: OAuthClientMetadata;
}): string | undefined {
  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 重新发起授权流程(步进授权),让用户授予额外权限,然后用新令牌重试原始请求。

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 规范要求 Client MUST 在授权和令牌请求中包含 resource 参数,即使 Authorization Server 不支持这个参数——这是一种前瞻性设计,确保当 Authorization Server 将来开始支持时,Client 已经在发送正确的参数。

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);

  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);
}

15.7 客户端认证方法

令牌请求需要客户端认证。MCP 支持 OAuth 2.1 定义的三种方法:

方法描述适用场景
client_secret_basicHTTP Basic 认证,client_id:client_secret Base64 编码放入 Authorization 头有 client_secret 的机密客户端(最安全)
client_secret_postclient_idclient_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
    && isClientAuthMethod(clientInformation.token_endpoint_auth_method)
    && (supportedMethods.length === 0
      || supportedMethods.includes(clientInformation.token_endpoint_auth_method))) {
    return clientInformation.token_endpoint_auth_method;
  }

  // 按安全性从高到低尝试
  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 请求都会经过以下流程:

  1. 传输层调用 authProvider.token() 获取当前令牌
  2. 将令牌放入 Authorization: Bearer <token> 头部
  3. 发送请求
  4. 如果收到 401 响应,调用 authProvider.onUnauthorized(ctx)
  5. onUnauthorized 尝试刷新令牌或重新授权
  6. 使用新令牌重试请求一次
  7. 如果重试仍然 401,抛出 UnauthorizedError

这种设计让传输层完全不感知 OAuth 的复杂性。传输层只知道”问 provider 要 token”和”告诉 provider token 失效了”。

15.9 错误处理与安全恢复

15.9.1 分级错误恢复

auth 函数(TypeScript SDK)实现了分级的错误恢复策略:

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 重新获取针对上游 API 的独立 Token。

这个禁令防止了”困惑的代理”(Confused Deputy)攻击——如果 MCP Server 将收到的 Token 直接转发给上游 API,上游 API 无法区分请求是来自 MCP Server 自身还是来自通过 MCP Server 代理的不可信 Client。

15.9.4 TS SDK 与 Python SDK 在 OAuth 实现上的不对称:一个被忽视的工程现实

把两个官方 SDK 的 auth 相关源码全部按规模实测——

TS SDK 实测(mcp-typescript-sdk)——

路径角色
packages/core/src/shared/auth.ts252Zod schemas: OAuthProtectedResourceMetadataSchema / OAuthMetadataSchema 等 RFC 9728/8414 元数据类型
packages/core/src/auth/errors.ts132OAuthError 子类层级
packages/core/src/shared/authUtils.ts57URL 校验等小工具
packages/client/src/client/auth.ts1745客户端 OAuth 流程主体(discovery + PKCE + token 交换 + refresh)
packages/client/src/client/authExtensions.ts702扩展机制(custom client auth methods)
客户端 + shared 合计2888
packages/server/**/auth*0没有任何文件——TS SDK 完全不提供服务端 OAuth 实现
packages/middleware/*/src/middleware/*134仅 hostHeaderValidation 三套(Express/Fastify/Hono),无 auth middleware

Python SDK 实测(mcp-python-sdk)——

路径角色
mcp/shared/auth.py170OAuth Pydantic 模型(同 TS 的 Zod schemas)
mcp/shared/auth_utils.py80工具
mcp/client/auth/oauth2.py646客户端 OAuth 流程
mcp/client/auth/utils.py339客户端工具
mcp/client/auth/extensions/client_credentials.py485client_credentials grant 扩展
mcp/server/auth/provider.py293服务端 OAuthAuthorizationServerProvider 抽象
mcp/server/auth/routes.py245完整 OAuth 端点路由/authorize/token/register/revoke/.well-known/*
mcp/server/auth/handlers/authorize.py225/authorize handler
mcp/server/auth/handlers/token.py219/token handler
mcp/server/auth/handlers/register.py133RFC 7591 dynamic client registration
mcp/server/auth/middleware/bearer_auth.py124Bearer token 验证中间件
mcp/server/auth/middleware/client_auth.py114客户端认证中间件
mcp/server/auth/handlers/revoke.py87/revoke handler
mcp/server/auth/middleware/auth_context.py46request-scoped auth context
其余 server/auth 小文件(settings / errors / metadata / json_response / init< 30 each小工具
客户端合计1470
服务端合计1572
shared + 客户端 + 服务端 总计3313

两条值得记住的工程现实——

  1. TS SDK 没有服务端 OAuth、Python SDK 有完整服务端实现——这意味着用 TypeScript 写的 MCP Server 如果想做 OAuth、必须自己实现 /authorize /token /register /revoke /.well-known/oauth-authorization-server 全套端点(或者接入第三方如 Auth0 / Keycloak / WorkOS);用 Python 的话SDK 直接给一个 ASGI 路由套件,几行代码挂上去就能跑——这是为什么本章 §15.8 AuthProvider 抽象的代码示例几乎都用 Python 风格——TS SDK 根本没有等价的服务端抽象
  2. TS 客户端 auth.ts 1745 行 vs Python 客户端 oauth2.py 646 行——TS 客户端是 Python 的 2.7 倍——根因有二:(a) TS 用 Zod runtime validation 对每条响应都跑 schema 校验、Python 直接走 Pydantic(同样校验但单行;TS 校验代码常需展开 try/catch + error formatting 多几行);(b) TS 客户端把 authExtensions.ts 702 行拆成独立扩展接口、Python 把 client_credentials 等扩展放在 extensions/client_credentials.py(485 行)但用法更统一

这条不对称对部署架构的直接影响——本章 §15.3 讨论的”三种客户端注册策略”中第 3 种”动态客户端注册(RFC 7591)“——只有 Python SDK 直接可用(mcp/server/auth/handlers/register.py 133 行成熟实现);TS SDK 用户要想暴露 /register 端点必须自己写。这也部分解释了为什么开源的 MCP Server demos 几乎全是 Python——服务端 OAuth 是重要的差异化能力。

15.9.5 OAuth 实现的两条主线:发现与持久化

OAuth 在 MCP 里最容易被误读成“拿到 token 后给 HTTP 请求加 Authorization 头”。SDK 的真实实现更像两条主线:先发现正确的授权服务器,再把客户端信息、PKCE verifier 和 token 状态持久化到会话存储里。TypeScript 侧的 OAuthClientProvider 接口说明了这一点:mcp-typescript-sdk/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-113handleOAuthUnauthorized() 会先从 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()auth.ts:1364-1430 生成 code verifier 与 code challenge,把 response_type=code、client_id、code_challenge、challenge method、redirect_uri、可选 scope 与 resource 写入授权 URL;auth.ts:1444-1454 的 token exchange 再把 authorization code、code_verifier 和 redirect_uri 发给 token endpoint。Python 侧也做同一件事:mcp-python-sdk/src/mcp/client/auth/oauth2.py:56-68 生成 128 字符 verifier,再用 SHA-256 和 base64url 得到 challenge;oauth2.py:179-214 根据 client_secret_basic、client_secret_post 或 none 准备 token 请求认证。

Python 的 401 处理更适合看完整顺序。oauth2.py:529-557 先从 401 的 WWW-Authenticate 中提取 resource_metadata,按优先级发现 Protected Resource Metadata,并把第一个 authorization server 作为 auth_server_url;oauth2.py:561-585 再发现 Authorization Server Metadata 并选择 scope;oauth2.py:587-610 决定使用 URL-based client id 还是动态注册;oauth2.py:612-621 完成授权、换 token、保存 token 后重试原请求。这个顺序说明“认证失败”在 MCP 远程传输里不是简单异常,而是一次可恢复的协议外流程。

服务端也有对应边界。mcp-python-sdk/src/mcp/server/auth/routes.py:64-142 创建 .well-known/oauth-authorization-server/authorize/token、可选 /register/revoke 路由;routes.py:145-180 的 metadata 明确声明 authorization_code、refresh_token、client_secret_post/basic 与 S256;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、哪个注册方式、在哪一步失败。

这些问题回答清楚,OAuth 才可运维。

否则只剩猜测。

15.10 本章小结

MCP 的 OAuth 2.1 认证框架是整个协议中最复杂的子系统。这种复杂性是必要的——它解决了远程 MCP Server 面临的认证与授权难题,同时兼顾了”Client 和 Server 之间没有预先关系”这一核心场景。

回顾本章的关键设计决策:

  1. 选择 OAuth 2.1 而非 API Key,因为 MCP 需要委托授权、细粒度权限控制和短期令牌
  2. 基于 RFC 9728 的授权服务器发现,让 Client 无需硬编码任何认证端点
  3. 三种客户端注册策略(预注册 > Client ID Metadata Document > 动态注册),覆盖从企业内部到完全开放的各种场景
  4. 强制 PKCE,保护公共客户端免受授权码截获攻击
  5. Resource Indicator 令牌绑定,防止令牌在多个资源服务器之间被滥用
  6. 认证与传输层解耦,通过 AuthProvider 接口让传输层无需了解 OAuth 细节

物理事实:Python SDK auth 3313 行 vs TS SDK auth 仅 2888 行(且无服务端实现)——本章 §15.3.3 提到的 RFC 7591 动态客户端注册只有 Python SDK 直接可用(server/auth/handlers/register.py 133 行)。TS SDK 用户要做 OAuth 服务端必须自己实现全套端点或接入 Auth0/Keycloak/WorkOS。