用 TypeScript SDK 构建并验证 Node.js MCP 客户端
一个能用的 Node.js MCP 客户端不长,这里的例子不到七十行。下面基于当前 TypeScript SDK 写一个小客户端,对接你自己掌控的 stdio 服务器,并依次处理调用成功、工具失败、工具不存在、超时、服务器中途退出和命令不存在。随后让同一个客户端分别连接旧式与现代服务器,说明为什么 SDK 的默认连接模式在 2026-07-28 服务器上会失败,以及何时应改用 auto。
官方客户端教程的 TypeScript 标签页(链接见“来源”)接下来会把一个 Anthropic 模型接进聊天循环,需要 API 密钥。示例停在这条边界上:下面所有内容都不需要密钥,第六节说明模型接在哪里。同一个 SDK 的服务器一侧,请读构建 MCP 服务器;想不写客户端就查看一个服务器,用 Inspector。
1. 需要什么,怎样搭起来
需要 Node.js 20 或更高版本(官方教程要求 20,客户端包自己声明 >=20)和 npm。五个包,版本固定到精确值,因为这里用的是 SDK 的 2.x 版本线:2025 年各修订版的官方教程安装单个包 @modelcontextprotocol/sdk 并从它的子路径导入,2026-07-28 的教程则安装 @modelcontextprotocol/client 并从它导入:
| 包 | 版本 | 用途 |
|---|---|---|
@modelcontextprotocol/client | 2.4.0 | 客户端:Client、各种传输、错误类 |
@modelcontextprotocol/server | 2.4.0 | 只用于第三节的测试服务器 |
zod | 4.6.7 | 声明测试服务器各工具的输入与输出 schema |
@types/node | 26.6.5 | Node.js 类型声明 |
typescript | 7.0.2 | 把 src/ 编译到 build/ |
在一个空目录里放这两个文件:
package.json
{
"name": "mcp-node-client",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc"
},
"dependencies": {
"@modelcontextprotocol/client": "2.4.0",
"@modelcontextprotocol/server": "2.4.0",
"zod": "4.6.7"
},
"devDependencies": {
"@types/node": "26.6.5",
"typescript": "7.0.2"
}
}tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}然后安装并编译:
npm install
npm run build2. 客户端
src/client.ts 包装了 SDK 的 Client 类。加上模型之后,这一部分你会保留下来。
src/client.ts
import { Client, ProtocolError, SdkError, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
export type Transport = StdioClientTransport | StreamableHTTPClientTransport;
/** How a tool call ended, in the four ways a caller has to tell apart. `sdk-error` is the SDK giving up: a timeout or a closed connection. */
export type ToolOutcome =
| { kind: "ok"; text: string; structured?: unknown }
| { kind: "tool-error"; text: string }
| { kind: "protocol-error"; code: number; message: string }
| { kind: "sdk-error"; code: string; message: string };
function textOf(content: ReadonlyArray<{ type: string; text?: string }>): string {
return content
.filter((block) => block.type === "text")
.map((block) => block.text ?? "")
.join("\n");
}
export class McpToolClient {
private readonly client: Client;
/**
* With `auto` the client probes with `server/discover` and falls back to `initialize`.
* Without it the SDK sends `initialize` only, which a server of revision 2026-07-28 or later may refuse.
*/
constructor(name: string, options: { auto?: boolean } = {}) {
this.client = new Client({ name, version: "1.0.0" }, options.auto ? { versionNegotiation: { mode: "auto" } } : {});
}
async connect(transport: Transport): Promise<void> {
await this.client.connect(transport);
}
get protocolVersion(): string | undefined {
return this.client.getNegotiatedProtocolVersion();
}
async toolNames(): Promise<string[]> {
const { tools } = await this.client.listTools();
return tools.map((tool) => tool.name);
}
async callTool(name: string, args: Record<string, unknown>, timeoutMs = 5000): Promise<ToolOutcome> {
try {
const result = await this.client.callTool({ name, arguments: args }, { timeout: timeoutMs });
const text = textOf(result.content);
if (result.isError) {
return { kind: "tool-error", text };
}
return result.structuredContent === undefined
? { kind: "ok", text }
: { kind: "ok", text, structured: result.structuredContent };
} catch (error) {
if (ProtocolError.isInstance(error)) {
return { kind: "protocol-error", code: error.code, message: error.message };
}
if (SdkError.isInstance(error)) {
return { kind: "sdk-error", code: error.code, message: error.message };
}
throw error;
}
}
async close(): Promise<void> {
await this.client.close();
}
}其中有四点要紧:
- 构造函数接收连接模式。
versionNegotiation: { mode: "auto" }让客户端先用server/discover探测,探测不成再退回initialize。SDK 自己的默认值是'legacy':只发initialize。第五节会展示这个默认值的代价。 connect执行所选时代的握手,getNegotiatedProtocolVersion()告诉你实际连上的是哪一个。- 不带游标调用
listTools()会遍历所有分页,返回完整列表,不用自己翻页。 callTool有四种结局。 规范把错误分成两类:协议错误,即 JSON-RPC 错误,比如工具不存在;工具执行错误,作为普通结果返回,带isError: true。SDK 再加一类没有 JSON-RPC 形态的:它自己放弃,原因是超时或连接已关闭。调用方对这四种要分别处理,所以客户端把它们作为四种结果返回,而不是抛出异常。
3. 一个对接用的服务器
客户端需要对手。src/server.ts 是用同一个 SDK 的服务器包写的 stdio 服务器。它有四个工具,每个只为产生一种结果:add 正常工作并返回结构化输出,fail 抛出异常,slow 延迟后才回答,crash 结束进程。
src/server.ts
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
// serveStdio calls this whenever it needs an instance, so every call must return a fresh server.
function createServer() {
const server = new McpServer({ name: "fixture", version: "1.0.0" });
server.registerTool(
"add",
{
description: "Add two numbers",
inputSchema: z.object({ a: z.number(), b: z.number() }),
outputSchema: z.object({ sum: z.number() }),
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
structuredContent: { sum: a + b },
}),
);
server.registerTool("fail", { description: "Always throws", inputSchema: z.object({}) }, async () => {
throw new Error("boom");
});
server.registerTool(
"slow",
{ description: "Answers after a delay", inputSchema: z.object({ ms: z.number() }) },
async ({ ms }) => {
await new Promise((resolve) => setTimeout(resolve, ms));
return { content: [{ type: "text", text: "done" }] };
},
);
server.registerTool("crash", { description: "Exits the server process", inputSchema: z.object({}) }, async () => {
process.exit(1);
});
return server;
}
serveStdio(createServer, { onerror: (error) => console.error("fixture server error:", error) });
console.error("fixture server running on stdio");关于服务器有两点。serveStdio 与每个客户端协商协议修订版,所以同一个服务器既回答 2026-07-28 的客户端,也回答仍以 2025 年代的 initialize 开场的客户端;手工用 server.connect(new StdioServerTransport()) 接起来的服务器,只服务 2025 年代的客户端。另外它用 console.error 打日志,因为 stdio 传输把标准输出留给协议消息:服务器“MUST NOT 向 stdout 写入任何不是有效 MCP 消息的内容”。
4. 运行并读懂结果
src/scenarios.ts 把客户端分别用两种连接模式连上服务器,然后发起七次调用和一次失败的连接。
src/scenarios.ts
import { fileURLToPath } from "node:url";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { McpToolClient, type ToolOutcome } from "./client.js";
const serverPath = fileURLToPath(new URL("./server.js", import.meta.url));
function stdio(command = process.execPath, args = [serverPath]) {
return new StdioClientTransport({ command, args, stderr: "ignore" });
}
function show(outcome: ToolOutcome): string {
switch (outcome.kind) {
case "ok":
return `ok text=${JSON.stringify(outcome.text)}` + (outcome.structured === undefined ? "" : ` structured=${JSON.stringify(outcome.structured)}`);
case "tool-error":
return `tool-error text=${JSON.stringify(outcome.text)}`;
case "protocol-error":
return `protocol-error code=${outcome.code} message=${JSON.stringify(outcome.message)}`;
case "sdk-error":
return `sdk-error code=${outcome.code}`;
}
}
async function main(): Promise<void> {
// 1. The same server, connected the SDK's default way and with version negotiation.
for (const auto of [false, true]) {
const probe = new McpToolClient("scenario-probe", { auto });
await probe.connect(stdio());
console.log(`connect ${auto ? "auto " : "default"}: protocol ${probe.protocolVersion}, tools ${(await probe.toolNames()).join(", ")}`);
await probe.close();
}
// 2. Tool calls over one negotiated connection.
const client = new McpToolClient("scenario-client", { auto: true });
await client.connect(stdio());
const calls: Array<[string, Record<string, unknown>, number?]> = [
["add", { a: 2, b: 3 }],
["add", { a: "2", b: 3 }],
["nosuch", {}],
["fail", {}],
["slow", { ms: 3000 }, 300],
["slow", { ms: 50 }, 2000],
["crash", {}],
];
for (const [name, args, timeoutMs] of calls) {
const label = `${name}(${JSON.stringify(args)})${timeoutMs === undefined ? "" : ` timeout=${timeoutMs}ms`}`;
console.log(`${label} -> ${show(await client.callTool(name, args, timeoutMs))}`);
}
await client.close();
// 3. A server command that does not exist.
const missing = new McpToolClient("scenario-missing", { auto: true });
try {
await missing.connect(stdio("mcp-server-that-does-not-exist", []));
console.log("missing command -> connected (unexpected)");
} catch (error) {
console.log(`missing command -> connect rejected: ${error instanceof Error ? error.message : String(error)}`);
}
}
main().catch((error: unknown) => {
console.error("scenario run failed:", error);
process.exit(1);
});用 node build/scenarios.js 运行。以下是那次运行的输出:
原始输出:node build/scenarios.js
connect default: protocol 2025-11-25, tools add, fail, slow, crash
connect auto : protocol 2026-07-28, tools add, fail, slow, crash
add({"a":2,"b":3}) -> ok text="5" structured={"sum":5}
add({"a":"2","b":3}) -> tool-error text="Input validation error: Invalid arguments for tool add: a: Invalid input: expected number, received string"
nosuch({}) -> protocol-error code=-32602 message="Tool nosuch not found"
fail({}) -> tool-error text="boom"
slow({"ms":3000}) timeout=300ms -> sdk-error code=REQUEST_TIMEOUT
slow({"ms":50}) timeout=2000ms -> ok text="done"
crash({}) -> sdk-error code=CONNECTION_CLOSED
missing command -> connect rejected: spawn mcp-server-that-does-not-exist ENOENT前两行是两次连接,第五节解释;下表覆盖其余八行:
| 调用 | 结果 | 说明 |
|---|---|---|
add({"a":2,"b":3}) | ok text="5" structured={"sum":5} | 正常结果:文本内容,加上工具输出 schema 所描述的结构化结果 |
add({"a":"2","b":3}) | tool-error text="Input validation error: Invalid arguments for tool add: a: Invalid input: expected number, received string" | 参数类型错误:服务器在带 isError: true 的结果里拒绝了输入,模型能读到消息并重试 |
nosuch({}) | protocol-error code=-32602 message="Tool nosuch not found" | 工具不存在:错误码为 -32602 的 JSON-RPC 错误,不是工具结果 |
fail({}) | tool-error text="boom" | 工具抛出了异常:服务器把异常变成携带消息的工具执行错误 |
slow({"ms":3000}) timeout=300ms | sdk-error code=REQUEST_TIMEOUT | 客户端在 300 毫秒后放弃;SDK 报告的是自己的错误码,不是 JSON-RPC 错误码 |
slow({"ms":50}) timeout=2000ms | ok text="done" | 同一连接上的下一次调用:仍然可用 |
crash({}) | sdk-error code=CONNECTION_CLOSED | 调用过程中服务器进程退出了,连接已经没有了 |
missing command | connect rejected: spawn mcp-server-that-does-not-exist ENOENT | 命令不存在:connect 自己就被拒绝,还没发出任何请求 |
从中可以带走三点:
- 同一个服务器,默认模式下连上的是 2025-11-25,
auto模式下连上的是 2026-07-28。服务器没变,变的是客户端的连接模式。 - 类型错误的参数作为工具执行错误返回,不存在的工具作为 JSON-RPC 错误返回,错误码
-32602。这是规范的划分,也决定了各自怎么处理:规范写明客户端“SHOULD 把工具执行错误提供给语言模型,使其能自我纠正”,而协议错误只是“MAY”提供。 - 超时只放弃一个请求,连接仍可用:之后的那次调用返回了
done。调用过程中服务器退出,这次调用以CONNECTION_CLOSED结束。
5. 连上的是哪个协议时代?
输出的前两行已经显示:默认模式连上 2025-11-25,auto 连上 2026-07-28。默认模式只发 initialize,这是 2026-07-28 之前每个客户端的做法。规范的兼容矩阵写明了这样的客户端遇到新时代服务器会怎样:旧式客户端对现代服务器,“失败”。要在线路上看到它,src/probe-client.ts 通过 Streamable HTTP,用每种连接模式连接客户端一页的探针服务器的每个时代;era_runs.py 再用架构一页的检查器检查探针看到的消息。它导入这两页里内嵌的两个脚本 client_probe_server.py 和 lifecycle_check.py,把两者保存在它旁边。
src/probe-client.ts
import { ProtocolError, SdkError, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { McpToolClient } from "./client.js";
// Usage: node build/probe-client.js default|auto PORT
const [mode, port] = process.argv.slice(2);
if ((mode !== "default" && mode !== "auto") || !/^\d+$/.test(port ?? "")) {
console.error("usage: probe-client.js default|auto PORT");
process.exit(2);
}
const client = new McpToolClient("probe-client", { auto: mode === "auto" });
try {
await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)));
const names = await client.toolNames();
console.log(`connected: protocol ${client.protocolVersion}, ${names.length} tools`);
await client.close();
} catch (error) {
const reason = ProtocolError.isInstance(error)
? `ProtocolError ${error.code}`
: SdkError.isInstance(error)
? error.code
: error instanceof Error
? error.name
: "unknown";
console.log(`connect failed: ${reason}`);
}era_runs.py
#!/usr/bin/env python3
"""Connect the TypeScript SDK client (probe-client.js) to the probe server of the clients page, in both eras and in the
SDK's two connect modes, and run lifecycle_check over what the probe saw.
Usage: era_runs.py BUILD_DIR BUILD_DIR = the directory that holds the compiled `build/probe-client.js`
Exit code 0 = each of the four runs ended as the table at the top of EXPECTED says; 2 = node or the build is missing.
Needs only the standard library, and two scripts from other pages saved next to this file: `client_probe_server.py`
(the probe server of the clients page) and `lifecycle_check.py` (the checker of the architecture page).
"""
import shutil
import subprocess
import sys
import threading
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import client_probe_server # noqa: E402
import lifecycle_check # noqa: E402
PORT = 8246
# (SDK mode, probe era) -> (the client says, whether lifecycle_check must pass)
EXPECTED = {
("default", "legacy"): ("connected: protocol 2025-11-25, 0 tools", True),
("default", "modern"): ("connect failed:", False),
("auto", "legacy"): ("connected: protocol 2025-11-25, 0 tools", True),
("auto", "modern"): ("connected: protocol 2026-07-28, 0 tools", True),
}
def run(build, mode, era):
records = []
server = client_probe_server.serve(era, PORT, records.append)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
done = subprocess.run([shutil.which("node"), str(build / "build" / "probe-client.js"), mode, str(PORT)],
capture_output=True, text=True, timeout=60, cwd=str(build))
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
return (done.stdout.strip() or done.stderr.strip()), records
def chain(records):
return " -> ".join(r["method"] for r in records)
def main():
if len(sys.argv) != 2 or not shutil.which("node") or not (Path(sys.argv[1]) / "build" / "probe-client.js").is_file():
print("usage: era_runs.py BUILD_DIR (node on PATH, `npm run build` done in BUILD_DIR)", file=sys.stderr)
return 2
build = Path(sys.argv[1])
bad = 0
for (mode, era), (says, must_pass) in EXPECTED.items():
output, records = run(build, mode, era)
print("run: sdk-mode=%s probe=%s" % (mode, era))
print(" client says: %s" % output)
print(" requests: %s" % chain(records))
ok = output.startswith(says)
if must_pass:
passed, warned, failed = lifecycle_check.summary(lifecycle_check.run_checks(records))
print(" lifecycle_check: %d pass, %d warn, %d fail" % (passed, warned, failed))
ok = ok and (warned, failed) == (0, 0)
else:
print(" lifecycle_check: not run (the connection failed)")
print(" check: %s" % ("as expected" if ok else "UNEXPECTED"))
bad += 0 if ok else 1
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())原始输出:era_runs.py
run: sdk-mode=default probe=legacy
client says: connected: protocol 2025-11-25, 0 tools
requests: initialize -> notifications/initialized -> tools/list
lifecycle_check: 7 pass, 0 warn, 0 fail
check: as expected
run: sdk-mode=default probe=modern
client says: connect failed: ProtocolError -32601
requests: initialize
lifecycle_check: not run (the connection failed)
check: as expected
run: sdk-mode=auto probe=legacy
client says: connected: protocol 2025-11-25, 0 tools
requests: server/discover -> initialize -> notifications/initialized -> tools/list
lifecycle_check: 7 pass, 0 warn, 0 fail
check: as expected
run: sdk-mode=auto probe=modern
client says: connected: protocol 2026-07-28, 0 tools
requests: server/discover -> tools/list
lifecycle_check: 7 pass, 0 warn, 0 fail
check: as expected| 连接模式 | 服务器时代 | 客户端报告 | 服务器看到的请求 | 生命周期检查 |
|---|---|---|---|---|
default | legacy | connected: protocol 2025-11-25 | initialize → notifications/initialized → tools/list | 7 pass, 0 warn, 0 fail |
default | modern | connect failed: ProtocolError -32601 | initialize | 未运行:连接失败 |
auto | legacy | connected: protocol 2025-11-25 | server/discover → initialize → notifications/initialized → tools/list | 7 pass, 0 warn, 0 fail |
auto | modern | connected: protocol 2026-07-28 | server/discover → tools/list | 7 pass, 0 warn, 0 fail |
按这个顺序读:
- 默认模式连旧式探针,连上了,它的消息通过全部七项生命周期检查。
- 默认模式连现代探针,客户端发出一个
initialize,连接以探针返回的-32601失败。服务器没有问题,是客户端说错了时代。 auto模式两次都连在了正确的时代。对旧式探针,消息以server/discover开头,探针拒绝它,接着是initialize;对现代探针,server/discover之后直接是列表请求。三次连上的记录全部通过检查。
除非明知服务器只讲较早的修订版,否则用 auto。有一项代价要知道,这几次 HTTP 运行里看不到:在 stdio 上,SDK 文档写明探测在一个短命的兄弟进程上进行,该进程“用同样的参数启动”,即“每次连接多启动一次”。auto 模式下,你的服务器命令在连接时会被启动两次,启动开销大的服务器要付两次。走 HTTP 时探测用的是连接本身。
6. 把模型接进循环
客户端停在模型开始的地方。面向模型的循环是三步,第二节的客户端每一步都覆盖:
| 步骤 | 客户端调用 | 对结果做什么 |
|---|---|---|
| 向模型描述工具 | listTools() 给出每个工具的 name、description、inputSchema | 按模型自己 API 的格式交给模型;保留工具名 |
| 执行模型选的调用 | callTool(name, args) | 按下面的四种结果分别处理 |
| 把答案交回去 | ok 和 tool-error 带着给模型看的文本 | 作为工具结果交回;tool-error 要标成错误,模型才能修正参数 |
四种结果里,ok 和 tool-error 回到模型那里。protocol-error 是请求或服务器的缺陷,规范只说客户端 MAY 把它交给模型;记录下来,结束这一支。sdk-error 由宿主决定:重试、重连或放弃。官方教程的 TypeScript 标签页给出了配 Anthropic 模型的完整循环,链接见“来源”。模型能不能选对工具、填对参数,取决于工具定义,这由工具和编写有效的工具两页负责。
7. 失败模式
| 你看到的 | 原因 | 怎么办 |
|---|---|---|
一次 initialize 之后出现 connect failed: ProtocolError -32601 | 默认模式把 initialize 发给了不回应它的服务器 | 用 versionNegotiation: { mode: "auto" }(第五节) |
connect 抛出 spawn … ENOENT | 服务器命令不存在或不在路径上 | 检查命令和工作目录;上面的运行显示 connect 以这条消息被拒绝 |
| 服务器的日志行出现在你的终端里 | SDK 对子进程 stderr 的默认值是 "inherit" | 把 stderr 设为 "ignore"(src/scenarios.ts 就是这样)或 "pipe";规范允许客户端捕获、转发或忽略 stderr,并说不要把它当作错误 |
stdio 服务器的标准输出里混进了多余文本,例如来自 console.log | 规范把标准输出留给 MCP 消息:服务器 MUST NOT 往里写别的内容 | 日志写到 stderr,如 src/server.ts |
| 工具结果看着正常,其实是一条错误消息 | 忽略了 isError: true | 使用文本之前先判断 isError;参数校验失败和抛出的异常都是这样到达的 |
慢工具出现 REQUEST_TIMEOUT | 工具耗时超过这次调用的 timeout | 为这次调用调大超时;连接仍可用,运行里的下一次调用就是证明 |
调用途中出现 CONNECTION_CLOSED | 服务器进程退出了 | 当作连接丢失:报告,然后重连或停止 |
| 照搬 2025 年修订版教程的代码编译不过 | 那些教程用的是 @modelcontextprotocol/sdk 及其子路径导入 | 用第一节固定的包,以及 src/client.ts 里的导入 |
| 连接时服务器启动了两次 | 在 stdio 上,auto 模式在第二个进程上探测(SDK 文档) | 让启动开销变小,或者只对确知为旧式的服务器保留默认模式 |
版本与验证说明
全部内容于 2026-10-26 在 macOS 上运行,Node.js 24.18.0、npm 11.16.0、TypeScript 7.0.2,@modelcontextprotocol/client 与 @modelcontextprotocol/server 均为 2.4.0;文中的记录输出就是那次运行,未经修改。关于 SDK 的说法,对照已安装包的文本核对;关于协议的说法,对照官方仓库的同一个固定提交(dae694f6)。
来源
- 客户端教程的 TypeScript 标签页:
docs/docs/2026-07-28/develop/build-client.mdx;服务器教程:docs/docs/2026-07-28/develop/build-server.mdx。站内对应:构建 MCP 客户端和构建 MCP 服务器。 - 工具错误:
docs/specification/2026-07-28/server/tools.mdx;stdio 规则与server/discover探测:basic/transports/stdio.mdx;兼容矩阵:basic/versioning.mdx。 - SDK:npm 上
@modelcontextprotocol/client2.4.0 和@modelcontextprotocol/server2.4.0 的类型声明与package.json。 - 规范与教程取自 modelcontextprotocol/modelcontextprotocol 的提交
dae694f61a31f7a3ec2af3aff709e11f70f61e48。