用 TypeScript SDK 构建并验证 Node.js MCP 客户端

用 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/client2.4.0客户端:Client、各种传输、错误类
@modelcontextprotocol/server2.4.0只用于第三节的测试服务器
zod4.6.7声明测试服务器各工具的输入与输出 schema
@types/node26.6.5Node.js 类型声明
typescript7.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 build

2. 客户端

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=300mssdk-error code=REQUEST_TIMEOUT客户端在 300 毫秒后放弃;SDK 报告的是自己的错误码,不是 JSON-RPC 错误码
slow({"ms":50}) timeout=2000msok text="done"同一连接上的下一次调用:仍然可用
crash({})sdk-error code=CONNECTION_CLOSED调用过程中服务器进程退出了,连接已经没有了
missing commandconnect rejected: spawn mcp-server-that-does-not-exist ENOENT命令不存在:connect 自己就被拒绝,还没发出任何请求

从中可以带走三点:

  1. 同一个服务器,默认模式下连上的是 2025-11-25,auto 模式下连上的是 2026-07-28。服务器没变,变的是客户端的连接模式。
  2. 类型错误的参数作为工具执行错误返回,不存在的工具作为 JSON-RPC 错误返回,错误码 -32602。这是规范的划分,也决定了各自怎么处理:规范写明客户端“SHOULD 把工具执行错误提供给语言模型,使其能自我纠正”,而协议错误只是“MAY”提供。
  3. 超时只放弃一个请求,连接仍可用:之后的那次调用返回了 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
连接模式服务器时代客户端报告服务器看到的请求生命周期检查
defaultlegacyconnected: protocol 2025-11-25initialize → notifications/initialized → tools/list7 pass, 0 warn, 0 fail
defaultmodernconnect failed: ProtocolError -32601initialize未运行:连接失败
autolegacyconnected: protocol 2025-11-25server/discover → initialize → notifications/initialized → tools/list7 pass, 0 warn, 0 fail
automodernconnected: protocol 2026-07-28server/discover → tools/list7 pass, 0 warn, 0 fail

按这个顺序读:

  1. 默认模式连旧式探针,连上了,它的消息通过全部七项生命周期检查。
  2. 默认模式连现代探针,客户端发出一个 initialize,连接以探针返回的 -32601 失败。服务器没有问题,是客户端说错了时代。
  3. 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/client 2.4.0 和 @modelcontextprotocol/server 2.4.0 的类型声明与 package.json。
  • 规范与教程取自 modelcontextprotocol/modelcontextprotocol 的提交 dae694f61a31f7a3ec2af3aff709e11f70f61e48。