Skip to content

第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
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 函数负责解析这个头部:

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

typescript
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 完整发现流程

将上述步骤串联起来,完整的授权服务器发现流程如下:

TypeScript SDK 中,discoverOAuthServerInfo 函数将上述整个流程封装为一次调用:

typescript
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 的元数据。

json
{
  "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 中的相关逻辑:

typescript
// 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 请求:

typescript
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 三种策略的选择逻辑

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 完整的授权码流程

15.4.3 SDK 中的 PKCE 实现

TypeScript SDK 使用 pkce-challenge 库生成 PKCE 参数,Python SDK 则手动实现:

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

typescript
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.5 令牌管理

15.5.1 Access Token 的使用

获取到 Access Token 后,Client 在每一个 HTTP 请求中通过 Authorization 头部携带它:

http
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 中的刷新逻辑:

typescript
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 中的令牌有效性检查同样值得关注:

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

typescript
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
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 中声明的资源标识:

typescript
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 函数实现了智能选择:

typescript
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 接口:

typescript
export interface AuthProvider {
  token(): Promise<string | undefined>;
  onUnauthorized?(ctx: UnauthorizedContext): Promise<void>;
}

对于简单场景(API Key、网关管理的令牌),只需要实现 token() 方法:

typescript
const authProvider: AuthProvider = {
  token: async () => process.env.API_KEY
};

对于完整的 OAuth 流程,传入 OAuthClientProvider,SDK 通过 adaptOAuthProvider 自动适配:

typescript
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)实现了分级的错误恢复策略:

typescript
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.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 细节

认证是安全的基石,但不是安全的全部。下一章我们将讨论 MCP 的服务发现机制——在认证之前,Client 首先需要知道有哪些 Server 可以连接。

基于 VitePress 构建