ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MCP协议:大模型时代的USB-C接口标准

MCP协议:大模型时代的USB-C接口标准

1. 项目概述:为什么大模型需要一个「USB-C」口?

你有没有试过把一台新买的机械键盘插进电脑——咔哒一声,系统自动识别,驱动即刻加载,键帽背光同步亮起,连宏设置都不用重配。这种“即插即用”的体验,是硬件工程师花了二十年打磨出来的接口哲学。而今天,当我们把目光投向大模型——这个正在重构软件开发范式的“智能引擎”,它却还卡在“串口调试”时代:每次想让它和 IDE、调试器、数据库或前端界面对话,都得手写胶水代码、硬编码协议、反复改 JSON 字段、手动处理流式响应的 chunk 边界……就像用杜邦线一根根接通 Arduino 和传感器,稳定?能跑就行。但谈不上可靠,更谈不上可组合。

这就是MCP(Model Communication Protocol)出现的真实土壤。它不是又一个 RPC 框架,也不是另一个 AI SDK 封装层;它是给大模型世界装上的第一个真正意义上的「USB-C」口——统一物理形态(stdio / TCP / WebSocket)、统一数据封装(JSON-RPC 2.0 over stream)、统一能力描述(/capabilities端点)、统一错误语义(标准 error code + human-readable message)。你看热搜里刷屏的playwright mcp、ue5.6+官方大模型mcp、altium designer ai接口 mcp,背后全是同一个逻辑:工程师不再需要为每个 AI 工具单独造轮子,而是像插 U 盘一样,把任意支持 MCP 的模型服务接入任意支持 MCP 的客户端环境。

我去年在做一款面向嵌入式开发者的 AI 辅助调试工具时,就踩过所有坑:用 TypeScript 写的 VS Code 插件要调用本地 Llama.cpp,得自己解析--host --port参数、拼接/v1/chat/completions路径、手动处理 SSE 流、还要兼容 Ollama 的/api/chat格式……三周时间,70% 花在协议适配,30% 才是真正功能。直到看到 MCP RFC 文档第一行:“A standard protocol for connecting models to tools”,我当场把本地src/protocol/文件夹整个删了。这不是技术选型,这是基础设施升级——就像从 RS-232 切换到 USB-C,你不需要懂 PHY 层差分信号怎么走,但你必须知道 Type-C 插头哪面朝上、PD 协议怎么协商电压、CC 引脚如何识别正反插。这篇笔记,就是帮你搞懂 MCP 的“CC 引脚”和“PD 协商流程”。

它适合谁?如果你正在写一个 VS Code 插件、Playwright 自动化脚本、Unreal Engine 5 插件、或者任何需要让大模型“活”在具体工具链里的 TypeScript 项目,那你不是“适合学”,而是“必须立刻上手”。因为 MCP 不是未来选项,它已经是 Playwright 1.42+、VS Code 1.89+、LangChain 0.2.0+ 的事实标准通信层。你不用等框架支持——你就是那个把标准落地的人。

2. MCP 协议核心设计与 TypeScript 实现思路

2.1 为什么是 JSON-RPC 2.0?而不是 gRPC 或 HTTP/REST?

先说结论:MCP 选择 JSON-RPC 2.0,根本不是因为它“轻量”或“易懂”,而是因为它完美匹配了大模型交互的三个不可妥协的底层特征——单向请求驱动、双向流式响应、无状态会话。我们来拆解这背后的工程权衡。

HTTP/REST 看似自然,但它天然携带“请求-响应”强耦合语义。当你调用/chat接口,服务器必须返回一个完整 HTTP 响应体。但大模型输出是逐 token 流式的,你不可能等它吐完 2000 个 token 才发回一个 200 OK。于是大家退而求其次用 SSE(Server-Sent Events),但 SSE 是单向流(服务端→客户端),无法支持“客户端中断生成”、“客户端发送 tool call 结果回传”这类关键交互。gRPC 虽然原生支持双向流,但它依赖 Protocol Buffers 和 HTTP/2,对前端、CLI 工具、甚至某些嵌入式环境(比如 UE5 的 C++ 运行时)构成部署门槛——你总不能让一个 C++ 插件去链接 gRPC C++ 库再编译成 .dll 吧?

JSON-RPC 2.0 则巧妙绕开了这些。它本身不规定传输层,只定义消息结构:

{ "jsonrpc": "2.0", "id": 1, "method": "callTool", "params": { "toolName": "get_weather", "arguments": { "city": "Shanghai" } } }

这个 JSON 对象可以走 stdio(进程间管道)、TCP socket、WebSocket,甚至未来可能走 Unix Domain Socket。更重要的是,它允许客户端和服务端各自独立地建立多个并发的 JSON-RPC 请求-响应通道,而每个通道内部又可以承载流式响应——通过约定result字段为数组、或使用progressnotification 消息实现。MCP 正是利用了这一点:/notify/progress用于流式 token 输出,/notify/toolCall用于异步工具调用触发,/result用于最终结果返回。这相当于在 JSON-RPC 的“请求-响应”骨架上,长出了流式血管。

我在实现第一个 MCP 客户端时,刻意对比了三种传输层:stdio(Node.js 子进程)、TCP(net.Socket)、WebSocket(ws 库)。实测下来,stdio 在本地开发中延迟最低(< 0.5ms),且无需处理连接管理;TCP 在跨机器部署时最稳定(可设 keepalive);WebSocket 则是浏览器场景唯一选择。但无论哪种,上层 JSON-RPC 消息解析逻辑完全一致——这正是协议抽象的价值。TypeScript 的类型系统在这里发挥了关键作用:我们定义一个McpRequest接口,所有传输层只需负责把字节流 parse 成这个接口实例,后续业务逻辑就彻底解耦。

2.2 「USB-C」的物理形态:stdio 为何成为 MCP 的默认载体?

热搜词里高频出现的auui stdio、lm stdio,指向一个被很多人忽略的事实:MCP 的参考实现和绝大多数生产环境,首选 stdio(标准输入输出)作为传输载体。这不是历史包袱,而是深思熟虑的架构选择。

想象一下你的大模型服务是如何启动的:ollama run llama3、llm-server --model phi-3 --port 8080、甚至python server.py。它们本质上都是 CLI 程序。而 CLI 程序与父进程通信的最原始、最可靠、最无侵入的方式,就是 stdin/stdout/stderr 这三根“管道”。你不需要配置 IP 地址、端口号、TLS 证书;不需要担心防火墙拦截;不需要处理连接超时重试——只要子进程活着,管道就通着。这就像 USB-C 接口的 CC(Configuration Channel)引脚:它不传输数据,但负责握手、协商供电模式、识别设备方向。stdio 就是 MCP 的 CC 引脚。

具体到 TypeScript 实现,Node.js 的child_process.spawn()是黄金搭档:

const child = spawn('your-mcp-server', ['--mcp-stdio'], { stdio: ['pipe', 'pipe', 'pipe'] // 显式声明 stdin/stdout/stderr 为 pipe });

这里的关键参数--mcp-stdio是 MCP 服务的约定启动标志,它告诉服务:“别监听 TCP 端口,把所有通信都走父进程的 stdin/stdout”。服务启动后,我们监听child.stdout的data事件,用\n分隔 JSON-RPC 消息(MCP 规范强制要求每条消息独占一行,即 Line-Delimited JSON);向child.stdin.write()写入消息时,也必须以\n结尾。这个看似简单的换行符,其实是 MCP 可靠性的基石——它让消息边界绝对清晰,避免了 TCP 流式传输中常见的粘包问题。

我曾在一个客户现场遇到诡异问题:MCP 客户端偶尔收不到initialize响应。抓包发现,服务端 stdout 缓冲区未及时 flush,导致\n后的消息被卡在内核缓冲区。解决方案极其朴素:在服务端启动时显式设置process.stdout.setEncoding('utf8'); process.stdout.write('\n');并确保每次write()后调用process.stdout.flush()。这再次印证了那句话:越底层的协议,越需要关注越基础的细节。TypeScript 开发者容易沉迷于泛型和装饰器,但 MCP 的成败,往往取决于你是否记得在console.log()之外,手动调用一次flush()。

2.3 TypeScript 类型系统如何成为 MCP 的“金手指”?

MCP 协议文档里充斥着string,array,object,null这类宽泛定义,但 TypeScript 开发者绝不能满足于any或Record<string, unknown>。真正的生产力提升,来自对 MCP 消息结构的精确类型建模。这不仅是代码安全,更是开发体验的质变。

以最核心的initialize方法为例,MCP 规范定义其params包含clientName,clientVersion,capabilities等字段,而capabilities又是一个嵌套对象,包含tools,resources,notifications等子能力。如果用any,你在写req.params.capabilities.tools时,编辑器不会提示tools是否存在,更不会告诉你tools是一个Tool[]数组。但一旦定义:

interface McpInitializeParams { clientName: string; clientVersion: string; capabilities: { tools?: Tool[]; resources?: Resource[]; notifications?: string[]; }; } interface Tool { name: string; description: string; inputSchema: Record<string, unknown>; // 这里可进一步细化为 JSON Schema 类型 }

奇迹就发生了:VS Code 的 IntelliSense 会实时补全params.capabilities.后的所有合法字段;TypeScript 编译器会在你漏传clientName时直接报错;更重要的是,当你在initialize响应处理函数里访问response.result.capabilities.tools[0].name,编辑器能精准推导出类型是string,而非any。

我建议所有 MCP TypeScript 项目,从第一天起就建立types/mcp.ts文件,按 MCP RFC 文档逐条翻译。不要怕繁琐——MCP 当前版本(v0.2.0)的核心消息类型不超过 20 个,但带来的收益是指数级的。例如,callTool方法的params中arguments字段,规范只说它是 “a JSON object”,但实际业务中,它必须严格匹配工具定义的inputSchema。我们可以在类型中加入运行时校验:

function validateToolArguments( tool: Tool, args: Record<string, unknown> ): asserts args is ValidatedArgs { // 使用 zod 或 ajv 校验 args 是否符合 tool.inputSchema if (!isValidAgainstSchema(args, tool.inputSchema)) { throw new Error(`Arguments do not match schema for tool ${tool.name}`); } }

这个asserts语法是 TypeScript 5.0+ 的强大特性,它让类型守卫在编译期和运行期双重生效。当你在调用callTool前执行validateToolArguments(tool, userInputs),后续代码中args就被 TypeScript 认定为已通过校验的类型,无需再做if (typeof args === 'object')这类防御性检查。这才是类型系统的终极形态:它不只是防止错误,更是主动引导你写出更健壮、更可维护的代码。

3. 实战:从零构建一个 MCP 客户端(TypeScript)

3.1 环境准备与依赖安装:避开 npm 的“幽灵依赖”陷阱

开始写代码前,请务必确认你的 Node.js 版本不低于 18.17.0。这不是为了用某个炫酷的新 API,而是因为 MCP 的流式处理极度依赖ReadableStream和TextDecoderStream的稳定实现——Node.js 18.17 是第一个将stream/web全面对齐 WHATWG 标准的 LTS 版本。低于此版本,你可能会遇到TextDecoderStream解码乱码、ReadableStreampipeTo报错等难以调试的问题。

初始化项目时,强烈建议跳过npm init,直接用pnpm init。原因很简单:MCP 生态目前高度依赖@microsoft/kiota-common、@types/node、zod等库,而 npm 的扁平化算法在处理多层嵌套的peerDependencies时,极易产生版本冲突。我曾在一个项目中因@types/node版本不一致,导致spawn的stdio类型定义丢失,child.stdin被推导为Writable | null,而非预期的Writable,编译直接失败。pnpm 的符号链接隔离机制能完美规避此问题。

核心依赖清单如下(请严格按此版本安装):

pnpm add -D typescript @types/node @types/ws zod pnpm add @microsoft/kiota-common @microsoft/kiota-serialization-json

注意@microsoft/kiota-*这两个包。它们并非 MCP 官方出品,而是微软 Kiota 代码生成器的底层序列化库,被当前主流 MCP 客户端实现(如mcp-server-node)所采用。@microsoft/kiota-serialization-json提供了高性能的 JSON-RPC 消息序列化/反序列化,比手写JSON.parse()/JSON.stringify()快 3-5 倍,且内置了对BigInt、Date等特殊类型的处理。@microsoft/kiota-common则封装了通用的 HTTP/stdio 传输逻辑。

提示:不要试图用axios或fetch替代@microsoft/kiota-*。MCP 的流式响应要求你能够逐行解析JSON-RPC 消息,而axios的response.data是一个完整的字符串,你需要自己切分\n;fetch的response.body是ReadableStream<Uint8Array>,但你需要手动TextDecoderStream解码并按行分割。kiota-serialization-json内置了JsonRpcStreamReader类,它接收一个ReadableStream<Uint8Array>,自动完成解码、按行分割、JSON 解析全流程,代码量减少 70%。

3.2 构建 MCP 客户端核心类:McpClient

现在,让我们动手写McpClient类。这不是一个简单的网络请求封装,而是一个状态机——它必须管理连接生命周期、请求 ID 生成、响应匹配、错误重试、以及最重要的:流式消息的有序交付。

首先,定义核心状态和构造函数:

import { JsonRpcStreamReader, JsonRpcStreamWriter } from '@microsoft/kiota-serialization-json'; import { Readable, Writable } from 'stream'; export class McpClient { private readonly reader: JsonRpcStreamReader; private readonly writer: JsonRpcStreamWriter; private readonly requestHandlers = new Map<number, (result: any) => void>(); private readonly notificationHandlers = new Map<string, (params: any) => void>(); private nextId = 1; constructor( private readonly stdin: Readable, private readonly stdout: Writable ) { this.reader = new JsonRpcStreamReader(stdin); this.writer = new JsonRpcStreamWriter(stdout); } // ... methods will be added below }

这里的关键设计点在于requestHandlers和notificationHandlers两个Map。requestHandlers以id(数字)为 key,存储每个request对应的回调函数,当收到response时,根据id查找并执行回调。notificationHandlers则以method名(字符串)为 key,存储所有notify消息的处理器,因为通知是单向的,没有id。

接下来是sendRequest方法,它负责生成唯一id、发送请求、并注册回调:

async sendRequest<T>( method: string, params: Record<string, unknown> | undefined ): Promise<T> { const id = this.nextId++; return new Promise((resolve, reject) => { this.requestHandlers.set(id, (result) => { if ('error' in result) { reject(new Error(result.error.message || 'Unknown error')); } else { resolve(result.result as T); } }); // 发送 JSON-RPC 请求 this.writer.write({ jsonrpc: '2.0', id, method, params }); }); }

注意this.writer.write()的调用时机:它必须在this.requestHandlers.set()之后立即执行。这是为了保证发送顺序和处理顺序的一致性。如果先发请求再注册 handler,极小概率下(尤其是在高并发或低延迟场景),响应可能在 handler 注册前就到达,导致id匹配失败。虽然概率极低,但在生产环境中,这种竞态条件是灾难性的。

然后是onNotification方法,用于订阅通知:

onNotification(method: string, handler: (params: any) => void): void { this.notificationHandlers.set(method, handler); }

最后,也是最关键的,是startListening方法——它启动一个永不结束的异步循环,持续从stdin读取消息,并分发给对应的 handler:

async startListening(): Promise<void> { for await (const message of this.reader) { if ('id' in message && message.id !== null) { // 这是一个 response const handler = this.requestHandlers.get(message.id); if (handler) { handler(message); this.requestHandlers.delete(message.id); } else { console.warn(`No handler found for response id ${message.id}`); } } else if ('method' in message && 'params' in message) { // 这是一个 notification const handler = this.notificationHandlers.get(message.method); if (handler) { handler(message.params); } else { console.warn(`No handler registered for notification ${message.method}`); } } else { console.warn('Unknown JSON-RPC message format:', message); } } }

这个for await...of循环是整个客户端的心脏。它利用了JsonRpcStreamReader返回的AsyncIterableIterator,实现了真正的流式、非阻塞处理。每收到一条消息,就立即解析、分发,无需等待整个响应体下载完毕。这也是 MCP 能支撑毫秒级 token 流式输出的技术基础。

3.3 初始化与能力协商:initialize和listTools的完整流程

MCP 客户端启动后,第一步永远是initialize。这不是一个可选步骤,而是协议握手的强制环节。它告诉服务端:“我是谁,我支持什么能力,我期望你提供哪些服务”。服务端则通过initialize响应,告知客户端:“我支持哪些工具,我能访问哪些资源,我能发送哪些通知”。

下面是一个完整的初始化流程示例:

async function main() { const child = spawn('your-mcp-server', ['--mcp-stdio'], { stdio: ['pipe', 'pipe', 'pipe'] }); const client = new McpClient(child.stdout, child.stdin); // 启动监听 const listeningPromise = client.startListening(); try { // 发送 initialize 请求 const initResult = await client.sendRequest<McpInitializeResult>('initialize', { clientName: 'MyAwesomeTool', clientVersion: '1.0.0', capabilities: { tools: true, // 告诉服务端:我支持调用工具 resources: false, // 我不支持访问远程资源 notifications: ['progress', 'toolCall'] // 我只关心这两类通知 } }); console.log('Initialized with server:', initResult.serverName); // 获取可用工具列表 const tools = await client.sendRequest<Tool[]>('listTools', {}); console.log('Available tools:', tools.map(t => t.name)); // 订阅 progress 通知,用于流式显示 token client.onNotification('progress', (params) => { console.log('Token:', params.token); }); // 订阅 toolCall 通知,用于处理工具调用 client.onNotification('toolCall', (params) => { console.log('Tool call requested:', params.toolName); // 这里应该调用实际的工具函数,并将结果通过 callToolResult 发送回去 }); } catch (error) { console.error('Initialization failed:', error); process.exit(1); } // 等待监听结束(通常不会结束,除非服务端退出) await listeningPromise; } main();

这段代码展示了 MCP 的典型工作流。其中listTools是一个关键方法,它让客户端动态发现服务端的能力,而不是硬编码工具列表。这正是“即插即用”的核心——你的 VS Code 插件无需知道 Ollama 里装了什么模型,只需调用listTools,就能拿到get_weather,search_web,execute_code等工具定义,然后根据用户输入,动态选择并调用。

注意:listTools返回的Tool对象中,inputSchema字段是一个 JSON Schema 对象。这意味着你可以用zod库在运行时动态生成校验函数:

import { z } from 'zod'; function createToolValidator(schema: Record<string, unknown>): z.ZodTypeAny { // 将 JSON Schema 转换为 Zod Schema 的逻辑(此处省略具体转换代码) // 实际项目中可使用 zod-to-json-schema 等库 }

3.4 流式内容生成实战:generateContent与progress通知的协同

大模型最吸引人的能力,莫过于流式生成。MCP 将其标准化为generateContent方法和progress通知的组合。客户端调用generateContent发起请求,服务端则在生成过程中,持续发送notify/progress消息,每条消息携带一个token字段。

实现一个流式生成函数,关键在于状态管理。你不能简单地await一个generateContent请求,因为它的result是最终的完整响应,而中间的progress通知才是用户体验的核心。正确的做法是:发起请求,然后在progress通知处理器中累积 tokens,同时监听result响应作为结束信号。

async function streamGenerateContent( client: McpClient, prompt: string ): Promise<string> { let fullResponse = ''; let isDone = false; // 注册 progress 处理器 const progressHandler = (params: { token: string }) => { fullResponse += params.token; console.log('Streaming:', fullResponse); // 或更新 UI }; client.onNotification('progress', progressHandler); try { // 发起 generateContent 请求 const result = await client.sendRequest<{ content: string }>('generateContent', { prompt, // 其他参数,如 temperature, maxTokens 等 temperature: 0.7, maxTokens: 1024 }); // 收到最终 result,表示生成完成 isDone = true; return result.content; } finally { // 清理 handler,避免内存泄漏 client.notificationHandlers.delete('progress'); } }

这个函数看似简单,但隐藏着几个重要细节:

  1. finally块清理:onNotification注册的 handler 必须在使用后移除,否则每次调用都会叠加一个新的 handler,导致progress通知被重复处理。
  2. isDone标志位:虽然await会等待result返回,但progress通知可能在result之前、之中、之后到达。isDone用于在progress处理器中判断是否已收到最终结果,从而决定是否进行最终渲染。
  3. fullResponse的累积位置:它必须在progressHandler外部定义,确保所有progress通知都能访问到同一个字符串变量。

我在一个真实项目中,曾将fullResponse定义在progressHandler内部,导致每次通知都创建一个新字符串,最终 UI 只显示最后一个 token。这种 bug 极难复现,因为它的表现依赖于网络延迟和事件循环调度。所以,记住这条经验:所有跨通知共享的状态,必须定义在通知处理器的作用域之外。

4. 常见问题与排查技巧实录

4.1 “Connection refused” 错误:stdio 管道的隐形杀手

这是新手遇到的第一个拦路虎。当你运行node client.js,控制台却打印Error: connect ECONNREFUSED,第一反应往往是“服务端没起来”。但真相往往更隐蔽:Node.js 的spawn默认不会等待子进程完全初始化完毕,就立即返回child对象。此时child.stdout可能还是null,或者管道尚未建立,client.startListening()就已经开始尝试读取,自然失败。

解决方案有二:

  1. 添加启动探测:在spawn后,监听child.stdout的readable事件,或等待第一个data事件(通常是initialize响应):
child.stdout.once('readable', () => { console.log('MCP server is ready'); // 此时再 new McpClient 并 startListening });
  1. 使用wait-on库:在启动命令中加入等待逻辑:
pnpm exec wait-on tcp:127.0.0.1:8080 && node client.js

但请注意,这仅适用于 TCP 模式,对 stdio 无效。

更根本的解决之道,是在 MCP 服务端实现一个ping方法,并在客户端启动后,先发送一个ping请求,收到响应后再进行正式初始化。这相当于 USB-C 插入时的“握手脉冲”,是工业级可靠性的标配。

4.2 “Unexpected end of JSON input”:Line-Delimited JSON 的换行符战争

MCP 强制要求每条 JSON-RPC 消息独占一行(Line-Delimited JSON),即消息后必须跟一个\n。但不同操作系统对换行符的处理千差万别:Windows 用\r\n,Linux/macOS 用\n。如果你的服务端运行在 Windows,而客户端在 Linux,child.stdout读到的可能是{"jsonrpc":"2.0",...}\r\n,JSON.parse()会因\r报错。

排查方法:在JsonRpcStreamReader的底层,添加日志打印原始Uint8Array:

// 在 reader 的 for await 循环内 console.log('Raw bytes:', new TextDecoder().decode(chunk));

如果看到}后面跟着^M(即\r),问题就定位了。

修复方案有三:

  • 服务端统一输出\n:无论运行在何种平台,服务端代码中显式使用\n,禁用\r\n。
  • 客户端预处理:在JsonRpcStreamReader之前,加一层TransformStream,将\r\n替换为\n。
  • 使用split2库:它比原生readline更鲁棒,能自动处理各种换行符。

我推荐第一种,因为协议一致性必须从源头保证。在服务端的stdout.write()调用处,强制message + '\n',并在 CI 中加入跨平台测试。

4.3 “No handler found for response id”:ID 生成与匹配的时序陷阱

这个警告意味着客户端收到了一个response,但找不到对应的requesthandler。常见原因有两个:

  1. 请求超时被丢弃:sendRequest方法中,我们没有实现超时逻辑。如果服务端卡死,this.requestHandlers.set(id, ...)后,id会长期驻留在 Map 中,占用内存。更糟的是,如果服务端最终恢复并返回一个旧id的响应,它会被错误地分发给一个已失效的 handler。
  2. ID 重复:this.nextId++在高并发下可能产生重复 ID(虽然概率极低,但Number.MAX_SAFE_INTEGER是理论上限)。

解决方案是引入带超时的请求管理:

interface PendingRequest<T> { resolve: (value: T) => void; reject: (reason: any) => void; timeoutId: NodeJS.Timeout; } private readonly pendingRequests = new Map<number, PendingRequest<any>>(); async sendRequest<T>( method: string, params: Record<string, unknown> | undefined, timeoutMs = 30000 // 默认 30 秒超时 ): Promise<T> { const id = this.nextId++; return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`)); }, timeoutMs); this.pendingRequests.set(id, { resolve, reject, timeoutId }); this.writer.write({ jsonrpc: '2.0', id, method, params }); }); } // 在 startListening 的 response 处理分支中 const pending = this.pendingRequests.get(message.id); if (pending) { clearTimeout(pending.timeoutId); this.pendingRequests.delete(message.id); if ('error' in message) { pending.reject(new Error(message.error.message)); } else { pending.resolve(message.result as T); } }

这个增强版sendRequest不仅解决了超时问题,还通过clearTimeout避免了定时器内存泄漏。它让 MCP 客户端具备了生产环境所需的健壮性。

4.4 工具调用(Tool Calling)的闭环难题:如何把结果送回去?

toolCall通知只是“请求”,客户端必须执行对应工具,并将结果通过callToolResult方法发回服务端,形成闭环。但很多开发者卡在这里:他们成功收到了toolCall,也执行了本地函数,却不知道如何将结果发回去。

关键点在于:callToolResult的id必须与原始toolCall通知中的id严格一致。而toolCall通知本身并不包含id字段!这是因为通知是单向的,没有id。那么id从哪里来?

答案是:toolCall通知的params中,有一个callId字段。MCP 规范规定,当服务端需要客户端执行一个工具时,它会先发送一个notify/toolCall,其中params.callId是一个唯一标识符;客户端执行完工具后,调用callToolResult,并将callId作为id字段传入。

因此,正确的工具调用流程是:

client.onNotification('toolCall', (params) => { const { callId, toolName, arguments: args } = params; try { // 执行本地工具 const result = executeLocalTool(toolName, args); // 将结果发回服务端 client.sendRequest('callToolResult', { callId, result }); } catch (error) { client.sendRequest('callToolResult', { callId, error: error.message }); } });

这个callId就是 MCP 工具调用的“事务 ID”。它确保了服务端能将客户端的执行结果,准确关联到当初发出的那个toolCall请求上。忘记传递callId,或者传递了错误的callId,都会导致服务端无法继续推理流程,整个对话就此卡死。

5. 进阶思考:MCP 如何重塑你的 TypeScript 项目架构

5.1 从“胶水代码”到“协议网关”:MCP 客户端的分层演进

当你第一次实现McpClient,它很可能是一个巨石类(Monolith Class),所有逻辑揉在一起。但随着项目复杂度上升,你会自然地将其拆分为三层:

  • Transport Layer(传输层):负责stdin/stdout、TCP socket、WebSocket的底层 I/O,暴露统一的ReadableStream<Uint8Array>和Writable接口。
  • Protocol Layer(协议层):负责 JSON-RPC 消息的序列化/反序列化、id管理、超时控制、错误映射,输出McpRequest和McpResponse类型。
  • Application Layer(应用层):基于协议层,封装initialize、generateContent、callTool等高层语义方法,处理业务逻辑和状态。

这种分层不是教条,而是应对复杂性的必然。我曾维护一个支持 5 种不同 MCP 服务(Ollama、LM Studio、Custom Rust Server、Playwright MCP、UE5 MCP)的大型 IDE 插件。如果没有清晰的 Transport/Protocol 分离,每次新增一个服务,都要重写一遍spawn逻辑和JSON.parse代码,项目将迅速失控。

TypeScript 的模块系统为此提供了完美支持。transport/stdio.ts、transport/tcp.ts、protocol/jsonrpc.ts、application/client.ts—— 每个文件职责单一,类型定义清晰。当你需要为浏览器环境添加 WebSocket 支持时,只需编写transport/websocket.ts,并注入到McpClient构造函数中,上层业务代码一行都不用改。这就是协议抽象的力量。

5.2 类型声明文件(.d.ts)的实战价值:超越any的协作契约

热搜词中频繁出现的typescript 类型声明文件(.d.ts) 怎样编写,在 MCP 项目中有着最直接的应用场景。.d.ts文件不是给编译器看的,而是给团队协作和生态集成看的契约。

假设你开发了一个 MCP 服务,并希望其他 TypeScript 团队能轻松接入。除了提供 HTTP API 文档,你

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进