前面写完 MCP Server 那篇文章之后,我又接了个需求:让一个 Agent 同时调两个 MCP Server。一个查 SQLite 数据库,一个查本地文件系统。Agent 跑了一轮,返回了一个答案。我看着答案觉得不太对,对了一下原始数据------AI 给出了文件系统里的"张三",但用户问的是数据库里张三的订单记录。
不是 Server 写错了,不是模型不行。是 AI 调错了 Tool。
两个 Server 都有 search 方法,AI 选了文件系统的 search,但它应该调数据库的 query。这时候我才真正理解 MCP Client 是干什么用的------它不是简单的"连接器",而是一个"翻译官",把多组 Server 的能力转译成 AI 能正确理解的语言。
Client 到底是干什么的
先花最短的时间说清楚角色分工。
| MCP Server | MCP Client | |
|---|---|---|
| 职责 | 提供 Tool 给外部调用 | 把 Tool 翻译给 AI 理解 |
| 类比 | 一个 API 接口 | API 文档 + 调用说明书 |
| 出问题的地方 | Tool 内部逻辑出错 | AI 选错了 Tool / 参数传不对 |
| 管理粒度 | 每个 Server 独立运行 | 一个 Client 可对接多个 Server |
Server 只负责"我有这个能力",Client 负责"AI 怎么知道该用哪个"。这个区分听起来简单,但只有当你手上有两个以上 Server 的时候,才知道 Client 的设计直接决定了 AI 会不会乱来。
翻车现场还原
先说场景。我在本地分别启动了 Node.js 写的两个 Server,一个暴露了 search、query、insert 三个 Tool 来操作 SQLite,另一个暴露了 search、read、write 来做文件读写。然后写了一个 Client 把它们连起来。以下代码基于 @modelcontextprotocol/sdk 0.6.x 版本。
第一版 Client 长这样:
js
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const dbClient = new Client({ name: 'database-client' });
const fileClient = new Client({ name: 'filesystem-client' });
await dbClient.connect(
new StdioClientTransport({ command: 'node', args: ['db-server.mjs'] })
);
await fileClient.connect(
new StdioClientTransport({ command: 'node', args: ['file-server.mjs'] })
);
// 列出各 Server 的 Tool
const dbTools = await dbClient.listTools();
const fileTools = await fileClient.listTools();
代码看起来没毛病对吧?两个 Client 实例分别连接不同的 Server,各管各的。但是问题出在 AI 这一侧------当我把两个 Server 的 Tool 列表合并给 LLM 时,AI 看到的工具列表是这样的:
diff
可用工具:
- search(来自 database-server)
- query(来自 database-server)
- insert(来自 database-server)
- search(来自 filesystem-server)
- read(来自 filesystem-server)
- write(来自 filesystem-server)
两个 search。同名。LLM 选 search 的时候,你不能保证它一定选数据库的那个。
事实上我跑了三组对话测试,其中有两次 AI 选了文件系统的 search。"张三"这个关键词在文件里确实有------某个文档里提到了这个名字------但用户问的是"张三的订单",应该在数据库里查 order 表。AI 拿到文件名和对应的内容片段,以为那就是答案。
这个场景其实挺典型。MCP 协议本身不限制 Tool 命名唯一性,Server 开发者各自命名自己的 Tool,撞名是常态。问题是,LLM 在做 Tool 选择时,依赖的是 Tool name + description 的语义匹配。当两个 Tool 的名字一模一样,description 又都跟"搜索"相关,模型大概率猜错。
翻车之后怎么修
发现问题后第一个想法是:给 Tool 名字加上命名空间前缀。
js
class NamespaceClient {
constructor(namespace, client) {
this.namespace = namespace;
this.client = client;
}
async listTools() {
const tools = await this.client.listTools();
return tools.map(tool => ({
...tool,
name: `${this.namespace}_${tool.name}`,
description: `[${this.namespace}] ${tool.description}`
}));
}
async callTool(name, args) {
const originalName = name.replace(`${this.namespace}_`, '');
return this.client.callTool(originalName, args);
}
}
const dbClient = new NamespaceClient('db', originalDbClient);
const fileClient = new NamespaceClient('fs', originalFileClient);
// 现在 AI 看到的列表变成了:
// - db_search
// - db_query
// - db_insert
// - fs_search
// - fs_read
// - fs_write
加上前缀之后,AI 看到的就是 db_search 和 fs_search,名字不同,选错的概率降了很多。我跑了七八轮测试,没有再出现调错 Tool 的情况。
不过这里有个细节值得说------description 也要改。光是改名字不够,因为 LLM 选 Tool 时 description 权重很高。我在 description 前面加了 [db] 和 [fs] 标签,相当于给 AI 一个视觉锚点。后面我翻过一些文章,有人用 XML 标签、有人用 Emoji,我试了一圈觉得纯文本前缀最稳定,模型解析出错率最低。
翻车二:一个 Server 挂了,全链路卡死
修完命名冲突之后,我以为这件事搞定了。直到第二个问题冒出来。
数据库 Server 那边有一次查询跑了很久------那张表数据量到了一定规模,索引没有建好,一次模糊查询拖了近两分钟。Client 一直在等 db_server 返回,file_server 的服务也跟着没法继续。
我查了一下日志才发现问题:Client 是串行处理 Tool 调用的。一个 Server 的 Tool 没返回,后续的调用全部排队等着。
这是一个设计上的取舍。MCP Client 默认不隔离不同 Server 的超时行为,一个慢 Server 会拖慢整个链路。不是每次都会遇到,但遇到就卡死整条链路。
修复方式很直接------给每个 Server 配独立超时:
js
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
const dbClient = new Client(
{ name: 'database-client' },
{ transportTimeout: 8000 } // 8秒超时
);
const fileClient = new Client(
{ name: 'filesystem-client' },
{ transportTimeout: 3000 } // 文件操作一般快得多
);
配了超时之后,db_server 那次慢查询在 8 秒后被 Client 主动中断,file_server 的调用正常执行。当然,超时本身不是完美的方案------超时意味着那个 Tool 调用失败了,AI 需要重试或者走 fallback。但至少不会让其他 Server 跟着陪葬。
后来我又加了一层更细的隔离:给每个 Server 的 Tool 调用包了一层 try/catch,让一个 Server 的失败不会传播到另一个。
js
async function safeCall(client, toolName, args) {
try {
return await client.callTool(toolName, args);
} catch (err) {
console.error(`[${client.id}] Tool ${toolName} failed:`, err.message);
return { error: true, message: `暂无法访问 ${client.id}` };
}
}
其实就是加了个错误边界,但效果很明显------一个 Server 挂掉不会影响另一个。
多 Server 管理的决策框架
写完这个项目之后,我自己整理了一个判断逻辑,什么场景下用什么策略:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 两个 Server 功能完全不重叠 | 命名空间前缀直接拆 | 简单,互不干扰 |
| Server 数量 >= 3 但功能有交集 | 代理聚合模式,统一入口 | 减少 AI 选择成本 |
| 有 Server 偶尔超时/不稳定 | 独立超时 + try/catch 隔离 | 一个挂了不拖累全局 |
| 多个 Server 服务同一场景 | 合并成一个 Server | 减少跨 Server 通信 |
| 外部不可控 Server(第三方) | 包装层 + 降级策略 | 不能假设第三方永远可用 |
代理聚合模式是什么?就是用一层代理 Server 包装下面多个子 Server 的 Tool,对外只有一个入口。子 Server 的 Tool 全部通过代理转发,Client 只需要连一个代理 Server 就行。适合 Server 数量多的时候,减少 AI 面对的选择空间。
这个方案的边界
以上做法能解决大部分多 Server 集成问题,但不是银弹。
命名空间前缀有一类场景搞不定------当 LLM 需要跨两个 Server 的数据做推理时。比如"对比数据库里张三的订单和文件系统里张三的简历",AI 需要同时调 db_search 和 fs_search,两次结果合并做分析。前缀方案只能防选错,不能加速跨 Server 协作。
跨 Server 数据融合是另一个话题了,可能需要 Client 侧做一层缓存或结果聚合。这个我还没完全想好,目前遇到的对比例子不多,方案还不够成熟。
另外超时配置的数值得根据实际场景调。我设的 8 秒和 3 秒是基于本地测试的,放到线上环境网络延迟不同,要重新压测。具体设多少没有万能公式,我自己的做法是先设成 5 秒,跑一周看日志,如果有 Tool 频繁超时就调大,如果服务器响应都很稳定就逐步缩紧。
你现在就可以做的一件事
打开你的 MCP 配置文件(一般是 mcp.json 或 claude_desktop_config.json),看看有没有配多个 Server。如果有,检查一下它们的 Tool 名字------有没有同名的?如果有,加个前缀,花不了五分钟,但能省掉后面排查"AI 为什么拿错数据"的时间。
我当时就是觉得"两个 search 应该没关系吧"------结果查了接近两小时才发现是这个原因。这个亏吃一次就够了。