Agent CLI源码剖析 - MCP传输与扩展生态

Agent CLI 源码剖析 05:MCP、传输层与扩展生态

MCP 在 Agent CLI 中的位置

前几篇讲的工具(读文件、执行命令这些)都写死在 Agent CLI 自己的代码里,叫本地工具。
MCP(Model Context Protocol)是一个开放协议,说白了就是让外部程序也能按统一格式把工具“插”给 Agent 用:别人写好一个 MCP server,你的 Agent 按协议连上去,就多了一批工具,Agent 自己的代码一行不用改。

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]

关键原则:

MCP 工具进入 Tool Registry 后,就应该像本地工具一样走 schema、权限、审计、超时和输出截断。

类比一下:不管请求来自内网还是外部,进了 Servlet 容器都要过同一条 Filter 链。
MCP 工具也一样,实现代码在外部,但进了 Tool Registry(工具注册表,Agent 当前可用工具的清单)之后,schema 校验(检查入参格式是否符合工具声明)、权限审批、审计日志、超时、输出截断一样都不能少。外部来的工具反而更不能有特权。

MCP 解决的问题

没有 MCP,每个 Agent 客户端都要自己适配外部系统:

  • Agent A → notes API
  • Agent B → notes API
  • Agent C → notes API

有 MCP:

notes MCP server → any MCP client

这个结构你应该眼熟:JDBC 就是同一个思路。各数据库厂商实现自己的驱动,应用代码只面向 JDBC 接口写,不用为 MySQL、PostgreSQL 各适配一遍。MCP 对 Agent 工具做的就是这件事。它不会让模型更聪明,价值全在工程侧:

  • 统一工具发现。
  • 统一资源读取。这里的资源(resource)是 MCP 协议里和 tool 并列的另一类能力:tool 是模型可以主动调用的操作(本文讲的 tools/list、tools/call 都是它),resource 是 server 暴露出来给客户端读的只读数据,比如一个文件的内容、一份配置。
    本文源码只覆盖 tool 这条线,resource 先知道有这个概念就行。
  • 统一工具调用。
  • 让工具生态可复用。

MCP 配置

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

上面的配置里刚好各有一个例子:notes 配的是 command,browser 配的是 url,分别对应两种常见连接方式:

类型说明
stdioAgent CLI 把 server 作为本地子进程启动,通过标准输入输出(stdin/stdout)传消息
HTTP/SSE/WebSocket通过网络连接远程或本地已经跑着的服务

SSE(Server-Sent Events)是 HTTP 上的服务器单向推送机制:客户端发一个请求后连接不断开,服务器持续往里写数据,网页消息推送常用它。选型先记结论:本地工具用 stdio 最省事,远程服务走 HTTP 系。

Transport 抽象

MCP 底层通常是 JSON-RPC 风格消息。JSON-RPC 说白了就是一种极简 RPC 格式:请求和响应都是一条 JSON,请求带 method(方法名)和 id,响应带同一个 id,靠 id 把请求和响应对上号。
没有服务注册,没有代码生成,比 Dubbo 裸得多。消息格式定了,再把“消息怎么收发”抽象成 transport:

代码块TS · 22 行收起展开
// JsonRpcMessage = MCP 底层 JSON-RPC 消息。
type JsonRpcMessage = {
  jsonrpc: "2.0";       // 协议版本。
  id?: string | number; // request/response 关联 ID。
  method?: string;      // 请求方法,例如 tools/list。
  params?: unknown;     // 请求参数。
  result?: unknown;     // 成功响应结果。
  error?: {
    code: number;       // 错误码。
    message: string;    // 错误信息。
    data?: unknown;     // 可选错误详情。
  };
};

// Transport = MCP client 依赖的传输接口。
// stdio/http/websocket 都可以实现它。
interface Transport {
  start(): Promise<void>; // 启动连接。
  send(message: JsonRpcMessage): Promise<void>; // 发送消息。
  onMessage(handler: (message: JsonRpcMessage) => void): void; // 注册接收回调。
  close(): Promise<void>; // 关闭连接。
}

MCP Client 只依赖 Transport,不关心底层是 stdio 还是 WebSocket。和你写 DAO 只面向 JDBC 的 Connection 接口一个道理:换驱动不用改业务代码,这里换传输方式不用改 client 逻辑。

StdioTransport

代码块TS · 71 行收起展开
// StdioTransport = 本地子进程 MCP server 的传输实现。
class StdioTransport implements Transport {
  // MCP server 子进程。
  private child?: ChildProcess;

  // 收到完整 JSON-RPC 消息后要调用的 handler。
  private handlers: Array<(message: JsonRpcMessage) => void> = [];

  // stdout 可能分包,所以需要 buffer 拼行。
  private buffer = "";

  constructor(
    private command: string, // 启动命令。
    private args: string[],  // 参数。
    private env: Record<string, string | undefined> // 环境变量。
  ) {}

  async start() {
    // 启动子进程。
    this.child = spawn(this.command, this.args, {
      stdio: ["pipe", "pipe", "pipe"],
      env: { ...process.env, ...this.env }
    });

    // stdout 收 MCP JSON-RPC 消息。
    this.child.stdout?.on("data", chunk => {
      // 先追加到 buffer,不直接 JSON.parse。
      this.buffer += chunk.toString("utf8");
      this.drainLines();
    });

    // stderr 当日志,不当协议消息。
    this.child.stderr?.on("data", chunk => {
      appendMcpLog("stderr", chunk.toString("utf8"));
    });
  }

  // drainLines = 从 buffer 中取出完整行。
  private drainLines() {
    while (this.buffer.includes("\n")) {
      const index = this.buffer.indexOf("\n");
      // 取出一行并 trim。
      const line = this.buffer.slice(0, index).trim();
      // 剩余内容留在 buffer,等待下一次 data。
      this.buffer = this.buffer.slice(index + 1);

      if (!line) continue;

      // 一行是一个 JSON-RPC 消息。
      const message = JSON.parse(line);
      for (const handler of this.handlers) {
        handler(message);
      }
    }
  }

  async send(message: JsonRpcMessage) {
    // 发送时也是一行一个 JSON。
    this.child?.stdin?.write(JSON.stringify(message) + "\n");
  }

  onMessage(handler: (message: JsonRpcMessage) => void) {
    // 注册回调。
    this.handlers.push(handler);
  }

  async close() {
    // 关闭子进程。
    this.child?.kill();
  }
}

注意 buffer。stdout 可能分包,不一定一次 data 就是一条完整 JSON。
这就是你在 Netty 里见过的 TCP 粘包/拆包问题:server 发了 {"id":1,"result":...} 加换行,你这边第一次 data 事件可能只收到前半截 {"id":1,,直接 JSON.parse 必炸;也可能两条消息挤在同一次 data 里到达。
所以先把碎片攒进 buffer,看到换行符才切出一条完整消息去解析,和 Netty 里按分隔符解码(LineBasedFrameDecoder 那一套)是同一招。

MCP Client

代码块TS · 98 行收起展开
// McpClient = MCP client,负责请求/响应配对和工具调用。
class McpClient {
  // 下一个 request id。
  private nextId = 1;

  // pending 保存等待响应的请求。
  private pending = new Map<number, {
    resolve(value: JsonRpcMessage): void;
    reject(error: Error): void;
    // 每个请求都有 timeout,避免永远挂着。
    timeout: NodeJS.Timeout;
  }>();

  constructor(
    public readonly name: string, // server 名。
    private transport: Transport  // 底层传输。
  ) {}

  async connect() {
    // 先注册消息处理,避免 start 后马上有消息但没人接。
    this.transport.onMessage(message => this.handleMessage(message));
    await this.transport.start();

    // 初始化握手。
    await this.request("initialize", {
      protocolVersion: "2026-03-26",
      clientInfo: {
        name: "learning-agent-cli",
        version: "0.1.0"
      }
    });
  }

  async listTools(): Promise<McpTool[]> {
    // 请求远端工具列表。
    const response = await this.request("tools/list", {});
    return (response.result as any).tools;
  }

  async callTool(name: string, input: unknown): Promise<ToolResult> {
    // 请求远端执行工具。
    const response = await this.request("tools/call", {
      name,
      arguments: input
    });

    // 转成本地统一 ToolResult。
    return normalizeMcpToolResult(response.result);
  }

  // request = 发送 JSON-RPC 请求并等待响应。
  private async request(method: string, params: unknown): Promise<JsonRpcMessage> {
    // 分配 ID。
    const id = this.nextId++;

    const promise = new Promise<JsonRpcMessage>((resolve, reject) => {
      // 超时后清理 pending 并 reject。
      const timeout = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`MCP request timeout: ${method}`));
      }, 30_000);

      this.pending.set(id, { resolve, reject, timeout });
    });

    // 发出请求。
    await this.transport.send({
      jsonrpc: "2.0",
      id,
      method,
      params
    });

    // 等待 handleMessage resolve/reject。
    return promise;
  }

  // handleMessage = 收到 JSON-RPC response 后按 id 找回 pending。
  private handleMessage(message: JsonRpcMessage) {
    // 没有数字 id 的消息先忽略,例如 notification。
    if (typeof message.id !== "number") return;

    // 找对应请求。
    const pending = this.pending.get(message.id);
    if (!pending) return;

    // 清理 timeout 和 pending。
    clearTimeout(pending.timeout);
    this.pending.delete(message.id);

    // JSON-RPC error 转 reject,否则 resolve。
    if (message.error) {
      pending.reject(new Error(message.error.message));
    } else {
      pending.resolve(message);
    }
  }
}

重点:

  • 每个 request 有 id。
  • pending map 等待响应。
  • 超时必须清理 pending。
  • JSON-RPC error 转异常。

这套“发请求时把回调按 id 存进 map,收到响应再按 id 取出来唤醒”的写法,就是异步 RPC 框架配对请求和响应的标准套路,Dubbo、手写 Netty RPC 都是这么干的。
为什么必须带 id?因为 stdio 是一条流,同时发出去的多个请求,响应可能乱序回来,没有 id 就对不上号。
超时清理也不能省:server 永远不回的话,pending 里的条目永远删不掉,等着它的 await 也永远挂着,等于内存泄漏加调用方卡死。

MCP Tool Wrapper

MCP tool 要包装成本地 Tool:

代码块TS · 22 行收起展开
// wrapMcpTool = 把远程 MCP tool 包装成本地 Tool。
// 包装后进入 Tool Registry,走和本地工具一样的权限/审计/超时。
function wrapMcpTool(client: McpClient, remoteTool: McpTool): Tool<any, any> {
  return {
    // namespace 防止不同 server 的 search/read 等工具重名。
    name: `mcp__${client.name}__${remoteTool.name}`,
    // 描述里标明来源。
    description: `[MCP:${client.name}] ${remoteTool.description}`,
    // 远程 schema 作为本地 inputSchema。
    inputSchema: remoteTool.inputSchema,
    // 根据名字和描述推断默认风险。
    risk: inferMcpRisk(remoteTool),
    // MCP 工具也必须有超时。
    timeoutMs: 30_000,
    // 返回内容也要限制大小。
    maxOutputBytes: 64_000,
    async run(input) {
      // 本地工具执行时,转发到远端 MCP server。
      return client.callTool(remoteTool.name, input);
    }
  };
}

为什么要 namespace?

因为多个 server 可能都有 search:

  • mcp__notes__search
  • mcp__browser__search
  • mcp__database__search

作用和 Java 的全限定类名一样:java.util.Datejava.sql.Date 同名不冲突,靠包名区分,这里靠 mcp__server名__tool名 区分。不加 namespace 的话,后加载的同名工具会把先加载的覆盖掉,模型调用 search 时就可能拿到错的实现。

MCP 风险推断

MCP server 是第三方代码,不是天然可信的。它自我描述说“我只读数据”不能直接当真,所以接入时按工具名和描述先猜一个保守的风险等级(risk,后面权限审批时用来决定要不要找用户确认)。宁可猜严了让用户多点一次确认,也别猜松了放行危险操作。

代码块TS · 24 行收起展开
// inferMcpRisk = MCP 工具默认风险推断。
// 真实项目应允许用户在配置里覆盖。
function inferMcpRisk(tool: McpTool): ToolRisk {
  // 拼接名称和描述,用关键词粗略判断。
  const text = `${tool.name} ${tool.description}`.toLowerCase();

  // 写/删/更新类工具。
  if (text.includes("delete") || text.includes("write") || text.includes("update")) {
    return "write";
  }

  // 执行命令类工具。
  if (text.includes("run") || text.includes("exec") || text.includes("shell")) {
    return "execute";
  }

  // 网络/浏览器类工具。
  if (text.includes("fetch") || text.includes("browser") || text.includes("http")) {
    return "network";
  }

  // 默认只读。
  return "read";
}

真实项目要允许用户配置覆盖风险等级。

MCP 连接失败处理

不要一个 server 挂了,全局不可用。思路和后端做服务降级一样:推荐服务挂了,不能连下单一起挂。MCP server 属于非核心依赖,连不上就记下错误、跳过它,Agent 带着剩下的工具照常启动。

代码块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 {
      // 连接 server。
      const client = await connectServer(server);

      // 获取远程工具。
      const remoteTools = await client.listTools();

      // 包装成本地工具。
      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 notes tools.

Agent Event Transport 和 MCP Transport 不同

这两个名字都带 transport,容易混。区别一句话:一个是 Agent 和外部工具服务器之间的通信管道,另一个是 Agent 把自己的执行进度推给用户界面的管道。

名称通信对象内容
MCP TransportAgent CLI <-> MCP serverJSON-RPC
Agent Event TransportAgent CLI -> 用户界面AgentEvent

MCP message:

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

Agent event:

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

一个面向机器(外部 server),一个面向人(UI),消息格式和生命周期完全不同。不要把这两层写到一个模块里,混在一起之后想换 UI 展示方式或者升级 MCP 协议版本,两边会互相牵制。

Command / Skill / Plugin 的位置

Agent CLI 的扩展机制不止 MCP 一种。下面四种各管一摊,先给个总印象:Command 给人用,Skill 给模型看,Plugin 是打包安装的载体,MCP 把工具实现放到进程外面。

扩展作用
Command用户手动敲的斜杠命令,例如 /help/mcp,程序直接处理,不经过模型
Skill写好的可复用工作流说明(比如“发布流程怎么走”),匹配到就注入上下文,相当于给模型塞一份操作手册
Plugin一个打包单位,装一个 plugin 可以同时注册工具、命令和技能
MCP外部进程/服务通过协议提供工具,实现代码在 Agent 之外

关系图:

Extension Routing 输入先分流,再进入上下文和工具注册。 User Input Slash? Command Skill Match Context QueryEngine Registry Plugins Local MCP yes no

MCP 常见失败模式

这张表把前面各节埋的坑汇总了一遍:左列是坑,中间是实际跑起来会看到的症状,右列是解法,每一条都能在前文找到对应实现。表里的 ToolExecutor 是系列前几篇讲过的统一工具执行入口:所有工具调用都从它过一遍权限、超时、审计,MCP 工具的 wrapper 也必须走这条路,不能自己另开后门。

失败表现修复
stdout 分包JSON parse 失败buffer 按行解析
request 无超时pending 永久挂住request timeout
工具重名模型选错工具namespace
server 挂了整体不可用单 server 隔离
MCP 工具绕过权限越权wrapper 走 ToolExecutor
无审计无法定位外部工具问题audit 记录 server

源码验收门槛

能调通一个 server 只是起点。MCP 层真正要验收的一条:外部工具进入 Agent 之后,仍然受权限、超时、审计这些统一边界的控制。
下表中间一列是读源码时应该能找到的证据,右列是缺了这条时的典型症状。表里的 ToolExecutor / permission gate 分别指前文的统一执行入口和它里面执行前的权限审批关卡(该找用户确认的操作就得停下来等确认)。

验收项必须看到的证据不合格表现
初始化可追踪initializetools/listcallTool 都有 request id 和 timeoutserver 卡住导致主循环挂死
工具命名隔离MCP tool 进入 registry 后带 server namespace不同 server 同名工具互相覆盖
schema 不丢失wrapper 保留 input schema、description、风险等级模型只能看到模糊工具名
权限统一MCP 工具调用仍经过 ToolExecutor / permission gate外部工具绕过本地审批
审计完整audit log 记录 server、tool、args 摘要、耗时、错误出错后不知道是哪台 server
故障隔离单个 MCP server 失败不影响本地工具和其他 server一个扩展挂掉拖垮整个 Agent

最小测试集:一个正常 stdio server、一个超时 server、一个输出非法 JSON 的 server、两个同名工具 server。四个 case 都能被隔离和记录,MCP 层才算可用于真实工程。

读源码抓手

看 MCP 和扩展层时,问:

  1. MCP config 从哪里读?
  2. stdio/http transport 怎么抽象?
  3. initialize/listTools/callTool 在哪里?
  4. MCP tool 如何包装成本地 Tool?
  5. 是否 namespace?
  6. 是否走统一权限?
  7. 连接失败是否隔离?
  8. MCP transport 和 UI transport 是否分离?
  9. plugin 能否绕过权限?

把 MCP 层当成 Agent 工具生态的边界层来读:所有外部能力从这里进来,也在这里被套上和本地工具相同的规矩。上面九个问题就是在检查这道边界有没有漏。

延伸阅读