MCP 客户端接入:一行注册一个 GitHub 工具

核心主要就是一句话:MCP 把「每个 Agent × 每个服务」的集成问题,从乘法(N×M)变成了加法(N+M)。

前言

Agent 不仅要拥有属于自己的工具,也要接外部能力------查 GitHub issue、读数据库、调浏览器、跑代码。在 MCP 出现之前,每接一个服务,你都要手写一堆工具函数list_issuescreate_issueget_repo......每个函数自己处理鉴权、分页、错误码、序列化。繁琐的是,这套活每换一个服务就要重做一遍------接 GitHub 写一套,接数据库再写一套,Agent 的代码里就会堆满了跟「Agent」本身毫无关系的东西。

MCP(Model Context Protocol,Anthropic 2024 年底提出的开放标准)要解决的就是这件事:让「工具提供方」和「工具消费方」解耦。 服务方按标准写一个 MCP server,暴露工具;Agent 方按标准写一个 MCP client,发现并调用工具。于是集成量从「M 个客户端 × N 个服务」降成「M 个客户端 + N 个服务」------这就是它被当成「Agent 工具标准」的原因。

一、MCP 的骨架:三个约定,缺一不可

MCP 没有很特别,它只是把三件事标准化了。你的 client 只要遵守这三个约定,就几乎能跟任何 server 说话:

  1. 传输 :客户端用一个子进程 把 server 拉起来,双方通过 stdin / stdout 互相发消息(本地场景),每行一个 JSON。
  2. 协议 :消息格式是 JSON-RPC 2.0 ------有 methodparamsid,请求带 id,响应回同一个 id
  3. 原语 :核心就两个方法------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 脚本

原因是 npxpnpm 这类命令在 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 就都能用了。 工具定义从「写死在代码里」变成「运行时动态发现」。


参考:

相关推荐
kolyle1 小时前
万级 QPS 下的 Token 分发系统架构:从 0 到 1 跑通 AI 时代的“水电煤“
开发语言·人工智能·系统架构·token·qps·极智词元·大模型私有化部署
掰头战士1 小时前
Prompt Cache,如果agent全靠LLM方做隐式缓存实在不够用。
前端·llm·agent
GoGeekBaird1 小时前
(万字长文拆解云沙箱)让 Agent 从执行代码升级到拥有一个临时Runtime
后端·agent
古少侠1 小时前
deepseek转word工具怎么选?DS随心转与4种方案对比实测
人工智能·word·powerpoint
torin1 小时前
Javaer转Agent:学习资料篇
后端·agent
Code_Artist1 小时前
☢︎自然语言 → 机器码:这到底是 AI 编程的终极形态,还是一个伪命题?
人工智能·llm·ai编程
老纪的技术唠嗑局2 小时前
Agent 习惯性删库跑路,数据库纷纷学 Git 续命
数据库·人工智能
开发笔记-阿牛2 小时前
做工业报警器语音提示,CK6159A 为什么更合适?
人工智能·stm32·单片机·嵌入式硬件·音频
dehuisun2 小时前
第 06 篇:RAG 混合召回策略:向量检索 + ES 关键词 + Rerank 重排
人工智能