MCP & Tools

MCP 工具超过 128 个后,Agent 如何自己选择工具

拆解 EthanKit 的自适应 MCP 工具集:宿主预选、运行时发现、动态注入与执行校验如何在单次请求限制内承载超大工具目录。

2026.07.22 · 13 分钟

把多个 MCP server 绑定到同一个 Agent 后,工具数量很容易从十几个增长到上百个。GitHub、Linear、浏览器、内部数据平台各自暴露几十个工具,完整目录超过 128 个并不罕见。真正的问题不是 MCP 能否列出这些工具,而是模型的一次请求能否接收全部工具定义,以及模型在大量相似 schema 中还能否稳定地选对工具。

EthanKit 的处理方式不是截断第 128 个之后的工具,也不是把完整目录拼进 system prompt,而是把“已连接的工具目录”和“当前轮可见的工具集合”分开:宿主保留完整 catalog,每次只向模型发送一个受预算约束的 active set;如果现有集合不能完成任务,Agent 可以调用 search_mcp_tools,让更多工具从目录进入下一轮上下文。

这里的 128 是 EthanKit Console 当前为 provider 请求设置的工具定义上限,不是 MCP 协议本身的限制。系统内部采用三条水位线:首轮最多 96 个定义,运行中动态扩展最多 112 个定义,发给 provider 前再用 128 做最终硬校验。三者计算时都包含内置工具、知识搜索和子 Agent 等非 MCP 工具,而不只是 MCP server 返回的工具。

核心不是分页,而是两阶段选择

整个过程可以拆成两个选择阶段:

多个 MCP server
      │ listTools()

完整 catalog(可以 > 128)

      ├─ 策略过滤:deny / allowlist / read-only / per-server limit

      ├─ 首轮排序:用户问题 + system prompt + 工具元数据

首轮 active set(总定义数 ≤ 96)

      ├─ 现有工具足够 ───────────────► 直接执行

      └─ 现有工具不够
             │ Agent 调用 search_mcp_tools

        激活更多工具(总定义数 ≤ 112)
             │ 下一轮重新读取 definitions

        Agent 看见新工具并调用

第一阶段由宿主完成。它根据当前用户问题和 Agent 的 system prompt,为模型准备一个相关的初始工作集。第二阶段才是 Agent 的主动选择:模型发现手边的工具不够时,不需要知道完整目录,也不需要猜一个隐藏工具并直接调用,而是用自然语言描述所需能力,让工具发现入口激活候选项。

这种设计类似操作系统的虚拟内存:catalog 是可访问的完整能力空间,active set 是当前装入上下文的工作集。工具仍然连接在当前 run 上,但“已连接”不等于“已暴露给模型”,更不等于“允许立即执行”。

第一步:把完整目录留在宿主侧

McpToolExecutor.catalog() 在 run 开始时遍历所有连接的 MCP server,通过 listTools() 取得工具名称、描述、输入 schema 和 annotations,并将名称规范化为 serverId__toolName。例如两个 server 都提供 search 时,模型实际看到的会是 github__searchlinear__search,执行器也能据此前缀路由到正确连接。

catalog entry 同时保留两种信息:一份是供选择器使用的 serverId、原始工具名和 annotations;另一份是最终可以发给模型的完整 definition。系统不会因为首轮没有选中某个工具而丢弃它的 schema,只是暂时不把它放进请求。

每个 run 都创建独立的 activeNames 集合。它既决定 definitions() 当前返回哪些 MCP schema,也被执行器用于运行时校验,因此一次 run 中的动态激活不会修改 Agent 的全局配置,更不会影响另一个并发 run。

第二步:先做权限过滤,再谈相关性

工具进入排序器之前,isMcpToolEligible() 会先应用每个 server 的 toolPolicy。当前策略模型包含:

  • modeautoallowlistall
  • allowedToolsdeniedToolspinnedTools
  • maxActiveTools:单个 server 在一次 run 中最多激活多少工具,默认 24,配置上限 96;
  • sideEffects:允许写操作,或仅允许明确标记为只读的工具。

过滤顺序很重要。deny 始终生效;allowlist 只保留显式列出的工具;auto 在配置了非空 allowed list 时,也只会在这个受限集合中自动选择。read-only 不是依赖工具名称猜测,而是要求 MCP 工具带有 readOnlyHint: true,没有明确标记的工具会被排除。这让权限边界保持 fail closed。

pinnedTools 与非 auto 模式下的 eligible tools 会被视为 required,优先占用预算。required tools 超过单 server 上限或首轮总容量时,系统直接抛出 McpToolSelectionError,要求减少 allowlist 或改用 auto,而不是静默丢弃管理员声明的工具。因此,all 是一个明确的全量选择,而不是“大概尽量多放一些”;它不适合直接绑定一个超过预算的大目录。

第三步:用可解释的词法分数准备首轮工具

通过策略过滤后,auto 候选项会进入 rankMcpTools()。当前实现刻意使用轻量、确定性的词法评分,而不是额外发起一次模型调用或依赖向量服务。简化后的分数可以表示为:

score = 12 × 用户问题中的工具词命中数
      +  2 × system prompt 中的工具词命中数
      + 16 × 用户问题是否直接提到 server id
      +  3 × system prompt 是否提到 server id
      +  3 × 是否带 readOnlyHint
      + 读写意图修正
      + 常见意图别名修正

工具词来自 toolName + description,经过 Unicode 字母与数字分词并去重。用户问题的权重高于 system prompt,避免一个宽泛的 Agent 人设压过当前任务。如果查询提到 Linear,Linear 工具会获得额外分数;如果提到“PR”“合并请求”“工单”“评审”或“日志”,则会与 pull_requestissuereviewlog 等工具名称做别名匹配。

读写意图还有一层保护。当问题包含“查询、列出、最近、summary、review”等读取信号时,listsearchgetreadfindviewdownload 形态的工具加 8 分;createupdatedeletepushsend 等写工具减 32 分。这个扣分不是权限控制——真正的权限仍由 tool policy 决定——但它能减少“用户只想查一下,模型首轮却只看到修改工具”的情况。

相同分数最终按 qualified name 排序,确保同一输入得到稳定结果。首轮 auto 选择还设置了每个 server 最多 12 个工具的公平性上限,即使全局预算仍有空位,单个大 server 也不能挤掉其他 server 的入口。该上限仍受更严格的 maxActiveTools 约束。

第四步:给 Agent 一个受控的工具发现入口

只要 catalog 中至少有一个 server 使用 auto 模式,系统就注册一个本地元工具 search_mcp_tools。它暴露给模型的参数很小:

{
  "query": "读取某个 issue 的评论和 review 状态",
  "server_ids": ["linear"],
  "limit": 6
}

其中 query 必填,server_ids 可选,limit 默认 6、最大 12。工具描述会告诉 Agent:当当前工具不足以完成任务时调用它,被激活的工具将在下一轮可见。

发现过程不会在完整 catalog 上无条件搜索。候选项必须同时满足:属于 auto server、尚未激活、匹配可选的 server 范围、通过相同的权限过滤,并且该 server 尚未达到 maxActiveTools。候选项随后复用首轮词法排序,只是把 Agent 为本次发现生成的 query 作为相关性输入。

返回值包含新激活工具的 qualified name、描述、当前 active 数量和剩余容量。Agent 因而能够先发现能力,再根据下一轮出现的完整 input schema 正确构造调用参数。发现工具本身不代理业务调用,也不把未经选择的全部 schema 塞回对话。

第五步:每轮动态刷新,而不是启动时固定工具数组

动态发现能生效,依赖 Agent loop 接受一个 toolDefs provider,而不只是静态数组:

const toolDefsProvider = () => [
  ...baseToolDefinitions,
  ...adaptiveMcpToolset.definitions(),
];

AgentLoop 在每次模型调用之前重新执行这个函数。于是一次完整交互是:

  1. 第 N 轮,模型只看到初始 active tools 与 search_mcp_tools
  2. 模型调用 search_mcp_tools,handler 向当前 run 的 activeNames 加入候选工具;
  3. 工具结果作为 observation 写回消息;
  4. 第 N + 1 轮开始前,loop 再次读取 definitions;
  5. 新工具的名称、描述和完整 input schema 此时才进入 provider 请求。

这一步是实现“Agent 自己选择”的关键。如果 tool definitions 在 run 启动时被复制成固定数组,搜索即使更新了 active set,模型也永远看不到新工具。

第六步:可见性和可执行性必须同时变化

只隐藏 definition 还不够。模型可能从历史消息得知一个工具名,也可能生成一个看起来合理的 server__tool 名称。如果执行器只按 server 前缀路由,未激活工具仍可能被直接调用,工具预算就会退化成纯 UI 行为,策略边界也会被绕过。

因此 McpToolExecutor 接收一个 canExecute 回调,并在真正调用 MCP server 前检查 qualified name 是否位于 activeNames。未激活调用会返回错误,并提示先使用 discovery capability。测试覆盖了完整边界:同一个隐藏工具在发现前执行会失败,MCP callTool() 不会被触发;调用 search_mcp_tools 激活后,definition 出现在下一轮,随后才能执行成功。

这形成了两道一致的门:

  • definition gate 决定模型当前能看见什么;
  • execution gate 决定宿主当前允许执行什么。

两者共享同一个 run-scoped active set,不需要依赖模型“自觉”遵守提示词。

三层预算如何配合

EthanKit 不把 128 个槽位全部用于首轮 MCP 工具,而是按完整请求计算容量:

首轮 MCP 容量 = 96 - 非 MCP 工具数 - discovery 工具数
动态 MCP 容量 = 112 - 非 MCP 工具数 - discovery 工具数
最终请求校验 = 全部工具定义数 ≤ 128

仓库中的大目录测试连接了 4 个 server,每个提供 40 个工具,总 catalog 为 160。假设另有 3 个非 MCP 工具,默认 auto 策略会从每个 server 首轮选 12 个,共激活 48 个 MCP 工具,再加 1 个 search_mcp_tools。因此 adaptive toolset 返回 49 个定义,完整首轮请求为 52 个定义,明显低于 96,而剩余 112 个 MCP 工具仍保留在宿主 catalog 中等待发现。

如果运行中持续发现工具,完整工具定义集合会在 112 水位停止增长;如果未来其他工具组合或配置错误仍让请求超过 128,AgentLoop 会在调用 provider 之前本地失败。这样可以把“供应商拒绝了一个过大的请求”变成可预测、可测试的宿主错误。

容量不足也不会导致不透明截断。非 MCP 工具本身已经超过水位、required tools 放不下,或单 server 的 required tools 超过 maxActiveTools 时,系统都会给出具体的 selection error。创建自适应工具集失败后,已经建立的 MCP connections 也会被关闭,避免错误路径泄漏连接。

这套方案解决了什么,又没有解决什么

自适应工具集同时解决了三个问题:provider 单次请求的定义数量上限、大量无关 schema 对上下文的占用,以及模型在相似工具之间的选择噪声。更重要的是,它没有为了“自动”而跳过 allowlist、side-effect policy 和执行时校验。

但当前实现也有清晰边界:

  • 排序是词法启发式,不是语义检索;工具描述质量差、同义词未覆盖时,首轮召回可能不理想;
  • active set 在一次 run 中只增不减,达到动态水位后不会自动淘汰早期工具;
  • 每个 server 首轮 12 个的固定上限保证了公平性,但单 server 深度任务可能更依赖运行中发现;
  • catalog 在 run 开始时获取,server 中途新增或修改工具不会自动刷新;
  • read-only 安全性依赖 MCP server 正确提供 annotations,虽然缺少标记时会安全地拒绝,但也可能损失可用工具;
  • 当前日志记录首轮 active 数与 catalog 总数,后续还可以补充候选得分、入选原因、发现命中率与工具淘汰轨迹。

下一步若要继续演进,可以在不改变安全模型的前提下替换 ranking:先用稳定规则过滤权限,再用 BM25、embedding 或小模型做候选召回;也可以引入按最近使用和任务阶段驱动的 LRU 式淘汰,让 active set 在固定预算内滚动,而不是单调增长。无论排序器怎样升级,catalog、active set、dynamic definitions 与 execution gate 这四层边界都应保留。

当 MCP 工具超过 128 个时,目标不应该是想办法把 128 个以上的 schema 全塞给模型,而是让 Agent 始终拥有一个足够小、与当前任务相关、必要时可扩展且无法绕过策略的工作集。工具越多,越需要把“连接能力”与“暴露能力”分开管理。

延伸阅读:MCP 与子 Agent · 安全与隐私