手写 MCP(Model Context Protocol)客户端:JSON-RPC 2.0 与跨进程工具发现

本节目标

为什么需要 MCP(Model Context Protocol)?

在没有 MCP 之前,如果你开发了 3 个不同的 Agent 应用(例如桌面聊天客户端、CLI 编码助手、CI 审查机器人),而团队需要接入 GitHub、PostgreSQL、Obsidian 笔记和 Sentry 日志 4 类工具,你必须在每个 Agent 里重复编写 3 × 4 = 12 套工具适配代码。

MCP(Model Context Protocol) 将「工具提供方(MCP Server)」与「Agent 宿主(MCP Host / Client)」通过标准的 JSON-RPC 2.0 协议彻底解耦:

┌──────────────────────┐          JSON-RPC 2.0           ┌─────────────────────────┐
│  JS Agent (MCP Host) │ ◄────── (Stdio / HTTP) ───────► │  Git / Postgres / File  │
│  ┌────────────────┐  │   1. initialize 握手协商能力    │       MCP Server        │
│  │  McpStdioClient│  │   2. tools/list 动态拉取 Schema │  (独立进程 / 任何语言)   │
│  └────────────────┘  │   3. tools/call 跨进程调用执行  └─────────────────────────┘
└──────────────────────┘

MCP 生命周期握手四步曲

一个合规的 MCP 客户端连接 MCP Server 时,必须严格遵循以下协议时序:

  1. 客户端发送 initialize 请求(携带 protocolVersion: "2024-11-05" 与 clientInfo)。
  2. 服务端返回 result(包含 serverInfo 与支持的 capabilities,如 tools、resources、prompts)。
  3. 客户端发送单向通知 notifications/initialized(无 id 字段),宣告握手完成。
  4. 客户端调用 tools/list 获取工具列表,并在模型发起调用时通过 tools/call 执行。

手写原生 Node.js Stdio MCP 客户端

下面的代码使用 Node.js 内置的 node:child_process 实现了一个标准 Stdio MCP 客户端,支持请求 ID 异步映射、行缓冲解析、握手协商以及将远程 MCP 工具一键注入上一节的 ToolRegistry:

import { spawn } from 'node:child_process'

export class McpStdioClient {
  constructor({ command, args = [], env = {} }) {
    this.command = command
    this.args = args
    this.env = { ...process.env, ...env }
    this.proc = null
    this.nextId = 1
    this.pending = new Map() // id -> { resolve, reject, timer }
    this.stdoutBuffer = ''
  }

  async connect() {
    this.proc = spawn(this.command, this.args, {
      env: this.env,
      stdio: ['pipe', 'pipe', 'pipe']
    })

    this.proc.stdout.setEncoding('utf-8')
    this.proc.stdout.on('data', (chunk) => this.#onStdoutData(chunk))
    this.proc.on('exit', (code) => {
      for (const [, p] of this.pending) {
        clearTimeout(p.timer)
        p.reject(new Error(`MCP Server 进程已退出 (code=${code})`))
      }
      this.pending.clear()
    })

    // 1. 发送 MCP initialize 握手请求
    const initResult = await this.request('initialize', {
      protocolVersion: '2024-11-05',
      capabilities: {},
      clientInfo: { name: 'js-agent-mcp-client', version: '1.0.0' }
    })

    // 2. 发送 initialized 通知(JSON-RPC Notification 不含 id)
    this.notify('notifications/initialized', {})
    return initResult
  }

  #onStdoutData(chunk) {
    this.stdoutBuffer += chunk
    const lines = this.stdoutBuffer.split('\n')
    this.stdoutBuffer = lines.pop() || ''

    for (const raw of lines) {
      const line = raw.trim()
      if (!line) continue
      try {
        const msg = JSON.parse(line)
        if (msg.id !== undefined && this.pending.has(msg.id)) {
          const req = this.pending.get(msg.id)
          this.pending.delete(msg.id)
          clearTimeout(req.timer)
          if (msg.error) req.reject(new Error(`MCP Error [${msg.error.code}]: ${msg.error.message}`))
          else req.resolve(msg.result)
        }
      } catch {
        // 忽略非 JSON 日志行
      }
    }
  }

  request(method, params = {}, timeoutMs = 15000) {
    const id = this.nextId++
    const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pending.delete(id)
        reject(new Error(`MCP 请求 ${method} (id=${id}) 超时`))
      }, timeoutMs)
      this.pending.set(id, { resolve, reject, timer })
      this.proc.stdin.write(payload)
    })
  }

  notify(method, params = {}) {
    const payload = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'
    this.proc.stdin.write(payload)
  }

  // 3. 动态发现 MCP Server 暴露的所有工具
  async listTools() {
    const res = await this.request('tools/list', {})
    return res.tools || []
  }

  // 4. 调用指定的 MCP 工具并提取文本结果
  async callTool(name, args = {}) {
    const res = await this.request('tools/call', { name, arguments: args })
    const textBlocks = (res.content || [])
      .filter((c) => c.type === 'text')
      .map((c) => c.text)
      .join('\n')
    if (res.isError) throw new Error(textBlocks || 'MCP 工具执行返回错误')
    return textBlocks
  }

  // 5. 将 MCP Server 的全部工具自动桥接注册到 Agent 的 ToolRegistry
  async mountToRegistry(registry, namespace = 'mcp') {
    const mcpTools = await this.listTools()
    for (const t of mcpTools) {
      registry.register({
        name: `${namespace}_${t.name}`,
        description: t.description || '',
        schema: t.inputSchema || { type: 'object', properties: {} },
        execute: (args) => this.callTool(t.name, args)
      })
    }
  }

  close() {
    this.proc?.kill()
  }
}

课后练习

练习 1:在 JSON-RPC 2.0 协议与 MCP 握手流程中,request('initialize', ...) 与 notify('notifications/initialized', ...) 发送的报文在字段结构上有什么核心区别?

答案:request 是需要服务端回复的 RPC 调用,报文必须携带唯一的 id 字段,客户端通过 pending Map 等待相同 id 的响应;而 notify 是单向事件通知(Notification),报文不包含 id 字段,服务端处理后不会返回响应报文。

练习 2:为什么基于 Stdio(标准输入输出)的 MCP Server 严禁在业务代码中使用 console.log() 打印普通调试日志?

答案:在 Stdio 传输模式下,stdout(标准输出流)是传输 JSON-RPC 2.0 报文的唯一通信通道。一旦业务代码调用 console.log('debugging...') 向 stdout 写入非 JSON 文本,就会污染协议帧结构导致客户端解析失败;所有调试日志必须写入 stderr(即 console.error())。