上一篇写完那个 stdio Server,我把它挂进 Claude Desktop,看着输入框旁边的工具图标亮起来,还挺得意。第二天想把这个能力搬进自己的产品,才发现代码得重写一遍。
因为 Claude Desktop 是别人的 Host。
它的 Client 已经在后台替我做完了所有事:拉起子进程、握手、拉一份工具清单、把 JSON Schema 拼进请求、拿到模型返回的 tool_calls、发起 tools/call、再把结果塞回对话。这些一步都不用我写,也就一步都拿不到。
MCP 把工具做成了标准件,但决定"用不用、怎么用"的那一层,在你的 Host 里。
这篇文章就写那一层:一个能连上任意 MCP Server、自己发现工具、自己调、出错还能自己纠正的 Client。全文代码可跑,输出日志都是本地跑出来的,不是编的。读完你会拿到:不到 120 行的 Client 和 Agent Loop、一条完整的三轮调用链、四个坑,以及一个判断------什么时候根本不该自己写 Client。
版本说明:Node 20+、@modelcontextprotocol/sdk 1.30、zod 4.x、openai 7.x(用于兼容 DeepSeek / 硅基流动等 OpenAI 协议网关),传输层用 Streamable HTTP。协议和 SDK 迭代都快,以官方文档为准。
先划清边界:Client 只管四件事
上一篇画过 Host / Client / Server 三个角色。这一篇真正动手的是中间那个,它的职责可以拆成四件,一件比一件容易出事:
| 阶段 | 做什么 | 出错的后果 |
|---|---|---|
| 连接 | 建立传输通道,交换 initialize,协商协议版本和能力 |
后面所有请求都发不出去 |
| 发现 | tools/list 拿到工具清单和参数 schema |
模型不知道有哪些工具可用 |
| 翻译 | 把 MCP 的 JSON Schema 转成模型厂商要的 tools 格式 |
模型乱传参,或者干脆不调用 |
| 循环 | 拿到 tool_calls → 执行 → 回灌结果 → 再问模型 |
死循环、上下文错乱、错误吞掉 |
有一句话值得先记住:Server 只声明能力,不做决策。 它不知道模型长什么样,也不知道用户问了什么。所有"该不该调用、调哪个、参数怎么填"的判断,都发生在 Client 这一侧的对话循环里。
图片:一次完整会话里的消息流向图------tools/list 只发一次,tools/call 每轮都可能发,模型与 Client 之间靠 messages 数组往返,建议尺寸 900×383
所以配一个 MCP Server 很简单,写一个不抽风的 Client 才是有意思的部分。
第一步:把上一篇的 Server 搬到 HTTP 上
上一篇用的是 stdio。它的问题不是不好用,而是被绑死在"本机子进程"这个形态上:一个 Host 启一个进程,远程调不了,多个客户端共享不了,也没法在中间插鉴权。
换成 Streamable HTTP,Server 端要改的其实只有"最后一公里"。工具注册那部分一个字不用动,包括上一篇用的 server.tool()------顺手提一句,SDK 1.30 里它已经标了 @deprecated,官方建议换成 registerTool(),参数从"位置参数"变成"一个对象",多了 title 和 outputSchema 的位置:
ts
// 旧写法(仍可用,但已弃用)
server.tool("search_notes", "搜索笔记", { keyword: z.string() }, handler);
// 新写法
server.registerTool(
"search_notes",
{
title: "搜索笔记",
description: "按关键词搜索笔记的标题、正文和标签,返回 id、标题和摘要。",
inputSchema: { keyword: z.string().min(1).describe("要搜索的关键词") },
},
async ({ keyword }) => ({
content: [{ type: "text", text: `...` }],
})
);
传输层从 stdio 换成 HTTP,加一个 Node 原生 http server 就够,不需要 Express:
ts
// server.ts
import { createServer } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const httpServer = createServer(async (req, res) => {
if (!req.url?.startsWith("/mcp") || req.method !== "POST") {
res.writeHead(404).end("not found");
return;
}
const body = await readBody(req);
// stateless:每个请求一套 server + transport,用完即弃
const server = createNotesServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
void transport.close();
void server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, body);
});
httpServer.listen(3001);
sessionIdGenerator: undefined 这个写法很反直觉,它表示的是 stateless 模式:不生成会话 ID,不校验会话,每个请求都是独立的。
MCP 的 Streamable HTTP 支持两种模式,选哪个取决于你的工具是不是有状态:
- stateless:工具只读、或者状态存在数据库里。部署多个实例、水平扩容都随便,最省心。
- stateful :工具需要记住"这个用户上一步干了什么"。这时要传
sessionIdGenerator: () => randomUUID(),Server 会返回Mcp-Session-Id响应头,客户端后续请求自动带上,你再用一个 Map 把 session id 映射到对应的 transport 实例。
我的笔记服务是纯读的,所以选了 stateless。如果你的工具依赖会话,别硬套 stateless,它会在你自以为跑通之后才开始丢数据。
这里有个小插曲。改完我习惯性用 curl 探一下服务活没活:
bash
curl -X POST http://localhost:3001/mcp \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",...}'
直接 406。响应体倒是很实诚:
json
{"jsonrpc":"2.0","error":{"code":-32000,
"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}
Streamable HTTP 的规则是:客户端必须在 Accept 头里同时 声明 application/json 和 text/event-stream,因为服务端有权决定这次返回普通 JSON 还是一条 SSE 流。curl 默认的 Accept: */* 不算数。
这个坑不影响 SDK 写的客户端(它会自动带上正确的头),但会影响你排查问题------手动 curl 一个 Streamable HTTP 服务,永远记得补上这个头,否则你会以为服务挂了。
第二步:三行代码,把工具清单问出来
Client 侧的连接比想象中短:
ts
// client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "notes-agent", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL("http://localhost:3001/mcp"));
await client.connect(transport); // 握手、协商协议版本
const { tools } = await client.listTools(); // 发现能力
connect() 内部做的事情比看起来多:发 initialize 请求、校验协议版本、接收服务端的 capabilities、再发一条 notifications/initialized。这些全自动,不用管。
listTools() 返回的东西值得看一眼,这是后面所有工作的原料:
json
[
{
"name": "search_notes",
"title": "搜索笔记",
"description": "按关键词搜索笔记的标题、正文和标签,返回匹配笔记的 id、标题和摘要。只返回摘要,不返回全文。",
"inputSchema": {
"type": "object",
"properties": {
"keyword": { "type": "string", "minLength": 1, "description": "要搜索的关键词" },
"limit": { "default": 5, "type": "integer", "minimum": 1, "maximum": 10, "description": "最多返回几条" }
},
"required": ["keyword"],
"$schema": "http://json-schema.org/draft-07/schema#"
},
"execution": { "taskSupport": "forbidden" }
}
]
注意两个细节,都和上一篇写的 zod 直接相关:
description是你用.describe()写的,default和minLength也是 zod 的.default()/.min()转出来的。上一篇说"描述是写给模型看的",到了 Client 这一侧你才看得到它到底长什么样$schema和execution是协议层加的,不是你写的。它们不能直接扔给模型,下一节马上踩这个坑
第三步:翻译,全篇最容易翻车的地方
模型厂商要的格式长这样:
ts
{ type: "function", function: { name, description, parameters: {...} } }
MCP 给的是标准的 JSON Schema。两边看起来差不多,所以很多人直接 parameters: tool.inputSchema 一把梭。能跑,但不干净。
我写了一个转换函数,干三件事:
ts
function toModelTools(tools: Tool[]): ChatCompletionTool[] {
return tools.map(({ name, description, title, inputSchema }) => {
// 1. $schema 是合法 JSON Schema 关键字,但不是模型要的
// 部分网关会对 parameters 做严格校验,多一个 key 就 400
const { $schema, ...parameters } = inputSchema as Record<string, unknown>;
return {
type: "function",
function: {
name, // 2. 工具名要守住 ^[a-zA-Z0-9_-]+$
description: description ?? title ?? name, // 3. 描述优先级:description > title > 名字
parameters,
},
};
});
}
三件事分别对应三个理由:
一、$schema、execution、outputSchema 这些是协议字段,不是模型字段。 留着它们,运气好模型无视,运气差网关直接报 Additional properties are not allowed ('$schema' was unexpected)。我倾向于一开始就清干净,别赌网关的宽容度。
二、工具名有字符集约束。 MCP 和 OpenAI 对工具名的要求都是 ^[a-zA-Z0-9_-]+$,但一旦你挂了多个 Server,名字里往往需要带来源前缀(notes__search、jira__search),这个 __ 分隔符就是为了避开下面坑三说的冲突。
三、description 的优先级不能空。 有些 Server 只填 title 不填 description,而模型只看 description。回退到 name 是最后兜底,虽然难看,但比一个空字符串强------空描述的工具有个特点:模型基本不会调用它。
还有一点容易被忽略:default 不会替你补参数。 上面 limit 的 default: 5 只是"提示",模型完全可以不传。如果参数 schema 是像上一篇那样用 zod 写的,SDK 在 Server 端解析时会补上默认值------所以兜底要写在 Server 里,Client 这边不能指望。
第四步:Agent Loop,四十行
到这一步工具清单已经能喂给模型了,剩下的就是一个循环。核心三句话:把工具清单交给模型;模型要工具就执行并回灌;模型不再要工具就结束。
图片:Agent Loop 流程图------请求模型 → 有 tool_calls?→ 执行并回灌 → 回到请求模型 / 无 tool_calls 则输出,建议尺寸 900×383
ts
async function run(client: Client, question: string) {
const { tools } = await client.listTools();
const modelTools = toModelTools(tools);
const messages: ChatCompletionMessageParam[] = [
{
role: "system",
content:
"你是一个笔记助手。需要查找内容时必须调用工具,不要凭空回答。" +
"如果工具返回失败,根据提示里的信息自行修正后再试一次。",
},
{ role: "user", content: question },
];
for (let step = 1; step <= MAX_STEPS; step++) {
const res = await llm.chat.completions.create({
model: MODEL,
messages,
tools: modelTools,
tool_choice: "auto",
});
const msg = res.choices[0].message;
messages.push(msg); // 纪律一:assistant 消息必须原样回灌
if (!msg.tool_calls?.length) {
return msg.content; // 不再要工具,循环结束
}
for (const call of msg.tool_calls) {
if (call.type !== "function") continue;
const args = JSON.parse(call.function.arguments || "{}");
let output: string;
try {
const result = await client.callTool({ name: call.function.name, arguments: args });
output = renderResult(result);
} catch (err) {
output = `[工具调用异常] ${(err as Error).message}`; // 纪律三:别让异常炸掉循环
}
messages.push({
role: "tool",
tool_call_id: call.id, // 纪律二:id 必须和上面那条调用对上
content: output,
});
}
}
throw new Error(`超过最大轮次 ${MAX_STEPS}`);
}
三条纪律,每一条背后都是一次 400。
纪律一:assistant 消息必须原样回灌。 模型这一轮返回的 message 里带着 tool_calls 数组,你要把整个对象 push 回 messages。如果你只 push 了 content(而它恰好是 null),下一轮请求就会因为"有 tool 消息但找不到对应的 assistant 调用"被拒。
纪律二:tool_call_id 必须一一对应。 一次返回多个 tool_calls 时,每个结果都要带着自己的 id 回去,顺序无所谓,id 不能错。并行调用时这里最容易写错。
纪律三:异常不能往上抛。 工具执行失败是一个业务结果,不是一个程序错误。把它变成文本喂回模型,是 Agent 能自我纠正的前提------下面坑二专门讲。
renderResult() 负责把 MCP 的返回结构压成一段文本:
ts
function renderResult(result: Awaited<ReturnType<Client["callTool"]>>): string {
const blocks = (result.content as Array<{ type: string; text?: string }>) ?? [];
const text = blocks
.map((b) => (b.type === "text" ? b.text ?? "" : `[${b.type} 内容已省略]`))
.join("\n");
return result.isError ? `[工具执行失败] ${text}` : text;
}
content 是个数组,元素可能是 text、image、audio、resource。文本模型只认文本,所以非文本块降级成一句说明------别直接 JSON.stringify 整个 result ,那会把协议元数据(isError、structuredContent)当成正文喂给模型,纯属浪费 token。
跑起来是这样的:
text
[discover] search_notes, get_note, save_note
[step 1] 调用 search_notes({"keyword":"MCP","limit":5})
-> 找到 2 条: - [n1] MCP 的传输层有三种 摘要:stdio 走标准输入输出...
[step 2] 调用 get_note({"id":"n1"})
-> # MCP 的传输层有三种 标签:mcp, 协议 stdio 走标准输入输出...
[step 3] 最终回答:
笔记里跟 MCP 有关的记录有 2 条,核心是两点:
1. 传输层:stdio 只适合本地进程,SSE 已被 Streamable HTTP 取代...
2. 和 Function Calling 的区别:前者是模型厂商的接口约定...
三轮,两次工具调用,一次最终回答。这就是 Agent Loop 的全部。第一版 Claude Code 也好,Cursor 的 Agent 模式也好,骨架都是这个循环,区别只在提示词、工具数量和错误处理做得多细。
踩坑记录
坑一:工具报错就 throw,等于把自愈能力扔了
- 现象:让 Agent 读一个不存在的笔记 id,整个循环直接崩掉,用户看到一堆堆栈
- 原因 :
callTool()在工具返回isError: true时不会抛异常 ,它正常 resolve,只是结果里带着isError。但我一开始在 Client 里写了if (result.isError) throw new Error(...),等于亲手把一个"可恢复的业务错误"升级成了"不可恢复的传输错误" - 解法 :把
isError转成一段给模型看的文本,让它自己改
改完之后,同一个问题变成了这样:
text
[step 1] 调用 get_note({"id":"n404"})
-> [工具执行失败] 笔记 n404 不存在,可用 id 有:n1, n2, n3
[step 2] 调用 get_note({"id":"n1"})
-> # MCP 的传输层有三种 标签:mcp, 协议 stdio 走标准输入输出...
[step 3] 最终回答:
我一开始用了一个不存在的 id(n404),工具返回可用 id 是 n1/n2/n3,
于是我改成 n1 重新读取成功。这篇讲的是:stdio 只适合本地进程...
模型没有重试同一个错误参数,它读懂了错误信息里的候选列表。这也反过来解释了上一篇那条"工具描述是 prompt 的一部分"------工具的错误信息同样是 prompt 的一部分 。写 笔记不存在 和写 笔记 n404 不存在,可用 id 有:n1, n2, n3,恢复率是两个世界。
顺带说,isError: true 和"请求本身失败"是两件事。前者是工具跑完了但结果是错的,后者是根本没连上。前者交模型,后者才该进你的日志和告警。
坑二:tool_call_id 用下标拼,并行调用立刻暴露
- 现象 :单个工具调用永远正常,模型一次返回两个
tool_calls时,报Invalid parameter: messages with role 'tool' must be a response to a preceeding message with 'tool_calls' - 原因 :我图省事,写成了
tool_call_id: "call_" + i,用数组下标当 id。模型给的 id 是call_abc123这种随机串,对不上号 - 解法 :永远用
call.id原值,不要自己生成、不要改写、不要在中间做任何"美化"
顺便提醒:call.function.arguments 是字符串 ,不是对象。模型偶尔会返回非法 JSON(比如中文引号、尾逗号),JSON.parse 会直接炸。稳妥的做法是包一层 try,解析失败就把"你给的参数不是合法 JSON"回灌给模型,让它重发一次------这又是同一个思路:能交给模型修的,就别自己崩。
坑三:挂三个 Server 之后,工具名开始打架
- 现象 :接了两个 Server,各自都有
search,模型调用时总有一个调不到,或者调错 - 原因 :
tools数组里的name必须全局唯一,后注册的会覆盖先注册的(取决于你怎么拼数组)。就算名字侥幸不重复,模型看着search、search_docs、searchAll也很难稳定选对 - 解法 :加来源前缀,并且在描述里带上来源
ts
const PREFIX = { notes: "notes", jira: "jira" };
function withPrefix(tools: Tool[], source: keyof typeof PREFIX): ChatCompletionTool[] {
return toModelTools(tools).map((t) => ({
...t,
function: {
...t.function,
name: `${PREFIX[source]}__${t.function.name}`,
description: `[${source}] ${t.function.description ?? ""}`,
},
}));
}
调用的时候再把前缀剥掉,转回真实的 serverName + toolName:
ts
const [source, ...rest] = call.function.name.split("__");
const client = clients[source]; // 每个 Server 一个 Client 实例
const result = await client.callTool({ name: rest.join("__"), arguments: args });
前缀不只是防冲突,它还是模型选工具时的信号 。notes__search 和 jira__search 放在一起,比两个都叫 search 的工具强太多。
再往下还有个更麻烦的问题:工具一多,光是把清单塞进请求就吃掉几千 token,而且模型的选择准确率会明显下降。业界的做法是按需加载------先给模型一个"工具目录",它需要某一类时再单独拉那一组。这是另一篇文章的话题,这里只需要知道:工具数量是有成本的,不是越多越好。
坑四:stdio 和 HTTP 的错误,长得完全不一样
- 现象 :本地 stdio 跑得好好的代码,换成 HTTP 之后,偶发
fetch failed - 原因 :stdio 下 Server 是子进程,进程死了连接立刻断,错误很明确。HTTP 下多了一层网络:服务没启动、端口被占、容器还没 ready,全都表现为
fetch failed或者ECONNREFUSED,cause里再套一层AggregateError - 解法 :连接阶段加重试,并且把
err.cause打出来。SDK 的StreamableHTTPClientTransport本身支持重连配置(initialReconnectionDelay、maxRetries),但它管的是已建立连接后的断线重连 ,首次connect()失败还是得你自己兜
这条不算 MCP 的问题,是分布式系统的一贯脾气。但因为它出现在"从本地到远程"这个迁移节点上,很容易被误判成协议 bug。
总结
- Client 的职责只有四件事:连接、发现、翻译、循环。真正难的是后两件
- Server 端从 stdio 换到 Streamable HTTP,改的是"最后一公里":一个 http server 加一个
StreamableHTTPServerTransport。无状态工具用 stateless(sessionIdGenerator: undefined),有状态才上 session - MCP 的
inputSchema不能直接当parameters用:清掉$schema等协议字段,守住工具名字符集,description一定要兜底 - Agent Loop 就四十行,但三条纪律不能破:assistant 消息原样回灌、
tool_call_id用原值、工具错误不 throw - 工具的错误信息是 prompt 的一部分。它能自愈,前提是你把"为什么错、接下来能试什么"写清楚
- 多 Server 场景先加前缀,再考虑按需加载工具
适用边界 :如果你只是在 Claude Desktop、Cursor、Cherry Studio 这类现成 Host 里用工具,那不用写 Client,配个 JSON 就完事,这篇文章你可以直接跳过。自己写 Client 的唯一理由是------工具要嵌进你自己的产品里,而你需要控制这一层:谁能用哪些工具(白名单)、每次调用记什么(审计)、失败了怎么降级(兜底)、成本怎么算(token 预算)。这些能力现成 Host 一个都不会给你。
上一篇说"Function Calling 是模型的能力,MCP 是生态的约定"。现在可以补一句:约定只有在 Host 侧被实现,才会变成产品里的能力。 Server 决定能做什么,Client 决定怎么用------而后者,才是你能做出差异的地方。
代码在本地跑通的完整版本一共三个文件(Server / Client / Agent Loop),加起来不到 300 行,协议细节以 modelcontextprotocol.io 为准。