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

代码块JSON · 14 行收起展开
{
  "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 serverCLI 启动一个子进程,通过 stdin/stdout 通信
HTTP/SSE server通过网络连接远程 MCP 服务

stdio 类似你本地跑 java -jar xxx.jar 然后通过管道跟它说话,进程由 CLI 自己拉起、自己管生死;HTTP/SSE 类似调一个已经在跑的远程 REST 服务,CLI 只负责建连接。

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 都只依赖这个接口。跟 JDBC 一个思路:上层代码只认 Connection 接口,底下换 MySQL 驱动还是 PostgreSQL 驱动都不用改业务代码。

StdioTransport

stdio server 的核心是启动子进程:CLI 用 spawn(创建子进程的系统调用)把 server 程序拉起来,往它的 stdin 写请求,从它的 stdout 读响应,和 Linux 管道 cat file | grep xxx 是同一套机制。

代码块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 分包(一条 JSON 可能被拆成多次 data 事件到达,也可能好几条挤在同一次里,就是你在 Netty 里见过的拆包/粘包问题)。
  • 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 转成异常。

这套 id + pending map 的写法是异步 RPC 框架里最经典的请求响应配对:请求和响应走的是同一条流,响应不一定按发送顺序回来,所以发请求时生成一个 id,把“等结果的凭据”存进 map,响应异步到达后按 id 取出来完成它。
Java 里做同样的事一般是往 map 里存 CompletableFuture,收到响应后 complete;这里存的是 Promise 的 resolve/reject,思路完全一样。

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:

  • mcp__notes__search
  • mcp__browser__search
  • mcp__database__search

作用类似 Java 的包名:search 这种名字谁都会起,加上 mcp__server名__ 前缀就像加了 package,不同来源的同名工具互不覆盖,出问题时也能一眼看出是谁家的工具。

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 可以提示:

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

Transport 与 Renderer 的区别

容易混:这篇里出现了两个叫 transport 的东西,方向完全相反。一个是 Agent 往外调 MCP server 用的通信通道,另一个是 Agent 把执行过程的事件推给用户界面的输出通道(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

非交互模式(在脚本里一次性调 Agent 拿结果,不开交互界面)最好支持 JSONL 输出。JSONL 就是 JSON Lines:每一行是一个独立完整的 JSON 对象,消费方逐行读、逐行 parse 就行:

代码块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(持续集成流水线,比如 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:

代码块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 深水区常见坑

下面是实现 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 断开时只抛出一条看不懂的底层异常
审计可定位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 源码就不是黑盒了。

延伸阅读