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 配置

代码块JSON · 14 行收起展开
{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["notes-mcp-server.js"],
      "env": {
        "NOTES_ROOT": "F:\\2. ObsidianNotes"
      }
    },
    "browser": {
      "url": "http://localhost:3001/mcp"
    }
  }
}

两类 server:

类型说明
stdio serverCLI 启动一个子进程,通过 stdin/stdout 通信
HTTP/SSE server通过网络连接远程 MCP 服务

Transport 抽象

先定义最小 transport:

代码块TS · 23 行收起展开
// 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 的核心是启动子进程。

代码块TS · 49 行收起展开
// 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 Lifecycle 连接、发现工具、包装、调用,全部走同一 client。 Agent CLI MCP Client Transport Server connect start spawn initialize capabilities tools/call result wrapped tools

MCP Client 实现骨架

代码块TS · 96 行收起展开
// 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

代码块TS · 22 行收起展开
// 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:

代码块TEXT · 3 行收起展开
mcp__notes__search
mcp__browser__search
mcp__database__search

MCP 风险推断

MCP server 提供的工具也要过权限。可以先粗略推断:

代码块TS · 24 行收起展开
// 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 都不能用。

代码块TS · 28 行收起展开
// 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 可以提示:

代码块TEXT · 2 行收起展开
MCP server notes failed to connect: timeout
Continuing without its tools.

Transport 与 Renderer 的区别

容易混:

名称作用
MCP TransportAgent CLI 和 MCP server 通信
Agent Event TransportAgent CLI 把事件输出给用户/客户端

MCP transport 处理的是 JSON-RPC:

代码块JSON · 1 行收起展开
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}

Agent event transport 处理的是用户可见事件:

代码块JSON · 1 行收起展开
{"type":"tool_start","name":"read_file"}

不要混在一个模块里。

Agent Event Transport

定义:

代码块TS · 6 行收起展开
// AgentEventTransport = Agent 事件输出通道。
// 注意它和 MCP Transport 不是一回事:这里是给用户/UI看的事件。
interface AgentEventTransport {
  send(event: AgentEvent): Promise<void>; // 发送 Agent 事件。
  close(): Promise<void>;                 // 关闭输出通道。
}

终端版本:

代码块TS · 10 行收起展开
// TerminalTransport = 终端事件输出。
// 把 AgentEvent 交给 renderEvent 打印。
class TerminalTransport implements AgentEventTransport {
  async send(event: AgentEvent) {
    renderEvent(event);
  }

  // 终端无需特殊关闭。
  async close() {}
}

SSE 版本:

代码块TS · 19 行收起展开
// 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 输出:

代码块JSON · 4 行收起展开
{"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 后,必须和本地工具一样走权限:

代码块TEXT · 7 行收起展开
MCP tool call
-> local Tool wrapper
-> validate input
-> check permission
-> call MCP client
-> normalize result
-> audit

不要因为工具来自 MCP 就直接信任。MCP server 可能:

  • 读本地文件。
  • 调浏览器。
  • 查数据库。
  • 发网络请求。
  • 执行命令。

MCP 审计记录

审计里要额外记录 server:

代码块TS · 7 行收起展开
// 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
不记录 serveraudit 无法定位记录 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/executorMCP 调用绕过本地安全策略
传输可恢复stdio/http/ws 错误映射为可读 error codepipe 断开只剩 obscure exception
审计可定位audit 记录 server、remote tool、transport、timeout、raw error只能看到“工具失败”

最小回归样例:配置两个 MCP server,一个正常、一个初始化失败。合格实现应保留正常 server 的工具列表,把失败 server 标为 unavailable,并在用户调用其工具时返回可解释错误;不能让整个 Agent CLI 无法启动。

读源码时看 MCP

按这个顺序找:

  1. MCP config 从哪读。
  2. server 怎么启动或连接。
  3. initialize 在哪里做。
  4. listTools 结果怎么变成本地 Tool。
  5. 工具名是否 namespace。
  6. callTool 是否走权限。
  7. MCP 错误是否可恢复。
  8. transport 是否和 renderer 分离。
  9. 审计是否记录 server 信息。

能回答这些,MCP 源码就不是黑盒了。