模块八:MCP 协议 | 前置依赖:第 23 课 | 预计学习时间:70 分钟
学习目标
完成本课后,你将能够:
- 说明 MCP OAuth 认证流程的完整生命周期(发现、授权、刷新、吊销)
- 解释 Elicitation 机制如何在终端中实现交互式认证
- 理解 MCP 工具的权限继承模型与沙箱隔离策略
- 描述官方注册表(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。代码确保:
- 传递请求发送时的 Token(不是当前 Token)给刷新函数
- 如果另一个连接器已经刷新了 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 等) |
| Elicitation | form/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 协作模式。