Skip to content

第 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/promptsshell-tool-mcp、GitHub MCP Server

关键约束:每个 Client 与恰好一个 Server 保持连接。如果 Agent 需要同时使用 5 个 MCP Server,Host 中就会有 5 个独立的 Client 实例。

mermaid
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 示例:

json
{
  "capabilities": {
    "experimental": {
      "codex/sandbox-state": {
        "version": "1.0.0"
      }
    }
  }
}

这告诉 Server:"我支持动态更新沙箱策略"。只有声明了此 capability 的 Client 才会收到 codex/sandbox-state/update 请求。Server 发送沙箱策略更新后,Client 返回空 response 确认:

json
// 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。三种消息类型:

  1. Request:带 idmethodparams,期望对方回复
  2. Response:带 idresult(或 error),与 Request 配对
  3. Notification:带 methodparams,无 id,不期望回复

工具调用的核心 RPC:

方法方向用途
initializeClient → Server握手,交换 capabilities
tools/listClient → Server发现可用工具
tools/callClient → Server调用工具
resources/listClient → Server发现可用资源
resources/readClient → Server读取资源内容
notifications/tools/list_changedServer → 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 传输。配置示例:

toml
[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-Id header)
  • 支持无状态部署(不需要维持持久连接)

WebSocket 传输

原理:Client 与 Server 建立 WebSocket 连接,双方在同一连接上双向发送 JSON-RPC 消息。

优点

  • 全双工:双方随时可以发送消息
  • 低延迟:无需为每条消息建立新连接
  • 适合高频交互场景

缺点

  • 连接管理复杂:需要处理断线重连、心跳保活
  • 负载均衡较难:有状态连接不如 HTTP 请求灵活

超时策略

工具执行时间差异巨大——ls 可能 10ms 完成,而 npm install 或数据库查询可能需要数分钟。MCP 的超时策略需要分层考虑:

层级默认值含义
启动超时10 秒Server 进程启动 + initialize + 首次 tools/list
工具调用超时120 秒单次 tools/call 的最大等待时间
连接超时无限制stdio 传输的连接生命周期等于进程生命周期

Codex 的实现中定义了这些超时常量:

rust
/// 启动超时:初始化 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 二进制:

typescript
// 平台检测逻辑
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 不兼容:

typescript
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 的映射:

text
McpConnectionManager
├── "shell-tool" → RmcpClient (stdio)
├── "github"     → RmcpClient (stdio)
└── "firecrawl"  → RmcpClient (sse)

工具命名:MCP Server 各自独立定义工具名,可能冲突(两个 Server 都叫自己的工具 search)。解决方案是全限定名——用双下划线 __ 拼接 server name 和 tool name:

text
github__search_issues
firecrawl__firecrawl_search

OpenAI Responses API 要求工具名匹配 ^[a-zA-Z0-9_-]+$,所以还需要一个 sanitize 步骤:

rust
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):

rust
#[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 不同,协议层只负责透传。

工具调用流程

mermaid
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 定义了启动状态机:

rust
enum McpStartupStatus {
    Starting,       // 正在启动
    Ready,          // 就绪
    Failed { error: String },  // 失败
}

每个 Server 独立汇报状态,最终汇总为 McpStartupCompleteEvent

rust
struct McpStartupCompleteEvent {
    ready: Vec<String>,      // 成功启动的 Server
    failed: Vec<McpStartupFailure>,  // 失败的 Server + 原因
    cancelled: Vec<String>,  // 被取消的 Server
}

通过适配器集成

并非所有系统都原生支持 MCP。一种常见模式是适配器层——将 MCP Server 的工具转换为框架原生的工具定义。

DeepAgents 的 ACP 集成展示了另一种思路:不是让 Agent 直接调用 MCP,而是在会话创建时接受 mcp_servers 参数,由框架层管理 MCP 连接:

python
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 实现了这种模式:

rust
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 工具,而是一个框架层的工具发现机制:

text
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 需要协作时,出现了新的协议需求:

对比维度MCPACP
通信方向Agent ↔ ToolAgent ↔ Agent(或 Host ↔ Agent)
语义工具发现 + 调用会话管理 + 流式响应 + 权限协商
状态管理无状态(每次调用独立)有状态(session 维护对话历史)
流式支持返回完整结果支持增量流式更新
典型 Server文件系统、数据库、API 适配器LLM Agent

ACP 的定位是:让 Agent 作为"服务"被标准化地调用,而不是把 Agent 当作一个 function call。

ACP 核心概念

ACP 定义了以下核心交互:

1. 会话管理

python
# 初始化:交换 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 的中间状态:

python
# 发送 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 可以在执行敏感操作前请求用户批准:

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

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

DeepAgents 通过 AgentServerACP 类实现 ACP Server 端,将 LangGraph 编译后的 Agent 桥接为标准 ACP 服务。

Agent 工厂模式:ACP Server 不直接持有一个 Agent 实例,而是持有一个工厂函数。每次创建会话或切换模式时,工厂根据上下文(cwd + mode)构建新的 Agent:

python
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 定义了三种模式:

python
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 配置——哪些工具调用需要暂停等待用户批准:

python
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 新 Agent 创建

工具调用可视化:ACP Server 将 Agent 的工具调用映射为结构化的 UI 事件。不同工具类型映射到不同的 ToolKind

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

sql
agent.astream(input, stream_mode=["messages", "updates"])

          ┌──────────────┴──────────────┐
          │                             │
    stream_mode="messages"      stream_mode="updates"
          │                             │
    处理 AI 消息 chunk             处理状态更新
    ├── 文本内容 → session_update   ├── __interrupt__ → 请求权限
    ├── 工具调用 chunk → 累积      └── tools 节点 → plan 更新
    └── 工具结果 → 完成事件

中断处理是 ACP 最复杂的部分。当 Agent 触发一个需要审批的工具调用时:

  1. LangGraph 的 interrupt() 暂停执行
  2. ACP Server 从 interrupt value 中提取 action request
  3. Server 通过 request_permission() 向 Client 发送审批请求
  4. 用户决策返回后,Server 通过 Command(resume={"decisions": [...]}) 恢复执行
  5. 整个流程在 while current_state.interrupts: 循环中持续,直到所有中断解决

MCP 与 ACP 的协议层对比

mermaid
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 汇报进展和请求权限。这形成了清晰的两层协议栈

text
Host (IDE / CLI)
    ↕ ACP
Agent Server
    ↕ MCP
Tool Servers (filesystem, GitHub, DB, ...)