手撕 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) | 怎么表示一次"请求-响应" | 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。新建一个目录,初始化:
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 关联
客户端三个核心能力:
- 启动服务端子进程 ,把它的 stdout 接入
readline逐行解析; - 发送请求并等待响应 ,靠 JSON-RPC 的
id把响应和请求对应起来; - 发送通知 (无
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 是整个流程的起点。我们发一个带 protocolVersion、capabilities、clientInfo 的请求,等服务端回 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),只实现 initialize、tools/list、tools/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; - 若必须手动处理流,按
\nsplit 后对每段trim(),空行跳过; - 切记单条 JSON 内部不能有未转义的换行(JSON.stringify 默认不会产生)。
5.3 请求 id 关联与超时
现象 :A 的响应被当成 B 的结果,或请求发出后永远 pending。
根因:
- 没按
id关联响应,或复用了id; - 忘记给请求加超时,服务端挂了就死等。
对策: - 用自增
nextId,pendingMap 以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 工具及所有参数均为演示用途的虚构示例,与环境中的任何真实系统、项目或数据无关。