手写 MCP(Model Context Protocol)客户端:JSON-RPC 2.0 与跨进程工具发现
本节目标
- 深入理解 MCP(Model Context Protocol,模型上下文协议) 为什么能成为连接 AI Agent 与外部数据源的「USB-C 标准接口」
- 掌握 MCP 底层的 JSON-RPC 2.0 报文协议与生命周期握手(
initialize→notifications/initialized→tools/list→tools/call) - 用原生 Node.js
child_process.spawn手写一个完整的 Stdio MCP Client,实现动态发现外部 MCP Server 工具并桥接给 Agent 使用
为什么需要 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 时,必须严格遵循以下协议时序:
- 客户端发送
initialize请求(携带protocolVersion: "2024-11-05"与clientInfo)。 - 服务端返回
result(包含serverInfo与支持的capabilities,如tools、resources、prompts)。 - 客户端发送单向通知
notifications/initialized(无id字段),宣告握手完成。 - 客户端调用
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())。