第 24 课:MCP 进阶 — OAuth、沙箱与官方注册表

4 阅读7分钟

模块八:MCP 协议 | 前置依赖:第 23 课 | 预计学习时间:70 分钟


学习目标

完成本课后,你将能够:

  1. 说明 MCP OAuth 认证流程的完整生命周期(发现、授权、刷新、吊销)
  2. 解释 Elicitation 机制如何在终端中实现交互式认证
  3. 理解 MCP 工具的权限继承模型与沙箱隔离策略
  4. 描述官方注册表(officialRegistry)的安全审计作用及名称规范化的注入防护

24.1 OAuth 认证体系(auth.ts)

auth.ts 是 89KB 的认证核心,实现了完整的 OAuth 2.0 流程。远程 MCP Server(SSE/HTTP 类型)通常需要 OAuth 认证才能访问。

认证流程全景

用户运行 /mcp 或首次连接远程 MCP Server
      
      
┌─────────────────────────────┐
 1. OAuth 元数据发现          
    RFC 9728  RFC 8414      
    或使用配置的 metadataUrl   
└──────────────┬──────────────┘
               
               
┌─────────────────────────────┐
 2. 动态客户端注册(DCR)     
     AS 注册 Claude Code    
    获取 client_id            
└──────────────┬──────────────┘
               
               
┌─────────────────────────────┐
 3. 启动本地 HTTP 服务器      
    监听回调端口              
    打开浏览器  授权页面     
└──────────────┬──────────────┘
                用户在浏览器中授权
               
┌─────────────────────────────┐
 4. 接收授权码(回调)        
    用授权码换取 Token        
    存入安全存储(Keychain)   
└──────────────┬──────────────┘
               
               
       连接成功,工具可用

元数据发现

fetchAuthServerMetadata 按照 RFC 标准进行多级发现:

async function fetchAuthServerMetadata(
  serverName: string,
  serverUrl: string,
  configuredMetadataUrl: string | undefined,
  fetchFn?: FetchLike,
): Promise<AuthorizationServerMetadata | undefined> {
  // 优先级 1:用户配置的 metadataUrl(必须 HTTPS)
  if (configuredMetadataUrl) {
    if (!configuredMetadataUrl.startsWith('https://')) {
      throw new Error('authServerMetadataUrl must use https://')
    }
    return OAuthMetadataSchema.parse(await response.json())
  }

  // 优先级 2:RFC 9728 保护资源发现 → RFC 8414 授权服务器发现
  try {
    const { authorizationServerMetadata } = await discoverOAuthServerInfo(serverUrl)
    if (authorizationServerMetadata) return authorizationServerMetadata
  } catch {
    // 降级到旧版发现
  }

  // 优先级 3:路径感知的 RFC 8414 直接发现(兼容旧服务器)
  return discoverAuthorizationServerMetadata(url)
}

ClaudeAuthProvider

ClaudeAuthProvider 实现了 MCP SDK 的 OAuthClientProvider 接口,管理单个服务器的 OAuth 凭证:

ClaudeAuthProvider
├── tokens()          → 读取已存储的 Token
├── saveTokens()      → 存储 Token(Keychain/安全文件)
├── redirectUrl()     → OAuth 回调 URL
├── clientMetadata()  → 客户端注册元数据
├── saveClientInfo()  → 存储客户端信息
├── redirectUrl()     → http://127.0.0.1:{port}/oauth/callback
└── auth()            → 触发完整的 OAuth 认证流

凭证键使用名称 + 配置哈希生成,防止同名不同配置的服务器共享凭证:

function getServerKey(
  serverName: string,
  serverConfig: McpSSEServerConfig | McpHTTPServerConfig,
): string {
  const configJson = jsonStringify({
    type: serverConfig.type,
    url: serverConfig.url,
    headers: serverConfig.headers || {},
  })
  const hash = createHash('sha256').update(configJson).digest('hex').substring(0, 16)
  return `${serverName}|${hash}`
}

Token 刷新

Token 过期时自动刷新,带指数退避重试:

Access Token 过期
      │
      ▼
refreshAuthorization()
      │
      ├── 成功 → 更新 Token,继续使用
      │
      ├── invalid_grant → Token 已失效
      │     │
      │     ▼
      │   invalidateCredentials('tokens')
      │   标记服务器为 'needs-auth'
      │
      └── 暂时性错误(5xx / TooManyRequests)
            │
            ▼
          最多重试 3 次(指数退避)
          失败 → 标记为 'needs-auth'

非标准错误码兼容:某些 OAuth 服务器(如 Slack)使用非标准错误码。normalizeOAuthErrorBody 将它们统一为标准错误:

const NONSTANDARD_INVALID_GRANT_ALIASES = new Set([
  'invalid_refresh_token',   // Slack
  'expired_refresh_token',   // Slack
  'token_expired',           // Slack
])
// 统一映射为 'invalid_grant'(RFC 6749 标准)

Token 吊销

断开连接时主动吊销 Token:

async function revokeServerTokens(serverName, serverConfig): Promise<void> {
  // 1. 发现 revocation_endpoint
  // 2. 先吊销 refresh_token(长期凭证)
  // 3. 再吊销 access_token
  // 4. 清除本地存储
}

吊销遵循 RFC 7009,支持两种认证方式的降级:

RFC 7009 标准方式(client_id in body)
      │
      ▼ 401 ?
降级为 Bearer Auth(非标准但部分服务器需要)

24.2 Elicitation — 交互式认证流

Elicitation 是 MCP 协议的一项能力,允许 MCP Server 在运行时向用户请求额外输入。最典型的场景是认证流程中的用户确认。

两种 Elicitation 模式

function getElicitationMode(params: ElicitRequestParams): 'form' | 'url' {
  return params.mode === 'url' ? 'url' : 'form'
}

Form 模式:服务器发送 JSON Schema,Claude Code 渲染为表单,用户填写后返回数据。

URL 模式:服务器发送一个 URL,Claude Code 打开浏览器,等待服务器确认完成。

Elicitation 处理流程

MCP Server 发送 ElicitRequest
      │
      ▼
registerElicitationHandler()
      │
      ├── 1. 运行 Elicitation Hooks(可编程拦截)
      │     │
      │     ├── Hook 返回响应 → 直接使用
      │     └── Hook 无响应 → 继续
      │
      ├── 2. 创建 Promise,添加到 AppState.elicitation.queue
      │     │
      │     ▼
      │   UI 渲染 ElicitationDialog
      │     │ 用户操作
      │     ▼
      │   resolve(ElicitResult)
      │
      └── 3. 运行 ElicitationResult Hooks(后处理)
            │
            ▼
          返回最终结果给 MCP Server

核心代码

function registerElicitationHandler(
  client: Client,
  serverName: string,
  setAppState: (f: (prevState: AppState) => AppState) => void,
): void {
  client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
    // Hook 拦截
    const hookResponse = await runElicitationHooks(serverName, request.params, extra.signal)
    if (hookResponse) return hookResponse

    // 创建 Promise 并加入队列
    const response = new Promise<ElicitResult>(resolve => {
      setAppState(prev => ({
        ...prev,
        elicitation: {
          queue: [
            ...prev.elicitation.queue,
            {
              serverName,
              requestId: extra.requestId,
              params: request.params,
              signal: extra.signal,
              respond: resolve,  // UI 调用此函数完成交互
            },
          ],
        },
      }))
    })

    return await response
  })

  // URL 模式的完成通知
  client.setNotificationHandler(
    ElicitationCompleteNotificationSchema,
    notification => {
      // 标记对应的 queue 条目为 completed
      setAppState(prev => { /* 更新 queue[idx].completed = true */ })
    },
  )
}

ElicitResult 的三种 action

action含义
accept用户接受,附带 content 数据
decline用户拒绝
cancel被取消(abort signal 或超时)

24.3 Channel 权限中继

channelPermissions.ts 实现了一个创新功能:通过外部 Channel(如 Telegram、iMessage、Discord)远程审批 Claude Code 的权限请求。

工作原理

Claude Code 遇到需要权限的操作
      │
      ├──▶ 本地终端弹出权限对话框
      │
      └──▶ 同时发送到 Channel(如 Telegram)
            │
            用户在手机上回复 "yes tbxkq"
            │
            ▼
      Channel Server 解析回复
      发送结构化通知 → Claude Code
            │
            ▼
      首个响应者获胜(race)

短 ID 生成

为了在手机上方便输入,使用 5 个字母的短 ID(不含 'l',避免与 '1' 混淆):

const ID_ALPHABET = 'abcdefghijkmnopqrstuvwxyz'  // 25 字母,去掉 'l'
// 25^5 ≈ 9.8M 组合空间

function shortRequestId(toolUseID: string): string {
  let candidate = hashToId(toolUseID)
  // 过滤粗口词汇(5 个随机字母可能拼出不雅词汇)
  for (let salt = 0; salt < 10; salt++) {
    if (!ID_AVOID_SUBSTRINGS.some(bad => candidate.includes(bad))) {
      return candidate
    }
    candidate = hashToId(`${toolUseID}:${salt}`)
  }
  return candidate
}

安全边界

Channel 权限中继需要三个条件全部满足:

function filterPermissionRelayClients(clients, isInAllowlist) {
  return clients.filter(c =>
    c.type === 'connected' &&                                    // 已连接
    isInAllowlist(c.name) &&                                     // 在允许列表中
    c.capabilities?.experimental?.['claude/channel'] !== undefined &&        // 声明 channel 能力
    c.capabilities?.experimental?.['claude/channel/permission'] !== undefined // 声明权限能力
  )
}

设计注释中的安全分析:代码注释记录了一个重要的信任边界讨论 — 被攻破的 Channel Server 确实可以伪造审批,但这不比 Channel 本身的消息注入能力更危险。


24.4 名称规范化与注入防护

normalizeNameForMCP

function normalizeNameForMCP(name: string): string {
  // 替换所有非 [a-zA-Z0-9_-] 字符为下划线
  let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
  // Claude.ai 服务器额外清理:合并连续下划线,去掉首尾下划线
  if (name.startsWith('claude.ai ')) {
    normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
  }
  return normalized
}

为什么 Claude.ai 服务器需要额外清理?

MCP 工具名称格式为 mcp__<serverName>__<toolName>,使用 __ 作为分隔符。如果 serverName 包含连续下划线,会导致解析混乱。Claude.ai 的服务器名称格式为 "claude.ai Slack" → 规范化后不能产生 __

描述截断防护

OpenAPI 生成的 MCP Server 经常在工具描述中塞入 15-60KB 的 API 文档。Claude Code 硬性截断:

const MAX_MCP_DESCRIPTION_LENGTH = 2048

这既节省 Token,也防止服务器通过超长描述进行 prompt injection。


24.5 官方注册表(officialRegistry.ts)

Anthropic 维护一个官方 MCP Server 注册表,用于标记哪些远程 MCP Server 是经过审核的。

实现

type RegistryServer = {
  server: {
    remotes?: Array<{ url: string }>
  }
}

let officialUrls: Set<string> | undefined = undefined

async function prefetchOfficialMcpUrls(): Promise<void> {
  if (process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) return

  const response = await axios.get<RegistryResponse>(
    'https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial',
    { timeout: 5000 },
  )

  const urls = new Set<string>()
  for (const entry of response.data.servers) {
    for (const remote of entry.server.remotes ?? []) {
      const normalized = normalizeUrl(remote.url)
      if (normalized) urls.add(normalized)
    }
  }
  officialUrls = urls
}

function isOfficialMcpUrl(normalizedUrl: string): boolean {
  return officialUrls?.has(normalizedUrl) ?? false  // 未加载 → 默认 false
}

设计特点

  • Fire-and-forget:启动时预取,不阻塞连接流程
  • Fail-closed:如果注册表加载失败,所有 URL 默认为非官方
  • URL 规范化:去掉查询参数和尾部斜杠后比较

安全意义

注册表信息用于 UI 标记(官方 vs 第三方)和分析数据中的信任级别分类,不用于阻断连接 — 这是一个标记系统,不是授权系统。


24.6 MCP 工具的系统提示集成

当 MCP Server 提供 instructions 字段时,会被注入到系统提示中:

ConnectedMCPServer.instructions
      │
      ▼
注入为 system-reminder 格式
┌──────────────────────────────────┐
│ # MCP Server Instructions        │
│                                  │
│ ## server-name                   │
│ <server instructions content>    │
└──────────────────────────────────┘

这允许 MCP Server 告诉模型如何使用它的工具。例如本课顶部系统提示中的 claude.ai Context7 指令就是通过这个机制注入的。


24.7 MCP 工具实现

Claude Code 将 MCP Server 的工具包装为三种 Tool:

MCPTool — 通用 MCP 工具

tools/MCPTool/MCPTool.ts 将每个 MCP 工具包装为标准的 Claude Code Tool:

MCPTool
├── name: "mcp__serverName__toolName"
├── inputSchema: 来自 MCP Server 的 JSON Schema
├── call(): 通过 MCP Client 调用远程工具
├── 超时: 默认 ~27.8 小时(100,000,000 ms)
└── 结果处理: 截断/持久化大结果

ListMcpResourcesTool

列出所有已连接 MCP Server 提供的资源:

// 返回格式
{
  resources: [
    { server: "my-server", uri: "file:///path", name: "config.json", mimeType: "application/json" },
    // ...
  ]
}

ReadMcpResourceTool

读取特定 MCP 资源的内容,支持文本和二进制(Base64 编码的图片等)。


24.8 Claude.ai Proxy 连接

登录 Claude.ai 的用户可以使用 Web 端配置的 MCP 连接器,通过代理转发:

function createClaudeAiProxyFetch(innerFetch: FetchLike): FetchLike {
  return async (url, init) => {
    const doRequest = async () => {
      await checkAndRefreshOAuthTokenIfNeeded()
      const tokens = getClaudeAIOAuthTokens()
      headers.set('Authorization', `Bearer ${tokens.accessToken}`)
      const response = await innerFetch(url, { ...init, headers })
      return { response, sentToken: tokens.accessToken }
    }

    const { response, sentToken } = await doRequest()
    if (response.status !== 401) return response

    // 401 时尝试刷新 Token 并重试一次
    const tokenChanged = await handleOAuth401Error(sentToken).catch(() => false)
    if (!tokenChanged) return response
    return (await doRequest()).response
  }
}

并发安全:多个 Claude.ai 连接器可能同时收到 401。代码确保:

  1. 传递请求发送时的 Token(不是当前 Token)给刷新函数
  2. 如果另一个连接器已经刷新了 Token,检测变化而不是重复刷新

课后练习

练习 1:OAuth 流程图

画出完整的 OAuth 认证流程图,包括元数据发现(3 级降级)、客户端注册、授权码交换、Token 存储。标注每一步可能的错误和对应的处理策略。

练习 2:Elicitation 模拟

设想一个 MCP Server 需要用户确认删除操作。设计 Elicitation 的请求和响应格式(form 模式),包括 JSON Schema 和用户交互流程。

练习 3:安全审计

阅读 channelPermissions.ts 中的安全注释。列出 Channel 权限中继的三个安全假设,并分析如果每个假设被打破会产生什么后果。

练习 4:注入防护测试

构造 3 个恶意的 MCP Server 名称,尝试让 normalizeNameForMCP 产生可能混淆 mcp__server__tool 解析的输出。验证规范化函数是否能正确防御。


本课小结

要点内容
OAuth 完整流程元数据发现(3 级)→ DCR → 浏览器授权 → Token 存储/刷新/吊销
Token 刷新自动检测过期、指数退避重试、非标准错误码兼容(Slack 等)
Elicitationform/url 两种模式,Promise 队列 + React UI 渲染,Hook 可拦截
Channel 权限远程审批(手机),5 字母短 ID,race 首个响应者获胜
名称规范化防止 __ 分隔符混淆,描述截断防 prompt injection
官方注册表Fire-and-forget 预取,fail-closed 设计,标记而非阻断

下一课预告

第 25 课:AgentTool — 子 Agent 生成 — 深入 233KB 的 AgentTool.tsx,理解 Claude Code 最复杂工具的架构:Agent 定义加载、工具限制、Fork 子 Agent、后台运行、worktree 隔离以及多 Agent 协作模式。