核心主要就是一句话:MCP 把「每个 Agent × 每个服务」的集成问题,从乘法(N×M)变成了加法(N+M)。
前言
Agent 不仅要拥有属于自己的工具,也要接外部能力------查 GitHub issue、读数据库、调浏览器、跑代码。在 MCP 出现之前,每接一个服务,你都要手写一堆工具函数 :list_issues、create_issue、get_repo......每个函数自己处理鉴权、分页、错误码、序列化。繁琐的是,这套活每换一个服务就要重做一遍------接 GitHub 写一套,接数据库再写一套,Agent 的代码里就会堆满了跟「Agent」本身毫无关系的东西。
MCP(Model Context Protocol,Anthropic 2024 年底提出的开放标准)要解决的就是这件事:让「工具提供方」和「工具消费方」解耦。 服务方按标准写一个 MCP server,暴露工具;Agent 方按标准写一个 MCP client,发现并调用工具。于是集成量从「M 个客户端 × N 个服务」降成「M 个客户端 + N 个服务」------这就是它被当成「Agent 工具标准」的原因。
一、MCP 的骨架:三个约定,缺一不可
MCP 没有很特别,它只是把三件事标准化了。你的 client 只要遵守这三个约定,就几乎能跟任何 server 说话:
- 传输 :客户端用一个子进程 把 server 拉起来,双方通过 stdin / stdout 互相发消息(本地场景),每行一个 JSON。
- 协议 :消息格式是 JSON-RPC 2.0 ------有
method、params、id,请求带id,响应回同一个id。 - 原语 :核心就两个方法------
tools/list(问 server「你有什么工具」)和tools/call(「帮我调这个工具」)。
取舍点 :注意,MCP 标准里其实还有 resources(资源)和 prompts(提示词)两个原语,但绝大多数 Agent 落地只用到 tools 这一支 。这是有意为之------「够用就好」,把复杂度压在工具调用这一个最痛的点上,而不是一上来就摊开一个大而全的协议。你的 client 只要实现 initialize + tools/list + tools/call,就能跑起来。
二、「一行注册」的真相:工具是问出来的,不是写死的
回到标题那句「一行注册一个 GitHub 工具」。它的意思不是「写一行代码注册一个工具」,而是------写一次注册逻辑,把整个 server 下的所有工具一次性注册进来。 接一个 GitHub server,起作用的就这么几行:
ts
const proc = spawn('npx', ['-y', '@modelcontextprotocol/server-github'], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32', // Windows 下 npx 是 .cmd,必须经 shell
env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN },
})
const tools = await listTools(proc) // 注册逻辑:问出工具 → 逐个注册
npx @modelcontextprotocol/server-github 拉起了 GitHub 官方 MCP server,注册的核心是动态发现:
json
客户端 → 服务端 {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
服务端 → 客户端 {"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"github"}}}
客户端 → 服务端 {"jsonrpc":"2.0","method":"notifications/initialized"} // 通知,无 id,不期待回复
客户端 → 服务端 {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
服务端 → 客户端 {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"create_issue","description":"...","inputSchema":{...}},
{"name":"list_issues","description":"...","inputSchema":{...}},
...几十个...
]}}
取舍点 :这就是 MCP 和「手写工具」的本质区别------工具列表是运行时问出来的,不是编译期写死的。 你不需要在 Agent 代码里声明「GitHub 有哪些工具」,server 自己知道,tools/list 一问全交出来。所以「接一个服务」的边际成本从「写 N 个函数」掉到了「起一个子进程」。server 升级加了个新工具,你的 Agent 一行不用改,下次 tools/list 自动就有了。
三、手写 client 的三个坑
官方标准摆在那,但把 client 真正写对,有三个问题一定要注意。
问题一:按 id 对账,别「发一个等一个」。 JSON-RPC 的响应是异步回来的,而且理论上可以乱序(比如走 HTTP 传输时)。所以 client 得维护一张「在途请求表」,响应回来按 id 对号入座:
ts
const pending = new Map<number, { resolve; reject }>()
function send(method, params) {
return new Promise((resolve, reject) => {
const id = ++requestId
const timer = setTimeout(() => reject(new Error(`timeout: ${method}`)), 15000)
pending.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v) },
reject: (e) => { clearTimeout(timer); reject(e) },
})
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
})
}
// stdout 每行一个响应,按 id 找 pending 里的对应 Promise
readline.createInterface({ input: proc.stdout }).on('line', (line) => {
const msg = JSON.parse(line)
const p = pending.get(msg.id)
if (p) { pending.delete(msg.id); msg.error ? p.reject(msg.error) : p.resolve(msg.result) }
})
取舍点 :pending 表 + id 匹配,是这个 client 的「心脏」。有人图省事会写「发一条、等一条」的同步逻辑,在 stdio 这种顺序传输上勉强能跑,但一旦换成 HTTP、或者未来想并发调多个工具,立刻翻车。按 id 对账是 JSON-RPC 的通用姿势,别贪那个省事。
问题二:Windows 下必须 shell: true。 这一行很容易漏:
ts
shell: process.platform === 'win32', // Windows 下 npx / pnpm 是 .cmd 脚本
原因是 npx、pnpm 这类命令在 Windows 上是 .cmd 批处理脚本,spawn 不经过 shell 时无法直接执行 .cmd ,会报 ENOENT。所以 Windows 平台要开 shell: true 让命令经 shell 执行。
取舍点 :shell: true 是个双刃剑------它解决了 .cmd 的问题,但如果你的命令字符串里有来自不可信输入的参数 ,就打开了命令注入的口子。所以这里有个前提:command 和 args 必须是白名单里的固定值 (比如固定的 npx + 固定的 server 包名),绝不能拼用户输入。踩这个坑的代价,是安全事故。
问题三:env 透传。 { ...process.env, ...yourEnv } 把主进程的环境变量(包括你从 .env 读出来的 GITHUB_TOKEN)传给子进程。漏了这行,server 起来却拿不到 token,tools/list 能通、tools/call 全报 401,你还得排查半天。
四、两个取舍:传输出路 & 别报假超时
取舍一:stdio 子进程 vs Streamable HTTP。 MCP 支持两种主流传输------本地用 stdio(起子进程),远程用 Streamable HTTP。本地跑 Agent 通常选 stdio,理由很实在:
- stdio 子进程:server 随 Agent 起、随 Agent 死,没有端口、没有网络暴露、不用管鉴权,最省心。
- Streamable HTTP:能远程部署、能被多个客户端共享,但要管 URL、端口、认证,复杂度上一个台阶。
取舍点 :不是「哪种更好」,是「你部署在哪」。本地单机 Agent,stdio 是默认答案 ;哪天你要把 server 部署到一台共享的机器上给团队用,再切 HTTP。协议层你已经按 id 对账写好了,换传输只是换掉 spawn 那一层。
取舍二:子进程崩了,别让它报「假超时」。 这是最值得学的一处细节。想象一个场景:你 tools/call 发出去,server 进程突然崩了------stdout 再也不会吐响应,你的 Promise 就挂在 pending 表里,直到 15 秒超时器触发,然后报一个误导性的错误「request timeout」。真实原因明明是「进程挂了」,却报成「超时」,排查方向直接带偏。
解法是监听子进程的 exit 事件,在进程死的那一刻,主动把表里所有 pending 请求 reject 掉,并带上真正的死因:
ts
let stderrBuf = ''
proc.stderr.on('data', (d) => stderrBuf += d.toString())
proc.on('exit', (code) => {
if (code === 0) return
const err = new Error(`server 进程退出 (code=${code})` + stderrBuf.trim().slice(-500))
for (const p of pending.values()) p.reject(err)
pending.clear()
})
取舍点 :多写了十几行,换来的是报错能报出真实原因 ------code=1 加上 stderr 的最后 500 字符,一眼看出是「token 没配」还是「包版本不对」。这跟前几篇反复强调的「失败要报真实原因、别用假超时糊弄」是同一个原则。超时是兜底,不是用来诊断的;能拿到真实死因,就别让人对着超时猜。
五、注册进工具表:前缀防重名 + 延迟加载省 token
tools/list 拿回来几十个工具,不能直接原样塞进 Agent 的工具表,注册时有两个处理很关键。
1. 加前缀,防重名。 每个工具的名字被改成 mcp__<server>__<tool>:
ts
for (const tool of tools) {
registry.register({
name: `mcp__${serverName}__${tool.name}`, // 比如 mcp__github__create_issue
description: `[MCP:${serverName}] ${tool.description}`,
parameters: tool.inputSchema,
execute: (input) => client.callTool(tool.name, input),
})
}
取舍点 :为什么必须加前缀?因为两个不同的 server 很可能提供同名工具 ------GitHub 有 create_issue,GitLab 也有 create_issue。不加前缀,后注册的会覆盖先注册的,模型调用时根本说不清「我要调哪个」。前缀既是命名空间,也是给模型看的「归属提示」------mcp__github__list_issues 一看就知道这是 GitHub 服务器的工具。
2. 延迟加载,省 token。 GitHub 官方 server 有几十个工具,每个的 inputSchema(JSON Schema)都有几十上百行。全塞进系统提示词,token 直接爆炸------而模型一次对话大概率只用得上其中一两个。所以常见做法是「延迟加载」:默认只在 prompt 里留一个工具名的摘要,模型真要用时,再按名字把完整定义捞出来。
取舍点 :这跟我们讲「外置记忆按需读取」、「上下文 token 估算」是同一个思路------别把用不上的东西塞进上下文,用到再取。 工具多了以后,这从「优化」变成「必须」。
六、MCP 是增强,不是必需
最后一个取舍,只有几行,但姿态很对:把 MCP 的连接包进 try/catch,失败就打个警告跳过,别让整个 Agent 崩掉。
ts
try {
await connectMCP()
} catch (err) {
console.log(`⚠ MCP 连接失败,已跳过: ${err.message}`)
}
取舍点 :MCP server 依赖一堆外部前提------npx 装了没、GITHUB_TOKEN 配了没、网络通不通。任何一个不满足,连接就会抛。 但你的 Agent 不该因为「接不上 GitHub」就整个启动失败------它还有本地文件、shell、RAG 这些核心工具能用。
所以这里的原则是:外部能力要「失败降级」,而不是「启动即崩」。 MCP 是把 Agent 的边界往外扩了一圈,但它始终是「增强项」,不是「地基」。地基塌了要报错停下,增强项挂了就该打个 ⚠ 然后继续跑。这个「什么该硬、什么该软」的判断,是工程化的分水岭。
结语
把 MCP 客户端这一条线串起来,其实就一句话:
用子进程把标准 server 拉起来,按
id对账地收发 JSON-RPC,tools/list问出工具,加前缀注册、延迟加载、失败降级。
它最大的价值不在代码,而在那个「N×M → N+M」的账:你写一次 client,全世界的 MCP server 就都能接了;别人写一次 server,所有 Agent 就都能用了。 工具定义从「写死在代码里」变成「运行时动态发现」。
参考: