在完成 MCP Server 相关文章后,一个实际需求随之而来:让一个 Agent 同时调用两个 MCP Server。一个用于查询 SQLite 数据库,另一个用于检索本地文件系统。Agent 运行一轮后返回了结果。核对答案时发现数据不太对,再比对原始数据——AI 返回了文件系统里的"张三",但用户实际询问的是数据库里张三的订单记录。

问题并非出在 Server 编写错误,也不是模型能力不足。真正的原因是 AI 选错了 Tool。
两个 Server 都提供了 search 方法,AI 最终选择了文件系统的 search,但正确做法应该是调用数据库的 query。这个场景让我深刻理解了 MCP Client 的真正职责——它不仅仅是简单的"连接器",更是一个"翻译官",将多组 Server 的能力转化为 AI 能够准确理解的语言。
MCP Client 的核心职责与作用
先用最短时间厘清角色分工。
| MCP Server | MCP Client | |
|---|---|---|
| 职责 | 提供 Tool 供外部调用 | 将 Tool 翻译给 AI 理解 |
| 类比 | 一个 API 接口 | API 文档 + 调用说明书 |
| 出问题的地方 | Tool 内部逻辑出错 | AI 选错了 Tool / 参数传不对 |
| 管理粒度 | 每个 Server 独立运行 | 一个 Client 可对接多个 Server |
Server 只负责"我具备这个能力",Client 则负责"AI 如何知道该用哪个"。这个区分听起来简单,但只有当你手上同时管理两个以上 Server 时,才会真正体会到 Client 的设计直接决定了 AI 是否会出错。
翻车现场还原:MCP 多 Server 调用实战
先说场景。本地分别启动了 Node.js 编写的两个 Server,一个暴露了 search、query、insert 三个 Tool 来操作 SQLite,另一个暴露了 search、read、write 用于文件读写。然后编写了一个 Client 将它们连接起来。以下代码基于 @modelcontextprotocol/sdk 0.6.x 版本。
第一版 Client 代码如下:
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 的 Toolconst dbTools = await dbClient.listTools();const fileTools = await fileClient.listTools();
代码看起来没毛病对吧?两个 Client 实例分别连接不同的 Server,各管各的。但问题出在 AI 这一侧——当把两个 Server 的 Tool 列表合并交给 LLM 时,AI 看到的工具列表是这样的:
可用工具:- 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 又都跟"搜索"相关,模型大概率猜错。
翻车之后怎么修:MCP Tool 命名冲突解决方案
发现问题后第一个想法是:给 Tool 名字加上命名空间前缀。
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 配独立超时:
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 的失败不会传播到另一个。
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 配置文件(一般是 mcp.json 或 claude_desktop_config.json),看看有没有配多个 Server。如果有,检查一下它们的 Tool 名字——有没有同名的?如果有,加个前缀,花不了五分钟,但能省掉后面排查"AI 为什么拿错数据"的时间。
当时就是觉得"两个 search 应该没关系吧"——结果查了接近两小时才发现是这个原因。这个亏吃一次就够了。
