第 16 章:MCP 协议 —— 工具的标准化接入
MCP(Model Context Protocol)的核心价值是将 Agent 与外部工具之间的集成从"每对一个适配器"降维为"统一协议 + 可插拔服务器",使任意工具只需实现一次 MCP Server 即可被所有支持 MCP 的 Agent 调用。
16.1 MCP 概述
问题背景
Agent 需要调用外部工具——文件系统、数据库、API、浏览器等。传统方案是每个 Agent 框架自己定义工具接口(function calling schema),每个工具自己写适配层。这导致 N 个 Agent 框架 × M 个工具 = N×M 个适配器的爆炸问题。
MCP 的思路类似 USB:定义一套标准化的工具发现、调用和结果返回协议,让任何工具(Server 端)和任何 Agent(Client 端)通过统一接口对话。
三层角色架构
MCP 定义了三个参与角色:
| 角色 | 职责 | 典型实例 |
|---|---|---|
| Host | 宿主应用,管理 Agent 运行环境 | Codex CLI、VS Code 扩展、Zed 编辑器 |
| Client | 协议客户端,维护与 Server 的 1:1 连接 | Host 内部的 MCP 连接管理器 |
| Server | 工具提供方,暴露 tools/resources/prompts | shell-tool-mcp、GitHub MCP Server |
关键约束:每个 Client 与恰好一个 Server 保持连接。如果 Agent 需要同时使用 5 个 MCP Server,Host 中就会有 5 个独立的 Client 实例。
graph TB
H[Host<br>Codex CLI / IDE]
C1[Client 1]
C2[Client 2]
C3[Client 3]
S1[Server: shell-tool]
S2[Server: github]
S3[Server: firecrawl]
H --> C1
H --> C2
H --> C3
C1 <--> S1
C2 <--> S2
C3 <--> S3能力协商(Capability Negotiation)
连接建立的第一步是 initialize 握手。Client 声明自己支持的 capabilities(如 elicitation、sampling),Server 声明自己提供的 capabilities(如 tools、resources、prompts)。双方只使用对方声明支持的功能。
这种设计的好处是前向兼容:协议新增的功能不会破坏旧版实现——旧 Client 不声明新 capability,Server 就不会尝试使用它。
Codex 声明的一个 experimental capability 示例:
{
"capabilities": {
"experimental": {
"codex/sandbox-state": {
"version": "1.0.0"
}
}
}
}这告诉 Server:"我支持动态更新沙箱策略"。只有声明了此 capability 的 Client 才会收到 codex/sandbox-state/update 请求。Server 发送沙箱策略更新后,Client 返回空 response 确认:
// Server -> Client: 更新沙箱策略
{
"id": "req-42",
"method": "codex/sandbox-state/update",
"params": {
"sandboxPolicy": {
"type": "workspace-write",
"writable_roots": ["/home/user/code"],
"network_access": false
}
}
}
// Client -> Server: 确认
{
"id": "req-42",
"result": {}
}协议消息格式
MCP 基于 JSON-RPC 2.0。三种消息类型:
- Request:带
id、method、params,期望对方回复 - Response:带
id、result(或error),与 Request 配对 - Notification:带
method、params,无id,不期望回复
工具调用的核心 RPC:
| 方法 | 方向 | 用途 |
|---|---|---|
initialize | Client → Server | 握手,交换 capabilities |
tools/list | Client → Server | 发现可用工具 |
tools/call | Client → Server | 调用工具 |
resources/list | Client → Server | 发现可用资源 |
resources/read | Client → Server | 读取资源内容 |
notifications/tools/list_changed | Server → Client | 工具列表变更通知 |
16.2 传输层的工程挑战
MCP 的协议层与传输层是解耦的。同一套 JSON-RPC 消息可以跑在不同的传输之上。但不同传输的工程特性差异很大。
stdio 传输
原理:Client 通过 spawn() 启动 Server 进程,两者通过 stdin/stdout 交换 JSON-RPC 消息。
优点:
- 最简单:无需网络配置、端口管理、TLS 证书
- 进程隔离天然提供安全边界
- 适合本地 CLI 工具集成
缺点:
- 无法跨机器——Client 和 Server 必须在同一台机器上
- 进程生命周期管理复杂:Server 崩溃时 Client 需要检测并重启
Codex 的 shell-tool-mcp 使用 stdio 传输。配置示例:
[mcp_servers.shell-tool]
command = "npx"
args = ["-y", "@openai/codex-shell-tool-mcp"]Client 启动时 spawn 这个 npm 包作为子进程,然后通过 stdin/stdout 通信。
SSE(Server-Sent Events)传输
原理:Client 通过 HTTP GET 建立 SSE 连接接收 Server 推送,通过 HTTP POST 发送请求到 Server。
特点:
- 单向流:SSE 本身只支持 Server → Client 推送
- 需要额外的 HTTP endpoint 做 Client → Server 通信
- 可以跨机器,但需要 HTTP 服务器基础设施
- 受限于 HTTP/1.1 的连接数限制(浏览器环境下每域名 6 个连接)
Streamable HTTP 传输
MCP 2025-03-26 版本引入的新传输方式,逐步取代 SSE:
- Client 发送 HTTP POST 请求
- Server 可以选择返回普通 JSON 响应(同步),或返回 SSE 流(异步)
- 可选的 session 管理(通过
Mcp-Session-Idheader) - 支持无状态部署(不需要维持持久连接)
WebSocket 传输
原理:Client 与 Server 建立 WebSocket 连接,双方在同一连接上双向发送 JSON-RPC 消息。
优点:
- 全双工:双方随时可以发送消息
- 低延迟:无需为每条消息建立新连接
- 适合高频交互场景
缺点:
- 连接管理复杂:需要处理断线重连、心跳保活
- 负载均衡较难:有状态连接不如 HTTP 请求灵活
超时策略
工具执行时间差异巨大——ls 可能 10ms 完成,而 npm install 或数据库查询可能需要数分钟。MCP 的超时策略需要分层考虑:
| 层级 | 默认值 | 含义 |
|---|---|---|
| 启动超时 | 10 秒 | Server 进程启动 + initialize + 首次 tools/list |
| 工具调用超时 | 120 秒 | 单次 tools/call 的最大等待时间 |
| 连接超时 | 无限制 | stdio 传输的连接生命周期等于进程生命周期 |
Codex 的实现中定义了这些超时常量:
/// 启动超时:初始化 MCP Server + 首次列出工具
const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
/// 单次工具调用超时
const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120);16.3 MCP 在各系统中的实现
TypeScript MCP Server 实现模式
以 Codex 的 shell-tool-mcp 为例,它是一个典型的 TypeScript MCP Server,提供名为 shell 的工具——在沙箱化的 Bash 实例中执行 shell 命令。
核心设计:这个 Server 不只是简单地执行命令。它使用一个经过 patch 的 Bash 二进制,拦截所有 execve(2) 系统调用。对于每个进程创建请求,Bash 回调 MCP Server 来决定是否允许执行。
这解决了一个关键安全问题:当 Agent 请求执行 ls 时,你无法仅凭命令名判断安全性——可能存在同名的恶意程序在 $PATH 中、shell alias 重定义、或 shell function 覆盖。通过拦截 execve(2),Server 始终知道实际要执行的程序的完整路径。
平台适配:Server 需要为不同操作系统和发行版提供不同的 Bash 二进制:
// 平台检测逻辑
function resolveTargetTriple(platform: NodeJS.Platform, arch: NodeJS.Architecture): string {
if (platform === "linux") {
if (arch === "x64") return "x86_64-unknown-linux-musl";
if (arch === "arm64") return "aarch64-unknown-linux-musl";
} else if (platform === "darwin") {
if (arch === "x64") return "x86_64-apple-darwin";
if (arch === "arm64") return "aarch64-apple-darwin";
}
throw new Error(`Unsupported platform: ${platform} (${arch})`);
}Linux 上还需要进一步区分发行版和版本(Ubuntu 24.04 vs 22.04、Debian 12 vs 11、CentOS 9 等),因为不同版本的 glibc 不兼容:
const LINUX_BASH_VARIANTS = [
{ name: "ubuntu-24.04", ids: ["ubuntu"], versions: ["24.04"] },
{ name: "ubuntu-22.04", ids: ["ubuntu"], versions: ["22.04"] },
{ name: "debian-12", ids: ["debian"], versions: ["12"] },
{ name: "centos-9", ids: ["centos", "rhel", "rocky", "almalinux"], versions: ["9"] },
// ...
];选择逻辑:先精确匹配 id + version → 回退到仅匹配 id → 最终回退到第一个变体。
MCP Elicitation:当工具需要用户确认时(如执行危险命令),Server 通过 MCP 的 elicitation 机制请求用户输入。匹配规则由 .rules 文件定义:
allow:命令在沙箱外执行(升级权限)prompt:通过 elicitation 请求用户批准forbidden:拒绝执行,返回错误
原生 MCP 集成(Codex)
Codex 的 MCP 集成是目前最完善的原生实现。核心组件是 McpConnectionManager——管理所有已配置的 MCP Server 连接。
连接管理:每个 Server 对应一个 RmcpClient 实例。Manager 维护一个 server_name → client 的映射:
McpConnectionManager
├── "shell-tool" → RmcpClient (stdio)
├── "github" → RmcpClient (stdio)
└── "firecrawl" → RmcpClient (sse)工具命名:MCP Server 各自独立定义工具名,可能冲突(两个 Server 都叫自己的工具 search)。解决方案是全限定名——用双下划线 __ 拼接 server name 和 tool name:
github__search_issues
firecrawl__firecrawl_searchOpenAI Responses API 要求工具名匹配 ^[a-zA-Z0-9_-]+$,所以还需要一个 sanitize 步骤:
fn sanitize_responses_api_tool_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}最大长度限制为 64 字符——server name + __ + tool name 的总长不能超过这个值。
协议类型映射:Codex 在 Rust 侧定义了 MCP 协议的核心类型,并确保它们同时兼容 TypeScript(通过 ts-rs)和 JSON Schema(通过 schemars):
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
pub name: String,
pub description: Option<String>,
pub input_schema: serde_json::Value,
pub output_schema: Option<serde_json::Value>,
pub annotations: Option<serde_json::Value>,
// ...
}input_schema 保持为 serde_json::Value 而非强类型——因为每个工具的参数 schema 不同,协议层只负责透传。
工具调用流程:
sequenceDiagram
participant LLM as LLM API
participant Host as Codex Core
participant Mgr as McpConnectionManager
participant Srv as MCP Server
LLM->>Host: tool_call(github__search_issues, args)
Host->>Host: 解析全限定名 → server="github", tool="search_issues"
Host->>Host: 发送 McpToolCallBeginEvent
Host->>Mgr: call_tool("github", "search_issues", args)
Mgr->>Srv: JSON-RPC tools/call
Srv-->>Mgr: CallToolResult
Mgr-->>Host: CallToolResult
Host->>Host: 发送 McpToolCallEndEvent(含 duration)
Host-->>LLM: tool_result启动状态追踪:MCP Server 启动可能失败(依赖缺失、认证过期等)。Codex 定义了启动状态机:
enum McpStartupStatus {
Starting, // 正在启动
Ready, // 就绪
Failed { error: String }, // 失败
}每个 Server 独立汇报状态,最终汇总为 McpStartupCompleteEvent:
struct McpStartupCompleteEvent {
ready: Vec<String>, // 成功启动的 Server
failed: Vec<McpStartupFailure>, // 失败的 Server + 原因
cancelled: Vec<String>, // 被取消的 Server
}通过适配器集成
并非所有系统都原生支持 MCP。一种常见模式是适配器层——将 MCP Server 的工具转换为框架原生的工具定义。
DeepAgents 的 ACP 集成展示了另一种思路:不是让 Agent 直接调用 MCP,而是在会话创建时接受 mcp_servers 参数,由框架层管理 MCP 连接:
async def new_session(
self,
cwd: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
) -> NewSessionResponse:
session_id = uuid4().hex
# mcp_servers 的连接管理由框架负责
return NewSessionResponse(session_id=session_id)16.4 Schema 膨胀问题
问题本质
每个 MCP 工具都有一个 JSON Schema 形式的 input_schema,描述其参数结构。当 Agent 调用 LLM 时,所有可用工具的 schema 都必须作为系统上下文的一部分传入。
假设一个工具的 schema 平均 200 token,10 个 MCP Server 各提供 10 个工具 = 100 个工具 = 20,000 token 仅用于描述工具——还没算 description。在 128K 上下文窗口中,这就占了 15%。
这不仅浪费 token,还稀释模型对真正重要工具的注意力——当模型面对 100 个工具的 schema 时,选择准确率显著低于面对 10 个工具时。
解法对比
方案 1:Lazy Loading(延迟加载)
只在系统上下文中放工具的名称和简短描述(stub),不放完整 schema。当 Agent 需要调用某个工具时,先通过一个 meta 工具获取完整 schema,然后再发起实际调用。
Codex 的 DynamicToolSpec 实现了这种模式:
pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
pub defer_loading: bool, // 关键字段
}当 defer_loading = true 时,工具的完整 schema 不立即暴露给模型。Client 侧维护了 deferred tools 列表,Agent 需要时通过专门的搜索工具来"fetch"完整定义。
Claude Code 的 ToolSearch 就是这种 meta 工具的生产实例——它不是真正的 MCP 工具,而是一个框架层的工具发现机制:
ToolSearch(query="github issue", max_results=5)
→ 返回匹配工具的完整 schema
→ 工具随后可被正常调用方案 2:Stub + Search
将所有工具分为两类:
- 常驻工具(always loaded):高频使用的核心工具,schema 始终在上下文中
- 按需工具(deferred):低频工具,只保留 name + description 作为 stub
Agent 通过搜索 stub 列表来发现按需工具,匹配后系统动态注入完整 schema。
这本质上是一种两级缓存:L1 是常驻工具(热数据),L2 是按需工具(冷数据)。
方案 3:allowed_tools 过滤
Host 在每次 LLM 调用时,根据当前任务上下文动态决定暴露哪些工具。不相关的工具直接不传入。
这需要 Host 有足够的上下文理解能力来判断"当前任务需要哪些工具"——通常通过启发式规则或轻量级分类器实现。
方案 4:Logit Masking
在 LLM 生成 tool name token 时,通过 logit bias 屏蔽不相关工具的 token。这不减少上下文占用,但能提高工具选择准确率。
缺点是需要 LLM 推理层的深度集成,大多数 API 不暴露这种控制。
各方案对比
| 方案 | token 节省 | 实现复杂度 | 额外延迟 | 适用场景 |
|---|---|---|---|---|
| Lazy Loading | 高 | 中 | 额外 1 轮 LLM 调用 | 工具数 > 30 |
| Stub + Search | 高 | 中 | 搜索步骤 | 工具数 20-100 |
| allowed_tools | 中 | 低 | 无 | 工具数 < 30 且任务分类明确 |
| Logit Masking | 无 | 高 | 无 | 需要推理层集成 |
生产系统通常组合使用:Codex 同时使用 Lazy Loading(defer_loading 字段)和 allowed_tools(配置层过滤),Claude Code 使用 Stub + Search(ToolSearch 工具)。
16.5 ACP(Agent Communication Protocol)
从 MCP 到 ACP
MCP 解决了 Agent ↔ Tool 的通信问题。但当系统中有多个 Agent 需要协作时,出现了新的协议需求:
| 对比维度 | MCP | ACP |
|---|---|---|
| 通信方向 | Agent ↔ Tool | Agent ↔ Agent(或 Host ↔ Agent) |
| 语义 | 工具发现 + 调用 | 会话管理 + 流式响应 + 权限协商 |
| 状态管理 | 无状态(每次调用独立) | 有状态(session 维护对话历史) |
| 流式支持 | 返回完整结果 | 支持增量流式更新 |
| 典型 Server | 文件系统、数据库、API 适配器 | LLM Agent |
ACP 的定位是:让 Agent 作为"服务"被标准化地调用,而不是把 Agent 当作一个 function call。
ACP 核心概念
ACP 定义了以下核心交互:
1. 会话管理
# 初始化:交换 capabilities
response = await agent.initialize(
protocol_version=1,
client_capabilities=ClientCapabilities(...)
)
# 创建会话
session = await agent.new_session(
cwd="/workspace",
mcp_servers=[...] # Agent 可以使用的 MCP Server
)
# 切换模式
await agent.set_session_mode(
mode_id="accept_edits",
session_id=session.session_id
)2. 流式 Prompt 处理
ACP 的 prompt() 方法支持多种内容类型输入(文本、图片、音频、资源),并通过 session_update 实时推送 Agent 的中间状态:
# 发送 prompt 并接收流式响应
response = await agent.prompt(
prompt=[
TextContentBlock(type="text", text="重构这个模块"),
ImageContentBlock(type="image", mime_type="image/png", data="..."),
ResourceContentBlock(type="resource_link", name="main.py", uri="file:///main.py"),
],
session_id=session_id
)Agent 处理过程中,通过 session_update 向 Client 推送:
- 文本消息(思考过程、中间输出)
- 工具调用开始/结束事件
- 计划更新(PlanEntry 列表)
3. Human-in-the-Loop 权限控制
ACP Agent 可以在执行敏感操作前请求用户批准:
# Agent Server 端请求权限
response = await self._conn.request_permission(
session_id=session_id,
tool_call=ToolCallUpdate(
tool_call_id=tool_id,
title="Execute: `npm install`",
raw_input=tool_args,
),
options=[
PermissionOption(option_id="approve", name="Approve", kind="allow_once"),
PermissionOption(option_id="reject", name="Reject", kind="reject_once"),
PermissionOption(option_id="approve_always", name="Always allow `npm install`", kind="allow_always"),
],
)三种权限粒度:
allow_once:仅本次批准reject_once:仅本次拒绝allow_always:记住该命令类型,后续自动批准
"Always allow" 的记忆粒度不是简单的工具名,而是提取到命令签名级别。例如 python -m pytest -q 的签名是 python -m pytest,后续只要是 python -m pytest 开头的命令都会自动批准,但 python -m pip install 不会:
def extract_command_types(command: str) -> list[str]:
# "cd /path && python -m pytest tests/" → ["cd", "python -m pytest"]
# "npm run build" → ["npm run build"]
# "ls -la | grep foo" → ["ls", "grep"]ACP 在 DeepAgents 中的实现

DeepAgents 通过 AgentServerACP 类实现 ACP Server 端,将 LangGraph 编译后的 Agent 桥接为标准 ACP 服务。
Agent 工厂模式:ACP Server 不直接持有一个 Agent 实例,而是持有一个工厂函数。每次创建会话或切换模式时,工厂根据上下文(cwd + mode)构建新的 Agent:
class AgentServerACP(ACPAgent):
def __init__(
self,
agent: CompiledStateGraph | Callable[[AgentSessionContext], CompiledStateGraph],
modes: SessionModeState | None = None,
):
# agent 可以是编译好的图(单实例),也可以是工厂函数
self._agent_factory = agent模式系统:ACP 支持 session mode 切换。DeepAgents 的 demo 定义了三种模式:
modes = SessionModeState(
current_mode_id="accept_edits",
available_modes=[
SessionMode(
id="ask_before_edits",
name="Ask before edits",
description="Ask permission before edits, writes, shell commands, and plans",
),
SessionMode(
id="accept_edits",
name="Accept edits",
description="Auto-accept edit operations, but ask before shell commands",
),
SessionMode(
id="accept_everything",
name="Accept everything",
description="Auto-accept all operations without asking permission",
),
],
)每种模式对应不同的 interrupt 配置——哪些工具调用需要暂停等待用户批准:
mode_to_interrupt = {
"ask_before_edits": {
"edit_file": {"allowed_decisions": ["approve", "reject"]},
"write_file": {"allowed_decisions": ["approve", "reject"]},
"execute": {"allowed_decisions": ["approve", "reject"]},
},
"accept_edits": {
"execute": {"allowed_decisions": ["approve", "reject"]},
},
"accept_everything": {}, # 无中断
}
工具调用可视化:ACP Server 将 Agent 的工具调用映射为结构化的 UI 事件。不同工具类型映射到不同的 ToolKind:
kind_map = {
"read_file": "read",
"edit_file": "edit",
"write_file": "edit",
"ls": "search",
"glob": "search",
"grep": "search",
"execute": "execute",
}对于 edit_file 工具,ACP Server 还会生成 diff 内容,让 Client 端可以渲染代码变更对比视图。
流式处理架构:ACP Server 的 prompt() 方法内部是一个流式处理循环,同时处理两种 stream mode:
agent.astream(input, stream_mode=["messages", "updates"])
│
┌──────────────┴──────────────┐
│ │
stream_mode="messages" stream_mode="updates"
│ │
处理 AI 消息 chunk 处理状态更新
├── 文本内容 → session_update ├── __interrupt__ → 请求权限
├── 工具调用 chunk → 累积 └── tools 节点 → plan 更新
└── 工具结果 → 完成事件中断处理是 ACP 最复杂的部分。当 Agent 触发一个需要审批的工具调用时:
- LangGraph 的
interrupt()暂停执行 - ACP Server 从 interrupt value 中提取 action request
- Server 通过
request_permission()向 Client 发送审批请求 - 用户决策返回后,Server 通过
Command(resume={"decisions": [...]})恢复执行 - 整个流程在
while current_state.interrupts:循环中持续,直到所有中断解决
MCP 与 ACP 的协议层对比
graph TB
subgraph "MCP: Agent ↔ Tool"
A1[Agent / Client] -->|tools/call| T1[Tool Server]
T1 -->|CallToolResult| A1
end
subgraph "ACP: Host ↔ Agent"
H1[Host / Editor] -->|prompt| A2[Agent Server]
A2 -->|session_update 流式| H1
A2 -->|request_permission| H1
H1 -->|approve/reject| A2
end
A2 -.->|Agent 内部使用 MCP 调用工具| T2[MCP Server]关键区别:MCP 是同步的请求-响应模式(调用工具,等待结果),ACP 是异步的流式+中断模式(发送 prompt,持续接收更新,可能被中断要求决策)。
一个 ACP Agent 内部通常也是 MCP Client——它通过 MCP 调用外部工具,同时通过 ACP 向上层 Host 汇报进展和请求权限。这形成了清晰的两层协议栈:
Host (IDE / CLI)
↕ ACP
Agent Server
↕ MCP
Tool Servers (filesystem, GitHub, DB, ...)