Agent CLI深水区 - MCP生命周期传输层 · AI Agent
Agent CLI 深水区 05:MCP 生命周期与传输层
为什么 MCP 要放到深水区讲
入门时可以把 MCP 理解成“外部工具协议”。但源码级理解要更具体:
- MCP server 怎么启动?
- client 怎么初始化?
- tools/resources/prompts 怎么发现?
- tool call 怎么变成 JSON-RPC 请求?
- stdio / SSE / WebSocket 这些 transport 怎么抽象?
- MCP 工具怎么纳入本地权限系统?
这篇只讲通用 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 接口。
MCP Server 配置
代码块收起展开
{
"mcpServers": {
"notes": {
"command": "node",
"args": ["notes-mcp-server.js"],
"env": {
"NOTES_ROOT": "F:\\2. ObsidianNotes"
}
},
"browser": {
"url": "http://localhost:3001/mcp"
}
}
}两类 server:
| 类型 | 说明 |
|---|---|
| stdio server | CLI 启动一个子进程,通过 stdin/stdout 通信 |
| HTTP/SSE server | 通过网络连接远程 MCP 服务 |
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 都只依赖这个接口。
StdioTransport
stdio server 的核心是启动子进程。
代码块收起展开
// 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 分包。
- 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 转成异常。
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__search
mcp__browser__search
mcp__database__searchMCP 风险推断
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 的区别
容易混:
| 名称 | 作用 |
|---|---|
| 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
非交互模式最好支持 JSONL 输出:
代码块收起展开
{"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 或别的程序可以解析,而不是靠 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 深水区常见坑
| 坑 | 表现 | 修复 |
|---|---|---|
| 不加 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 断开只剩 obscure exception |
| 审计可定位 | 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 源码就不是黑盒了。