手撕 MCP:用 TypeScript 从零写一个能跑的最小客户端(附可运行 demo)

手撕 MCP:用 TypeScript 从零写一个能跑的最小客户端(附可运行 demo)

📖 摘要:本文从工程视角拆解 Model Context Protocol(MCP):不堆概念,直接用 TypeScript 从零写一个能跑的最小 stdio 客户端,覆盖 JSON-RPC 2.0 消息帧、initialize 握手、tools/list 与 tools/call 全流程,并附带一个可独立运行的演示服务端。文中还会澄清 2026-07-28 新规范的"无状态 server/discover"与旧版 initialize 握手的区别,并复盘 stdout 污染、换行黏包、请求 id 关联等高频踩坑。读完你将真正理解 MCP 客户端在底层到底干了什么。

🏷️ 关键词:MCP,Model Context Protocol,TypeScript,JSON-RPC,Agent

目录

  • [一、为什么又写 MCP?](#一、为什么又写 MCP?)
  • [二、核心原理:JSON-RPC 2.0 + stdio 传输](#二、核心原理:JSON-RPC 2.0 + stdio 传输)
    • [2.1 协议分层:传输层 / 消息层 / 方法层](#2.1 协议分层:传输层 / 消息层 / 方法层)
    • [2.2 消息帧格式:换行分隔的 JSON](#2.2 消息帧格式:换行分隔的 JSON)
    • [2.3 两个时代:initialize 握手 vs 无状态 server/discover](#2.3 两个时代:initialize 握手 vs 无状态 server/discover)
  • [三、动手实现一个最小 MCP 客户端](#三、动手实现一个最小 MCP 客户端)
    • [3.1 环境准备](#3.1 环境准备)
    • [3.2 客户端核心:连接、收发、id 关联](#3.2 客户端核心:连接、收发、id 关联)
    • [3.3 握手与工具调用](#3.3 握手与工具调用)
    • [3.4 配套:一个可跑的演示服务端](#3.4 配套:一个可跑的演示服务端)
  • 四、跑起来看结果
  • 五、高频踩坑与优化
    • [5.1 stdout 被日志污染](#5.1 stdout 被日志污染)
    • [5.2 换行帧与黏包](#5.2 换行帧与黏包)
    • [5.3 请求 id 关联与超时](#5.3 请求 id 关联与超时)
    • [5.4 新旧协议混用](#5.4 新旧协议混用)
    • [5.5 进程生命周期与优雅关闭](#5.5 进程生命周期与优雅关闭)
  • 六、总结与延伸

一、为什么又写 MCP?

2026 年打开任何一个技术热榜,AI Agent 工程化都是绕不开的主线:Agent 记忆、上下文数据库、多 Agent 编排、可观测与成本控制反复屠榜。而在这些热闹之下,有一个底层协议被反复提及、却很少被人真正"拆开看"------MCP(Model Context Protocol)

很多同学对 MCP 的认知停留在"哦,就是那个让大模型调工具的标准"。但当你真的要:

  • 在一个没有官方 SDK 的运行时里接 MCP;
  • 排查"为什么我的客户端连上服务端却一直卡住";
  • 或者只是想搞清楚 initialize 到底在干什么;

你才发现:光看概念图没用,得亲手写一遍才知道水有多深。

本文的目标很纯粹------不依赖任何 MCP SDK,用 Node.js + TypeScript 从零实现一个最小但能跑通的 stdio MCP 客户端,并配一个 demo 服务端,让你看到协议最朴素的样子。

💡 阅读前提:了解 Node.js 子进程、基本的异步编程即可,不需要提前懂 MCP。

二、核心原理:JSON-RPC 2.0 + stdio 传输

2.1 协议分层:传输层 / 消息层 / 方法层

MCP 本质是"套在 JSON-RPC 2.0 之上的一套方法约定"。把它拆成三层就好理解了:

层级 职责 你写客户端要操心的事
传输层(Transport) 怎么把字节送过去 stdio(子进程管道)/ Streamable HTTP
消息层(JSON-RPC 2.0) 怎么表示一次"请求-响应" jsonrpcidmethodparamsresulterror
方法层(MCP Methods) 业务语义 initializetools/listtools/call

客户端真正要实现的,就是把"我想调某个工具"翻译成一条 JSON-RPC 请求,通过传输层发给服务端,再把回来的响应解析成结果。协议本身不神秘,难的都是工程细节(见第五章)。

2.2 消息帧格式:换行分隔的 JSON

以最常见的 stdio 传输为例,规范对消息帧的规定非常"硬核":

  • 客户端启动服务端为子进程,双方通过子进程的 stdin / stdout 通信;
  • 每条消息是一行完整的 JSON,以换行符 \n 分隔
  • 单条消息里不能包含内嵌换行
  • 服务端只能往 stdout 写合法的 MCP 消息,任何日志都必须走 stderr;
  • 没有"请求头",没有"连接握手包",就是纯纯的一行一行 JSON。

这对实现有两个直接影响:

  1. readline 按行读取是最省心的方案(Node 自带);
  2. 服务端哪怕 console.log 一个调试信息到 stdout,都会直接破坏协议通道------这是新手最高频的坑。

2.3 两个时代:initialize 握手 vs 无状态 server/discover

这是 2026 年写 MCP 客户端必须知道的背景。协议在 2026-07-28 做了一次大修订:

  • 旧版(2024-11-05 / 2025-06-18 等) :客户端必须先发 initialize 请求,拿到服务端能力声明后,再发一条 notifications/initialized 通知,之后才能调用业务方法。这是一个有状态的握手流程。
  • 新版(2026-07-28) :协议走向无状态 。现代服务端不需要 initialize 握手,客户端可以在 _meta 里直接带 protocolVersionclientCapabilitiesclientInfo,可选地先发 server/discover 探测支持的版本。每个请求都是自包含的。

⚠️ 实战提醒:截至 2026 年中,社区里大量存量 MCP 服务端仍是旧版 handshake 实现 。本文的客户端为了最大化兼容性,采用旧版 initialize 流程来演示(这也是最清晰、最适合讲原理的一条路径);文末会说明如何适配新版。

三、动手实现一个最小 MCP 客户端

3.1 环境准备

只需要 Node.js(18+ 即可,建议 20+)和 TypeScript。新建一个目录,初始化:

bash 复制代码
mkdir minimal-mcp-client && cd minimal-mcp-client
npm init -y
npm install -D typescript @types/node
npx tsc --init --module nodenext --target es2022 --moduleResolution nodenext

不需要安装任何 MCP 相关的包------这就是"手撕"的意义。

3.2 客户端核心:连接、收发、id 关联

客户端三个核心能力:

  1. 启动服务端子进程 ,把它的 stdout 接入 readline 逐行解析;
  2. 发送请求并等待响应 ,靠 JSON-RPC 的 id 把响应和请求对应起来;
  3. 发送通知 (无 id、无响应)。

完整实现如下(文件 client.ts):

typescript 复制代码
import { spawn, type ChildProcess } from 'node:child_process';
import { createInterface, type Interface } from 'node:readline';
import { EventEmitter } from 'node:events';

interface JsonRpcMessage {
  jsonrpc: '2.0';
  id?: number;
  method?: string;
  params?: unknown;
  result?: unknown;
  error?: { code: number; message: string; data?: unknown };
}

export class MinimalMcpClient {
  private proc: ChildProcess;
  private rl: Interface;
  private nextId = 1;
  private pending = new Map<number, (msg: JsonRpcMessage) => void>();
  private bus = new EventEmitter();

  constructor(command: string, args: string[] = []) {
    // stdio: stdin/stdout 走管道,stderr 继承父进程,避免污染协议通道
    this.proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
    this.rl = createInterface({ input: this.proc.stdout! });
    this.rl.on('line', (line) => this.onLine(line));
  }

  private onLine(line: string): void {
    const text = line.trim();
    if (!text) return;
    let msg: JsonRpcMessage;
    try {
      msg = JSON.parse(text);
    } catch {
      return; // 协议通道上不应出现非 JSON,忽略并记入日志(演示从简)
    }
    if (msg.id !== undefined && this.pending.has(msg.id)) {
      const resolve = this.pending.get(msg.id)!;
      this.pending.delete(msg.id);
      resolve(msg);
    } else if (msg.id === undefined && msg.method) {
      this.bus.emit('notification', msg); // 服务端主动通知
    }
  }

  /** 发送请求并等待对应 id 的响应 */
  request<T = unknown>(method: string, params?: unknown, timeoutMs = 10_000): Promise<T> {
    const id = this.nextId++;
    const payload: JsonRpcMessage = { jsonrpc: '2.0', id, method, params };
    return new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`请求超时: ${method} (id=${id})`));
      }, timeoutMs);
      this.pending.set(id, (msg) => {
        clearTimeout(timer);
        if (msg.error) reject(new Error(`[${msg.error.code}] ${msg.error.message}`));
        else resolve(msg.result as T);
      });
      this.proc.stdin!.write(JSON.stringify(payload) + '\n');
    });
  }

  /** 发送通知(无响应,无 id) */
  notify(method: string, params?: unknown): void {
    const payload: JsonRpcMessage = { jsonrpc: '2.0', method, params };
    this.proc.stdin!.write(JSON.stringify(payload) + '\n');
  }

  /** 完成初始化握手,兼容旧版 initialize 流程 */
  async initialize(clientName = 'minimal-mcp-client', clientVersion = '1.0.0') {
    const result = await this.request('initialize', {
      protocolVersion: '2024-11-05',
      capabilities: {},
      clientInfo: { name: clientName, version: clientVersion },
    });
    this.notify('notifications/initialized');
    return result;
  }

  listTools() {
    return this.request<{ tools: Array<{ name: string; description?: string }> }>(
      'tools/list',
      {},
    );
  }

  callTool(name: string, args: Record<string, unknown>) {
    return this.request('tools/call', { name, arguments: args });
  }

  onNotification(handler: (msg: JsonRpcMessage) => void): void {
    this.bus.on('notification', handler);
  }

  close(): void {
    this.proc.stdin?.end();
    this.proc.kill();
  }
}

3.3 握手与工具调用

initialize 是整个流程的起点。我们发一个带 protocolVersioncapabilitiesclientInfo 的请求,等服务端回 result 后,再补一条 notifications/initialized 通知 (注意:通知没有 id,服务端不会回复)。之后才能合法地调用 tools/list / tools/call

调用入口(文件 main.ts):

typescript 复制代码
import { MinimalMcpClient } from './client';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const serverEntry = join(__dirname, 'demo-server.js'); // 演示服务端编译产物

async function main() {
  // 以 node 启动同目录下的演示服务端(stdio 模式)
  const client = new MinimalMcpClient('node', [serverEntry]);

  await client.initialize();
  const { tools } = await client.listTools();
  console.log('✅ 服务端暴露的工具:', tools.map((t) => t.name));

  const result = await client.callTool('calculator', { a: 6, b: 7, op: 'add' });
  console.log('🧮 调用结果:', JSON.stringify(result, null, 2));

  client.close();
}

main().catch((err) => {
  console.error('运行失败:', err);
  process.exit(1);
});

3.4 配套:一个可跑的演示服务端

为了让你能端到端跑通 ,这里给一个最小 stdio 服务端(文件 demo-server.ts),只实现 initializetools/listtools/call 三个方法,并暴露一个 calculator 工具。它严格只往 stdout 写合法 JSON。

typescript 复制代码
import { createInterface } from 'node:readline';

const rl = createInterface({ input: process.stdin });

function send(obj: unknown): void {
  process.stdout.write(JSON.stringify(obj) + '\n');
}

rl.on('line', (line: string) => {
  let msg: any;
  try {
    msg = JSON.parse(line);
  } catch {
    return; // 跳过非法行,真实场景应记录日志
  }

  // 通知(无 id)无需回复
  if (msg.id === undefined) return;

  switch (msg.method) {
    case 'initialize':
      send({
        jsonrpc: '2.0',
        id: msg.id,
        result: {
          protocolVersion: '2024-11-05',
          capabilities: { tools: {} },
          serverInfo: { name: 'demo-calc-server', version: '1.0.0' },
        },
      });
      break;

    case 'tools/list':
      send({
        jsonrpc: '2.0',
        id: msg.id,
        result: {
          tools: [
            {
              name: 'calculator',
              description: '对两个数做加减乘除',
              inputSchema: {
                type: 'object',
                properties: {
                  a: { type: 'number' },
                  b: { type: 'number' },
                  op: { type: 'string', enum: ['add', 'sub', 'mul', 'div'] },
                },
                required: ['a', 'b', 'op'],
              },
            },
          ],
        },
      });
      break;

    case 'tools/call': {
      const { name, arguments: args } = msg.params as {
        name: string;
        arguments: { a: number; b: number; op: string };
      };
      if (name !== 'calculator') {
        send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'unknown tool' } });
        break;
      }
      const map: Record<string, number> = {
        add: args.a + args.b,
        sub: args.a - args.b,
        mul: args.a * args.b,
        div: args.a / args.b,
      };
      send({
        jsonrpc: '2.0',
        id: msg.id,
        result: { content: [{ type: 'text', text: String(map[args.op]) }] },
      });
      break;
    }

    default:
      send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } });
  }
});

把上面三个文件编译运行即可:

bash 复制代码
npx tsc
node main.js

四、跑起来看结果

正确运行后,你会看到类似输出:

text 复制代码
✅ 服务端暴露的工具: [ 'calculator' ]
🧮 调用结果: {
  "content": [
    {
      "type": "text",
      "text": "13"
    }
  ]
}

背后发生的协议交互(每一行就是一次 JSON-RPC 消息)其实只有四步:

text 复制代码
client  → server: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
client  ← server: {"jsonrpc":"2.0","id":1,"result":{...}}
client  → server: {"jsonrpc":"2.0","method":"notifications/initialized"}
client  → server: {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
client  ← server: {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
client  → server: {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{...}}
client  ← server: {"jsonrpc":"2.0","id":3,"result":{"content":[...]}}

看到这里你应该明白了一件事:MCP 客户端并没有什么魔法,它就是个"会发 JSON-RPC、会按 id 配对响应、会先握手再调用"的进程间通信客户端。

五、高频踩坑与优化

5.1 stdout 被日志污染

现象 :客户端卡住或解析报错,怎么都连不上。

根因 :服务端把 console.log、框架日志写到了 stdout,这些非协议文本混进 JSON 流,客户端 JSON.parse 失败或直接错位。

对策

  • 服务端日志一律走 stderr(console.error 或写文件),stdout 只留给协议;
  • 客户端侧 spawn 时把 stderr 设为 'inherit',把它和协议通道(stdout)彻底分开。

5.2 换行帧与黏包

现象 :偶尔一条消息解析出两段、或两条被拼成一个。

根因 :没严格遵守"一行一条 JSON + 换行分隔",或手动 buffer 时切分逻辑有 bug。

对策

  • 直接用 readline.createInterface 按行读取,省去自己维护 buffer;
  • 若必须手动处理流,按 \n split 后对每段 trim(),空行跳过;
  • 切记单条 JSON 内部不能有未转义的换行(JSON.stringify 默认不会产生)。

5.3 请求 id 关联与超时

现象 :A 的响应被当成 B 的结果,或请求发出后永远 pending。

根因

  • 没按 id 关联响应,或复用了 id
  • 忘记给请求加超时,服务端挂了就死等。
    对策
  • 用自增 nextIdpending Map 以 id 为键;
  • 收到响应立即 deleteclearTimeout
  • 通知(无 id)绝不能走"等待响应"的分支(见 onLine 里的 else if)。

5.4 新旧协议混用

现象 :连某些服务端时握手失败,报 UnsupportedProtocolVersion 之类错误。

根因 :2026-07-28 起新版服务端是无状态的,不再要求 initialize;旧服务端反过来不认 server/discover

对策

  • 存量兼容优先用 initialize 握手(本文方案);
  • 若要支持新版,先发 server/discover 探测,按返回的 supportedVersions 选版本,把 protocolVersion/clientCapabilities/clientInfo 放进每个请求体的 _meta 字段;
  • 不要写死单版本,维护一个"支持的版本列表"做协商。

5.5 进程生命周期与优雅关闭

现象 :程序退出后,服务端子进程变成孤儿进程占着资源。

根因 :直接 kill 或父进程退出但没关 stdin。

对策

  • 客户端关闭时先 stdin.end(),让服务端读到 EOF 自行退出(这是规范推荐的"唯一可移植的优雅关闭信号");
  • 仍不退再 proc.kill()
  • Windows 上没有 SIGTERM/SIGKILL 这套,需调用 TerminateProcess,但 child.kill() 已封装好。

六、总结与延伸

回过头看,MCP 客户端的核心就三件事:启动子进程用管道通信、按 JSON-RPC 2.0 的 id 配对请求响应、先握手再调用业务方法。把它从"黑盒 SDK"还原成几十行 TypeScript,很多"连不上""卡住""解析错"的问题就都有了清晰的排查方向。

延伸方向(建议你动手试试):

  • 接真实服务端 :把 main.ts 里的启动命令换成任意 stdio 模式的社区 MCP server,验证你的客户端是"协议级通用"的;
  • 支持 Streamable HTTP 传输:新版规范主推 HTTP POST + SSE,思路一致,只是传输层从管道换成 HTTP;
  • 加上能力协商与超时重试:生产级客户端要处理服务端意外退出后重启(无状态协议下 in-flight 请求可直接重试);
  • 多工具批量调用与并发callTool 本身是无状态的,可并行发多个请求靠 id 区分。

💡 一句话收尾:当你能不依赖 SDK 把一个协议跑通时,你才真正"拥有"了它,而不是被它的封装困住。

如果本文对你有帮助,欢迎点赞、收藏,评论区聊聊你踩过的 MCP 坑。


示例数据声明:文中 demo-calc-servercalculator 工具及所有参数均为演示用途的虚构示例,与环境中的任何真实系统、项目或数据无关。

相关推荐
秋田君1 小时前
Qt_串口编程
开发语言·qt
worilb1 小时前
Java/JVM 常见诊断文件对比
java·开发语言
deepseek231 小时前
Anthropic开源Commerce Agents:购物与商户智能体如何把审批写进工具链
人工智能·ai agent·mcp
ttwuai1 小时前
Go 后台初始化 MySQL 脚本失败怎么办?先查 DDL 还是生成配置
开发语言·mysql·golang
2601_962885722 小时前
如何用 Python 做 A 股全市场扫描选股?(多条件筛选实战)
开发语言·python
奈斯先生Vector2 小时前
AIGC 视频生成换个拍法:用 Kling Video 把一张人物图变成可剪辑的短故事
开发语言·人工智能·windows·python·aigc·音视频
一木 之林2 小时前
五、C++ 新特性、关键字与编译原理(进阶)(一)
c语言·开发语言·c++
秋名RG2 小时前
Java IO 体系深度剖析:从流式编程到 JDK 21 高并发陷阱
java·开发语言
Pocker_Spades_A2 小时前
Python快速入门专业版(五十九):re实战——用正则爬取豆瓣电影Top250(全流程解析)
开发语言·python