Agent CLI深水区 - MCP生命周期传输层
Agent CLI 深水区 05:MCP 生命周期与传输层
为什么 MCP 要放到深水区讲
入门时可以把 MCP(Model Context Protocol,一个开放协议,规定了 Agent 怎么用统一格式去发现和调用外部工具、外部数据源)理解成“外部工具协议”。但源码级理解要更具体:
- MCP server 怎么启动?
- client 怎么初始化?
- tools/resources/prompts 怎么发现?
- tool call 怎么变成 JSON-RPC 请求?
- stdio / SSE / WebSocket 这些 transport 怎么抽象?
- MCP 工具怎么纳入本地权限系统?
先把上面几个生词说清楚,后面就直接用了:
- JSON-RPC:用 JSON 编码的远程调用协议。请求里带 method(方法名)和 id,响应带同一个 id,靠 id 把请求和响应配对。你可以理解成一个极简版的 RPC 框架线协议。
- transport:传输层,负责把一条消息从一端送到另一端,不关心消息内容是什么。
- stdio:标准输入输出。父进程往子进程的 stdin 写数据、从它的 stdout 读数据,本质就是进程间管道。
- SSE:Server-Sent Events,基于 HTTP 长连接的服务器单向推送,服务端可以持续往客户端发消息,客户端不能从这条连接上回话。
- WebSocket:双向长连接,两边都能随时发消息。
- tools/resources/prompts:MCP server 对外提供的三类能力,分别是工具(可调用的函数)、资源(可读取的数据,比如文件内容)、提示词模板。
这篇只讲通用 clean-room 设计。clean-room 的意思是不看不抄任何现成产品的源码,只根据协议规范和公开文档,自己从零推一遍实现。
MCP 在 Agent CLI 的层次
flowchart TD
A[QueryEngine] --> B[Tool Registry]
B --> C[Local Tools]
B --> D[MCP Tool Wrappers]
D --> E[MCP Client]
E --> F[Transport]
F --> G[MCP Server]
关键点:
QueryEngine 不应该知道某个工具来自 MCP。它只看到统一 Tool 接口。
QueryEngine 是 Agent 的主循环,负责跟模型对话、决定调哪个工具;Tool Registry 是所有工具的注册表。
这个设计和 Spring 里面向接口编程一个道理:业务代码只依赖接口,具体实现是本地 Bean 还是远程服务,它不关心。
MCP 工具被包装成统一的 Tool 接口后,对 QueryEngine 来说和本地工具没有任何区别。
MCP Server 配置
代码块收起展开
{
"mcpServers": {
"notes": {
"command": "node",
"args": ["notes-mcp-server.js"],
"env": {
"NOTES_ROOT": "F:\\2. ObsidianNotes"
}
},
"browser": {
"url": "http://localhost:3001/mcp"
}
}
}上面配置里的 notes 和 browser 正好对应两类 server,区别就在通信方式:
| 类型 | 说明 |
|---|---|
| stdio server | CLI 启动一个子进程,通过 stdin/stdout 通信 |
| HTTP/SSE server | 通过网络连接远程 MCP 服务 |
stdio 类似你本地跑 java -jar xxx.jar 然后通过管道跟它说话,进程由 CLI 自己拉起、自己管生死;HTTP/SSE 类似调一个已经在跑的远程 REST 服务,CLI 只负责建连接。
Transport 抽象
先定义最小 transport:
代码块收起展开
// JsonRpcMessage = MCP 底层通信的 JSON-RPC 2.0 消息。
// request/response/error 都用这个结构表达。
type JsonRpcMessage = {
jsonrpc: "2.0"; // JSON-RPC 协议版本,固定 2.0。
id?: string | number; // request/response 对应 ID。通知类消息可以没有 id。
method?: string; // 请求方法,例如 initialize、tools/list、tools/call。
params?: unknown; // 请求参数。
result?: unknown; // 成功响应结果。
error?: {
code: number; // JSON-RPC 错误码。
message: string; // 错误说明。
data?: unknown; // 可选错误详情。
};
};
// Transport = MCP client 依赖的传输层接口。
// 不管底层是 stdio、HTTP/SSE、WebSocket,client 都只调用这几个方法。
interface Transport {
start(): Promise<void>; // 启动连接:spawn 子进程或建立网络连接。
send(message: JsonRpcMessage): Promise<void>; // 发送 JSON-RPC 消息。
onMessage(handler: (message: JsonRpcMessage) => void): void; // 注册收消息回调。
close(): Promise<void>; // 关闭连接/杀子进程。
}不管底层是 stdio、SSE 还是 WebSocket,MCP client 都只依赖这个接口。跟 JDBC 一个思路:上层代码只认 Connection 接口,底下换 MySQL 驱动还是 PostgreSQL 驱动都不用改业务代码。
StdioTransport
stdio server 的核心是启动子进程:CLI 用 spawn(创建子进程的系统调用)把 server 程序拉起来,往它的 stdin 写请求,从它的 stdout 读响应,和 Linux 管道 cat file | grep xxx 是同一套机制。
代码块收起展开
// StdioTransport = 通过 stdin/stdout 和 MCP server 子进程通信。
class StdioTransport implements Transport {
// MCP server 子进程。
private child?: ChildProcess;
// 收到 JSON-RPC 消息后要通知的回调列表。
private handlers: Array<(message: JsonRpcMessage) => void> = [];
constructor(
private command: string, // 启动命令,例如 node。
private args: string[], // 命令参数,例如 ["server.js"]。
private env: Record<string, string | undefined> // 传给子进程的环境变量。
) {}
async start() {
// 启动子进程,并把 stdin/stdout/stderr 都接出来。
this.child = spawn(this.command, this.args, {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...this.env }
});
// 从 stdout 读取 MCP server 发回的 JSON-RPC 消息。
this.child.stdout?.on("data", chunk => {
// 教学版假设一行一个 JSON。真实系统要处理分包/粘包。
for (const line of chunk.toString("utf8").split("\n")) {
if (!line.trim()) continue;
// 把 JSON 字符串解析成消息对象。
const message = JSON.parse(line);
// 通知所有 onMessage 注册的处理器。
for (const handler of this.handlers) handler(message);
}
});
}
async send(message: JsonRpcMessage) {
// 发送时也按一行一个 JSON 写入 stdin。
this.child?.stdin?.write(JSON.stringify(message) + "\n");
}
onMessage(handler: (message: JsonRpcMessage) => void) {
// 注册消息处理器。
this.handlers.push(handler);
}
async close() {
// 关闭时杀掉子进程。真实系统要更优雅地 shutdown。
this.child?.kill();
}
}真实实现要处理:
- stdout 分包(一条 JSON 可能被拆成多次 data 事件到达,也可能好几条挤在同一次里,就是你在 Netty 里见过的拆包/粘包问题)。
- stderr 日志。
- 子进程退出。
- JSON parse 错误。
- 初始化超时。
- Windows 路径和 shell 差异。
但核心就是这个。
MCP Client 生命周期
MCP Client 实现骨架
代码块收起展开
// McpClient = MCP JSON-RPC client。
// 它负责 initialize、tools/list、tools/call,以及 request/response 配对。
class McpClient {
// 下一个 JSON-RPC request id。
private nextId = 1;
// pending 保存“已发送但还没收到响应”的请求。
// key 是 request id,value 是对应 Promise 的 resolve/reject。
private pending = new Map<number, {
resolve(value: JsonRpcMessage): void;
reject(error: Error): void;
}>();
constructor(
public readonly name: string, // MCP server 名,用于 namespace 和 audit。
private transport: Transport // 底层传输。
) {}
async connect() {
// 先注册消息处理器,避免 start 后立即返回消息但没人接。
this.transport.onMessage(message => this.handleMessage(message));
// 启动 transport:stdio 会 spawn 子进程,HTTP 会建连接。
await this.transport.start();
// MCP 初始化握手,告诉 server client 信息和协议版本。
await this.request("initialize", {
protocolVersion: "2026-03-26",
clientInfo: {
name: "learning-agent-cli",
version: "0.1.0"
}
});
}
async listTools(): Promise<McpTool[]> {
// 请求 server 列出可用工具。
const response = await this.request("tools/list", {});
// 真实系统要校验 response.result 的结构。
return (response.result as any).tools;
}
async callTool(name: string, input: unknown): Promise<ToolResult> {
// 把本地工具调用转成 MCP tools/call 请求。
const response = await this.request("tools/call", {
name,
arguments: input
});
// MCP 返回格式要转换成本地统一 ToolResult。
return normalizeMcpToolResult(response.result);
}
// request = 发送一个 JSON-RPC 请求,并等待同 id 的响应。
private async request(method: string, params: unknown): Promise<JsonRpcMessage> {
// 分配 request id。
const id = this.nextId++;
// 创建一个 Promise,收到响应时由 handleMessage resolve/reject。
const promise = new Promise<JsonRpcMessage>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
// 发送 JSON-RPC request。
await this.transport.send({
jsonrpc: "2.0",
id,
method,
params
});
// 等待响应。
return promise;
}
// handleMessage = 处理 transport 收到的 JSON-RPC 消息。
private handleMessage(message: JsonRpcMessage) {
// 没有数字 id 的消息可能是 notification,这里先忽略。
if (typeof message.id !== "number") return;
// 根据 id 找到等待中的请求。
const pending = this.pending.get(message.id);
if (!pending) return;
// 找到了就删除,避免内存泄漏。
this.pending.delete(message.id);
// error 响应 reject,否则 resolve。
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message);
}
}
}这段代码的关键:
- 每个 request 有 id。
- pending map 保存等待中的请求。
- 收到 response 后按 id 找回 promise。
- JSON-RPC error 转成异常。
这套 id + pending map 的写法是异步 RPC 框架里最经典的请求响应配对:请求和响应走的是同一条流,响应不一定按发送顺序回来,所以发请求时生成一个 id,把“等结果的凭据”存进 map,响应异步到达后按 id 取出来完成它。
Java 里做同样的事一般是往 map 里存 CompletableFuture,收到响应后 complete;这里存的是 Promise 的 resolve/reject,思路完全一样。
MCP 工具包装成本地 Tool
代码块收起展开
// wrapMcpTool = 把 MCP 远程工具包装成本地统一 Tool。
// 包装后 QueryEngine 不需要知道这个工具来自 MCP。
function wrapMcpTool(client: McpClient, remoteTool: McpTool): Tool<any, any> {
return {
// 加 namespace 防止不同 MCP server 的工具重名。
name: `mcp__${client.name}__${remoteTool.name}`,
// 描述里标明来源,方便用户审查和 trace。
description: `[MCP:${client.name}] ${remoteTool.description}`,
// 远程工具的输入 schema 直接作为本地工具 schema。
inputSchema: remoteTool.inputSchema,
// 根据工具名/描述粗略推断风险。
risk: inferMcpRisk(remoteTool),
// MCP 调用要有超时,不能让远端工具卡住整个 Agent。
timeoutMs: 30_000,
// 远端返回也要限制大小。
maxOutputBytes: 64_000,
async run(input) {
// 本地工具执行时,实际转发给 MCP client。
return client.callTool(remoteTool.name, input);
}
};
}为什么工具名要加 namespace?
避免不同 server 都有 search:
mcp__notes__searchmcp__browser__searchmcp__database__search
作用类似 Java 的包名:search 这种名字谁都会起,加上 mcp__server名__ 前缀就像加了 package,不同来源的同名工具互不覆盖,出问题时也能一眼看出是谁家的工具。
MCP 风险推断
MCP server 提供的工具也要过权限。可以先粗略推断:
代码块收起展开
// inferMcpRisk = 根据 MCP 工具名和描述推断风险等级。
// 这是默认兜底策略,严肃系统应该允许用户配置覆盖。
function inferMcpRisk(tool: McpTool): ToolRisk {
// 把名称和描述拼在一起做关键词判断。
const text = `${tool.name} ${tool.description}`.toLowerCase();
// delete/write/update 通常意味着写操作。
if (text.includes("delete") || text.includes("write") || text.includes("update")) {
return "write";
}
// run/exec/shell 通常意味着命令执行。
if (text.includes("run") || text.includes("exec") || text.includes("shell")) {
return "execute";
}
// fetch/http/browser 通常意味着网络或浏览器访问。
if (text.includes("fetch") || text.includes("http") || text.includes("browser")) {
return "network";
}
// 默认按只读处理,但工具内部仍要受权限系统约束。
return "read";
}这只是默认策略。严肃系统应该允许用户在配置里覆盖风险等级。
MCP 连接失败怎么办
不要因为一个 MCP server 挂了,整个 Agent CLI 都不能用。
代码块收起展开
// loadMcpTools = 加载所有配置的 MCP server 工具。
// 一个 server 失败不应该拖垮整个 Agent CLI。
export async function loadMcpTools(config: McpConfig) {
const tools: Tool<any, any>[] = [];
const errors: McpLoadError[] = [];
for (const server of config.servers) {
try {
// 连接单个 MCP server。
const client = await connectServer(server);
// 拉取远程工具列表。
const remoteTools = await client.listTools();
// 远程工具统一包装成本地 Tool 后加入注册表。
tools.push(...remoteTools.map(tool => wrapMcpTool(client, tool)));
} catch (error) {
// 记录失败,但继续加载其他 server。
errors.push({
server: server.name,
message: error instanceof Error ? error.message : String(error)
});
}
}
// 返回成功加载的工具和失败列表,UI 可以展示警告。
return { tools, errors };
}UI 可以提示:
代码块收起展开
MCP server notes failed to connect: timeout
Continuing without its tools.Transport 与 Renderer 的区别
容易混:这篇里出现了两个叫 transport 的东西,方向完全相反。一个是 Agent 往外调 MCP server 用的通信通道,另一个是 Agent 把执行过程的事件推给用户界面的输出通道(renderer 就是把这些事件渲染成终端文字的那一层)。下面这张表帮你区分:
| 名称 | 作用 |
|---|---|
| MCP Transport | Agent CLI 和 MCP server 通信 |
| Agent Event Transport | Agent CLI 把事件输出给用户/客户端 |
MCP transport 处理的是 JSON-RPC:
代码块收起展开
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}Agent event transport 处理的是用户可见事件:
代码块收起展开
{"type":"tool_start","name":"read_file"}不要混在一个模块里。
Agent Event Transport
定义:
代码块收起展开
// AgentEventTransport = Agent 事件输出通道。
// 注意它和 MCP Transport 不是一回事:这里是给用户/UI看的事件。
interface AgentEventTransport {
send(event: AgentEvent): Promise<void>; // 发送 Agent 事件。
close(): Promise<void>; // 关闭输出通道。
}终端版本:
代码块收起展开
// TerminalTransport = 终端事件输出。
// 把 AgentEvent 交给 renderEvent 打印。
class TerminalTransport implements AgentEventTransport {
async send(event: AgentEvent) {
renderEvent(event);
}
// 终端无需特殊关闭。
async close() {}
}SSE 版本:
代码块收起展开
// SseAgentTransport = Web/SSE 事件输出。
// 适合把同一个 QueryEngine 接到浏览器或桌面端。
class SseAgentTransport implements AgentEventTransport {
// response 是 Node HTTP ServerResponse。
constructor(private response: ServerResponse) {}
async send(event: AgentEvent) {
// SSE event 名用 AgentEvent type。
this.response.write(`event: ${event.type}\n`);
// data 里放 JSON 序列化后的事件。
this.response.write(`data: ${JSON.stringify(event)}\n\n`);
}
async close() {
// 结束 HTTP 响应。
this.response.end();
}
}这样同一个 QueryEngine 可以服务:
- CLI。
- Web。
- 桌面端。
- 自动化脚本。
Structured Output
非交互模式(在脚本里一次性调 Agent 拿结果,不开交互界面)最好支持 JSONL 输出。JSONL 就是 JSON Lines:每一行是一个独立完整的 JSON 对象,消费方逐行读、逐行 parse 就行:
代码块收起展开
{"type":"text_delta","text":"正在分析..."}
{"type":"tool_start","name":"read_file"}
{"type":"tool_result","summary":"Read src/main.ts"}
{"type":"turn_end","reason":"final_answer"}这样 CI(持续集成流水线,比如 GitHub Actions)或别的程序可以逐行解析出结构化事件,而不是靠 grep 控制台文字去猜执行到哪一步了。
MCP 与权限系统的关系
MCP 工具进入 Tool Registry 后,必须和本地工具一样走权限:
MCP tool call → local Tool wrapper → validate input → check permission → call MCP client → normalize result → audit
不要因为工具来自 MCP 就直接信任。MCP server 可能:
- 读本地文件。
- 调浏览器。
- 查数据库。
- 发网络请求。
- 执行命令。
MCP 审计记录
审计里要额外记录 server:
代码块收起展开
// McpToolAudit = MCP 工具审计记录。
// 在普通 ToolAuditRecord 基础上增加 MCP server 和远程工具信息。
type McpToolAudit = ToolAuditRecord & {
mcpServer: string; // 哪个 MCP server。
remoteToolName: string; // server 里的原始工具名。
transport: "stdio" | "http" | "websocket"; // 使用的传输方式。
};这样出了问题能定位:
- 是哪个 server。
- 哪个 remote tool。
- 输入是什么。
- 输出是什么。
- 有没有超时。
MCP 深水区常见坑
下面是实现 MCP 集成时最容易踩的坑,每行按“坑是什么、踩了会出什么症状、怎么修”来读:
| 坑 | 表现 | 修复 |
|---|---|---|
| 不加 namespace | 工具重名 | mcp__server__tool |
| 连接失败直接崩 | 一个 server 挂,全局不可用 | 把失败隔离在单个 server 内,只跳过它 |
| 不走权限 | 外部工具越权 | MCP wrapper 走统一权限 |
| 不设超时 | tool call 卡死 | request timeout |
| 不记录 server | audit 无法定位 | 记录 mcpServer |
| transport 混乱 | UI 输出和 MCP 通信缠一起 | 分离 transport 类型 |
源码验收门槛
MCP 的验收不能停在“server 能连上”。真正要证明的是:远程能力进入 Agent 后,仍然服从本地工具系统的 schema 校验(按工具声明的参数格式检查输入)、权限、超时、审计和失败隔离。下面这张表从左往右读:验收什么、代码里要能指出哪段作为证据、什么样算不合格。
| 验收项 | 必须看到的源码证据 | 不合格表现 |
|---|---|---|
| 生命周期完整 | connect、initialize、listTools、callTool、close 都有状态记录 | 连接后直接 call tool |
| server 隔离 | 单个 MCP server 初始化失败不会拖垮全部工具 | 一个 server 挂,全局启动失败 |
| 工具命名稳定 | 远程工具转换成本地 mcp__server__tool | 不同 server 工具重名覆盖 |
| schema 二次校验 | 本地调用前仍按 MCP tool schema 校验参数 | 完全相信模型生成的 arguments |
| 权限不旁路 | MCP tool wrapper 进入统一 permission/executor | MCP 调用绕过本地安全策略 |
| 传输可恢复 | stdio/http/ws 错误映射为可读 error code | pipe 断开时只抛出一条看不懂的底层异常 |
| 审计可定位 | audit 记录 server、remote tool、transport、timeout、raw error | 只能看到“工具失败” |
最小回归样例:配置两个 MCP server,一个正常、一个初始化失败。合格实现应保留正常 server 的工具列表,把失败 server 标为 unavailable,并在用户调用其工具时返回可解释错误;不能让整个 Agent CLI 无法启动。
读源码时看 MCP
按这个顺序找:
- MCP config 从哪读。
- server 怎么启动或连接。
- initialize 在哪里做。
- listTools 结果怎么变成本地 Tool。
- 工具名是否 namespace。
- callTool 是否走权限。
- MCP 错误是否可恢复。
- transport 是否和 renderer 分离。
- 审计是否记录 server 信息。
能回答这些,MCP 源码就不是黑盒了。