Skip to content

Layer 4: Hook & MCP 扩展体系 —— Agent 框架的扩展点设计

如何在一个安全关键的 Agent 系统中设计扩展点,让用户和第三方能够自定义 Agent 行为,同时不破坏安全边界和性能保证?

这是框架设计的经典难题:开放性 vs 安全性。Claude Code 通过 Hook 事件系统和 MCP 协议集成给出了一个工程上的参考答案。


┌──────────────────────────────────────────────────────────┐
│ Agent 核心 (L1/L2) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 扩展层 (L4) │ │
│ │ │ │
│ │ ┌────────────────┐ ┌─────────────────────┐ │ │
│ │ │ Hook 系统 │ │ MCP 系统 │ │ │
│ │ │ (事件驱动) │ │ (协议驱动) │ │ │
│ │ │ │ │ │ │ │
│ │ │ 25 种事件 │ │ 5 种传输协议 │ │ │
│ │ │ 5 种 Hook 类型 │ │ 动态工具发现 │ │ │
│ │ │ 安全沙箱 │ │ 资源读取 │ │ │
│ │ │ 权限集成 │ │ Elicitation 交互 │ │ │
│ │ └────────┬───────┘ └──────────┬──────────┘ │ │
│ │ │ │ │ │
│ │ └────────┬────────────────┘ │ │
│ │ │ │ │
│ │ 互联: Hook 可以处理 MCP Elicitation │ │
│ │ 互联: Hook 可以修改 MCP 工具输出 │ │
│ │ 互联: MCP 配置变更触发 ConfigChange Hook │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

类别事件触发时机能力
工具生命周期PreToolUse工具执行前修改输入、允许/拒绝/询问权限
PostToolUse工具执行后检查结果、修改 MCP 工具输出
PostToolUseFailure工具执行失败观察错误
PermissionDenied权限被拒绝后观察
PermissionRequest权限对话框前自动允许/拒绝(headless 模式)
会话生命周期SessionStart会话初始化设置 watchPaths
SessionEnd会话关闭清理(1.5s 超时)
Stop / SubagentStopAgent 响应结束前观察
StopFailureAPI 错误终止观察
用户交互UserPromptSubmit用户提交输入阻止、添加上下文
团队协作TeammateIdle子 Agent 空闲前观察
TaskCreated / TaskCompleted任务状态变更观察
MCP 集成ElicitationMCP 请求用户输入自动接受/拒绝/取消
ElicitationResult用户响应 Elicitation观察
配置监控ConfigChange设置文件变更观察
InstructionsLoadedCLAUDE.md 加载观察(含 load_reason)
CwdChanged工作目录变更设置 CLAUDE_ENV_FILE
FileChanged被监控文件变更matcher 指定文件名
压缩PreCompact压缩前阻止、追加自定义指令
PostCompact压缩后观察
工作树WorktreeCreate / WorktreeRemoveGit 工作树操作stdout → 路径
其他Setup仓库初始化/维护观察
Notification系统通知matcher: notification_type
观察型(只能看,不能改):
PostToolUseFailure, PermissionDenied, SessionEnd, Stop,
StopFailure, InstructionsLoaded, PostCompact, TaskCreated, TaskCompleted
影响型(可以添加上下文或修改行为):
PreToolUse, PostToolUse, UserPromptSubmit, PermissionRequest,
Elicitation, PreCompact, SessionStart, CwdChanged, FileChanged
阻止型(可以阻止操作继续):
PreToolUse (exit code 2), UserPromptSubmit (exit code 2),
PreCompact (exit code 2)

★ 设计洞察:事件分三级能力是有意为之。观察型零风险(只是通知);影响型有限风险(可以添加上下文但不能直接修改核心流程);阻止型最高风险(exit code 2 可以阻止操作),但只给了最关键的几个事件。


type HookCommand =
| { type: 'command'; command: string; timeout?: number } // Shell 命令
| { type: 'prompt'; prompt: string; model?: string } // Claude API 调用
| { type: 'agent'; prompt: string; model?: string } // Agent 调用
| { type: 'http'; url: string; headers?: Record } // HTTP Webhook
| { type: 'callback'; callback: Function } // 内部回调(仅系统使用)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"config": {
"type": "command",
"command": "python3 check_safety.py",
"timeout": 5000
}
}
]
}
}

输入:JSON 通过 stdin 传递 输出:stdout(JSON 或文本) + exit code 环境:继承用户 shell 环境 + CLAUDE_ENV_FILE 支持

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"config": {
"type": "command",
"command": "check_git_safety.sh",
"if": "Bash(git *)"
}
}
]
}
}

if 使用权限规则语法,在进程启动之前评估。不匹配的 hook 不会被执行(节省进程启动开销)。

{
"config": {
"type": "command",
"command": "long_running_check.sh",
"async": true,
"asyncRewake": true
}
}
  • async: true:后台执行,不阻塞主流程
  • asyncRewake: true:后台执行,但如果 exit code = 2,唤醒主流程处理阻止错误

事件触发
1. 安全检查
├─ shouldDisableAllHooksIncludingManaged() → 策略覆盖?
└─ shouldSkipHookDueToTrust() → 工作区信任?
2. Hook 过滤
├─ 合并来源: user + project + local + policy + plugins + session
├─ matcher 过滤(工具名、MCP 服务器名等)
├─ `if` 条件评估
└─ 返回优先级排序的 hook 列表
3. 并行执行
├─ 每个 hook 独立超时 + AbortSignal
├─ 结果按完成顺序收集
└─ 不等最慢的(非阻塞聚合)
4. 结果聚合
└─ AggregatedHookResult {
permissionBehavior?: 'allow' | 'deny' | 'ask'
updatedInput?: Record<string, unknown>
additionalContexts?: string[]
blockingError?: HookBlockingError
updatedMCPToolOutput?: unknown
...
}
Exit Code 0 = 成功
→ stdout 显示给模型(transcript 模式)或作为附加上下文
Exit Code 2 = 阻止
→ stderr 立即显示给模型(作为 blocking error)
→ 操作被阻止(工具不执行 / 提交被拦截)
其他 = 非阻止错误
→ stderr 仅显示给用户(不给模型看)
→ 操作继续正常执行

★ 设计洞察:为什么用 exit code 而不是 JSON 字段来表示阻止?因为 Shell 脚本是最常见的 hook 实现。exit 2echo '{"block":true}' 简单得多,降低了 hook 作者的门槛。同时 exit code 2 不是常见的自然退出码(0=成功, 1=通用错误),不容易误触发。

性能数据:
完整 Hook 执行路径: ~6.01µs / 次
快速路径(仅内部回调): ~1.8µs / 次
优化幅度: -70%
触发条件:
所有 hook 都是内部 callback(无用户 hook)
→ 跳过: span 创建、进度发射、AbortSignal 设置、JSON 输出解析

5.1 核心原则:Hook 影响但不覆盖权限

Section titled “5.1 核心原则:Hook 影响但不覆盖权限”
Hook 能做的:
✓ 提供权限建议(allow / deny / ask)
✓ 修改工具输入(可能改变权限判定结果)
✓ 添加额外上下文(给模型看)
Hook 不能做的:
✗ 覆盖 deny 规则(规则永远优先)
✗ 绕过安全检查(bypass-immune 决策不受影响)
✗ 修改权限模式
决策优先级:
deny 规则 > Hook deny > Hook allow > 交互式对话框 > 默认
services/tools/toolHooks.ts
resolveHookPermissionDecision():
1. Hook 返回 'allow' → 跳过交互式对话框,但 deny 规则仍然检查
2. Hook 返回 'deny' → 直接拒绝
3. Hook 返回 'ask' → 强制弹出对话框(即使有 allow 规则)
4. Hook 无决策 → 走正常权限流程

┌────────────┬──────────────────────────────────────────┐
│ 传输协议 │ 特点 │
├────────────┼──────────────────────────────────────────┤
│ stdio │ 本地子进程,最简单,适合本地工具 │
│ sse │ HTTP Server-Sent Events,适合云服务 │
│ http │ Streamable HTTP,支持重试 │
│ websocket │ WebSocket + TLS/mTLS,适合持久连接 │
│ sdk │ 进程内 SDK,Anthropic 内部使用 │
└────────────┴──────────────────────────────────────────┘
// MCP SDK 定义的传输接口
interface Transport {
send(message: JSONRPCMessage): Promise<void>
onMessage: AsyncIterable<JSONRPCMessage>
close(): Promise<void>
}

★ 设计洞察:传输层是完全可插拔的。只需实现 Transport 接口,就可以添加新的传输协议(如 gRPC、WebRTC)。这使得 MCP 系统不受限于特定的通信方式,但所有传输协议都共享同一套工具发现、权限检查和错误处理逻辑。


第一次调用 MCP 工具
ensureConnectedClient(server)
缓存命中? → 返回缓存的 client
缓存未命中:
1. 解析配置(环境变量展开)
2. 选择传输协议
3. 建立连接
4. 发送 initialize 请求
5. 缓存 client + sessionId + capabilities
6. 调用 tools/list 发现工具
7. 返回 client
认证过期 (401):
→ McpAuthError → 状态切换为 'needs_auth'
→ 如果是 OAuth: 触发重新授权流程
→ 如果是 Bearer: 通知用户更新 token
会话过期 (404 + JSON-RPC -32001):
→ 清除缓存 → 下次调用重新连接
OAuth Token 刷新:
→ checkAndRefreshOAuthTokenIfNeeded()
→ 处理 invalid_grant(token 轮换)
→ 处理暂时性错误(重试)
type McpAuthMethod =
| { type: 'bearer'; token: string } // 静态 token
| { type: 'oauth'; clientId: string } // OAuth 流程
| { type: 'xaa' } // 跨应用访问(内部)

mcp__[server-name]__[tool-name]
示例:
mcp__github__create_issue ← github 服务器的 create_issue 工具
mcp__slack__send_message ← slack 服务器的 send_message 工具
MCPTool = buildTool({
isMcp: true
maxResultSizeChars: 100_000 ← 100KB 上限,超过持久化到磁盘
call():
1. 查找对应的 MCP 服务器
2. 确保已连接(懒初始化)
3. 调用 tools/call JSON-RPC
4. 处理 Elicitation(如果服务器请求用户输入)
5. 截断过长结果
6. 处理二进制内容(持久化到磁盘)
7. 返回结果
checkPermissions():
→ { behavior: 'passthrough' }
→ 交给外层权限系统处理
})
资源是独立的工具(不是嵌套在工具执行中):
ListMcpResourcesTool:
输入: { server: string }
输出: { resources: [{ uri, name, mimeType, description }] }
ReadMcpResourceTool:
输入: { server: string, uri: string }
输出: { contents: [{ uri, text?, blobSavedTo? }] }

★ 设计洞察:把 MCP 资源设计为独立的工具而非嵌套在工具执行中,带来三个好处:

  1. 资源可以有独立的权限规则
  2. 资源发现是显式的(不会在初始化时拉取所有资源)
  3. 资源可以使用独立的缓存策略

9. Elicitation:MCP 工具中的用户交互

Section titled “9. Elicitation:MCP 工具中的用户交互”
MCP 服务器调用 → 服务器需要用户输入
发送 Elicitation 请求(JSON-RPC -32042)
{ message, requested_schema?, mode: 'form' | 'url' }
尝试 Hook 解决:
执行 Elicitation hooks
├─ Hook 返回 accept + content → 自动响应,无需用户交互
├─ Hook 返回 decline/cancel → 拒绝请求
└─ Hook 无决策 → 继续到 UI
UI 对话框:
用户在 REPL 中看到表单/URL
用户输入响应
执行 ElicitationResult hooks(观察型)
响应发送回 MCP 服务器
工具恢复执行
场景: CI/CD 中运行 Claude Code,MCP 服务器需要 OAuth 授权
传统方式: 弹出浏览器 → 人工操作 → 超时失败
Hook 方式:
Elicitation Hook 接收请求
→ 从环境变量读取 token
→ 自动返回 { action: 'accept', content: { token: '...' } }
→ 零人工干预

MCP 工具调用 → PreToolUse Hook(matcher: 'mcp__github__*')
→ Hook 检查操作安全性
→ 返回 permissionDecision: 'allow'
→ MCP 工具直接执行(跳过用户对话框)
MCP 工具返回结果 → PostToolUse Hook
→ Hook 检查输出中的敏感信息
→ 返回 updatedMCPToolOutput(脱敏后的结果)
→ 模型看到的是脱敏后的数据
.mcp.json 文件变更 → ConfigChange Hook
→ Hook 验证新的 MCP 服务器配置
→ Hook 可以触发客户端缓存清理

优先级从高到低:
1. userSettings (~/.claude/settings.json)
2. projectSettings (.claude/settings.json)
3. localSettings (.claude/settings.local.json)
4. policySettings (企业管理配置,只读)
5. pluginHooks (插件注册)
6. builtinHooks (内部系统)
7. sessionHooks (内存中,临时)
优先级从高到低:
1. .mcp.json (项目运行时配置)
2. settings.json (用户/项目/本地)
3. enterprise (企业管理配置)
4. plugins (插件发现)

MCP 去重规则:computeServerSignature() 忽略 env 和 headers,只看核心配置。


Layer 1: 信任门控
→ 所有 Hook 需要工作区信任
→ 企业策略可以禁用所有 Hook
Layer 2: 能力分级
→ 观察型(零风险)→ 影响型(有限风险)→ 阻止型(最高风险)
→ 不同事件对应不同的能力等级
Layer 3: 权限不可逾越
→ Hook 的权限建议不能覆盖 deny 规则
→ Hook 不能提升自己的权限等级
能扩展的:
✓ 新的 Hook 事件类型(框架级添加)
✓ 新的 MCP 传输协议(实现 Transport 接口)
✓ 新的认证方式(添加 McpAuthMethod 变体)
✓ 新的 MCP 服务器(配置文件添加)
✓ 新的工具行为(PreToolUse Hook 修改输入)
不能篡改的:
✗ 核心 Hook 事件语义(固定契约)
✗ 权限决策层级(规则 > Hook > 默认)
✗ Exit code 语义(0/2/other 固定)
✗ MCP 协议语义(SDK 管理)
为什么不用 JSON 字段 {"block": true}?
1. Shell 脚本最常见 → exit 2 比 JSON 输出简单
2. exit code 2 不是自然退出码 → 不会误触发
3. stderr 自然成为错误消息 → 无需额外协议
4. 与 Unix 哲学一致 → 进程退出码是通用的通信机制

交互方向说明
← L1 (Agent Loop)Stop Hooks 在循环 Phase 5 执行
← L2 (Tool 系统)PreToolUse/PostToolUse Hooks 嵌入工具执行管线
→ L3 (权限系统)Hook 提供权限建议,但规则优先级更高
← L5 (上下文管理)PreCompact/PostCompact Hooks 允许自定义压缩行为
← L6 (子 Agent)SubagentStop/TeammateIdle Hooks 在子 Agent 生命周期触发
↔ MCPHook 处理 Elicitation;Hook 修改 MCP 输出;配置变更触发 Hook