欢迎光临
我们一直在努力

手撕 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?
  • 二、核心原理:JSON-RPC 2.0 + stdio 传输
    • 2.1 协议分层:传输层 / 消息层 / 方法层
    • 2.2 消息帧格式:换行分隔的 JSON
    • 2.3 两个时代:initialize 握手 vs 无状态 server/discover
  • 三、动手实现一个最小 MCP 客户端
    • 3.1 环境准备
    • 3.2 客户端核心:连接、收发、id 关联
    • 3.3 握手与工具调用
    • 3.4 配套:一个可跑的演示服务端
  • 四、跑起来看结果
  • 五、高频踩坑与优化
    • 5.1 stdout 被日志污染
    • 5.2 换行帧与黏包
    • 5.3 请求 id 关联与超时
    • 5.4 新旧协议混用
    • 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) 怎么表示一次"请求-响应" jsonrpc、id、method、params、result、error
方法层(MCP Methods) 业务语义 initialize、tools/list、tools/call 等

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

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

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

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

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

  • 用 readline 按行读取是最省心的方案(Node 自带);
  • 服务端哪怕 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 里直接带 protocolVersion、clientCapabilities、clientInfo,可选地先发 server/discover 探测支持的版本。每个请求都是自包含的。

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

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

    3.1 环境准备

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

    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 关联

    客户端三个核心能力:

  • 启动服务端子进程,把它的 stdout 接入 readline 逐行解析;
  • 发送请求并等待响应,靠 JSON-RPC 的 id 把响应和请求对应起来;
  • 发送通知(无 id、无响应)。
  • 完整实现如下(文件 client.ts):

    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 是整个流程的起点。我们发一个带 protocolVersion、capabilities、clientInfo 的请求,等服务端回 result 后,再补一条 notifications/initialized 通知(注意:通知没有 id,服务端不会回复)。之后才能合法地调用 tools/list / tools/call。

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

    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),只实现 initialize、tools/list、tools/call 三个方法,并暴露一个 calculator 工具。它严格只往 stdout 写合法 JSON。

    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' } });
    }
    });

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

    npx tsc
    node main.js

    四、跑起来看结果

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

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

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

    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;
    • 忘记给请求加超时,服务端挂了就死等。 对策:
    • 用自增 nextId,pending Map 以 id 为键;
    • 收到响应立即 delete 并 clearTimeout;
    • 通知(无 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-server、calculator 工具及所有参数均为演示用途的虚构示例,与环境中的任何真实系统、项目或数据无关。

    赞(0)
    未经允许不得转载:171主机测评 » 手撕 MCP:用 TypeScript 从零写一个能跑的最小客户端(附可运行 demo)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址