1. 从 40 个工具挤爆 prompt 说起MCP 工具分组到底解决什么问题如果你正在用 Spring AI 写 MCP Server大概率经历过这个阶段一开始只有三五个McpTool工具列表返回给客户端毫无压力等业务铺开财务、运维、数据分析、订单、风控各写一个工具类工具数量冲到 40 个以上问题就集中爆发了。最直观的表现是 system prompt 被工具描述撑爆。MCP 客户端连接时会先发tools/list服务端把全部工具的名称、描述、入参 schema 一次性返回客户端再把这些塞进模型的上下文。工具越多每次对话的入站 Token 消耗越高而且模型面对几十个功能相近的工具时选错工具的概率明显上升。另一个更麻烦的问题是租户隔离A 租户在工具列表里能看到 B 租户的工具虽然真正调用时还有一层鉴权兜底但体验上已经很难看了。我试过最直接的思路是绕开 MCP 官方的注册方式自己写一套路由分发。但McpTool用着确实顺手Spring AI 的 MCP 自动配置也在快速迭代自己造轮子意味着后续每次升级都要重新对齐协议细节不划算。所以真正合理的方案是「只做减法」不改McpTool的写法不改 MCP 协议本身客户端也不需要任何改造只在tools/list响应写回客户端之前把不属于当前业务分组的工具删掉。这篇文章就围绕这个思路把注解设计、启动扫描、SSE 响应过滤、统一 Key 接入这几块串起来给出一套可以直接抄的配置骨架。适合谁看正在用 Spring AI 搭 MCP Server、工具数量已经超过 20 个、需要按业务域或租户做工具可见性隔离的后端开发者。读完你能拿到一份可运行的config.toml与settings.json骨架以及按分组验证调用的完整步骤。2. 前置准备用 TaoToken 统一 Key 打通多 MCP 服务的鉴权在讲工具分组之前先把鉴权这条线理清楚。多 MCP 服务场景下如果每个服务各自维护一套 Key客户端配置会迅速膨胀切换工具组时还要改一堆环境变量。比较省事的做法是用一个统一的 API 通道来收口TaoToken 在这里扮演的就是这个角色一个 Key 覆盖多个模型与 MCP 接入场景客户端只需要认一个地址。官网入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基址是 https://taotoken.net/api 注意 API 地址后面不加 UTM 参数直接拼路径即可。你需要提前准备的东西不多一个可用的 TaoToken API Key在控制台的 API Keys 页面创建地址是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentJDK 17 及以上Spring Boot 3.2Spring AI 版本建议 1.0.0-M6 之后一个能跑起来的 MCP Server 工程已经引入了spring-ai-mcp-server-spring-boot-starter关于 Key 的存放别硬编码进代码。本地开发放环境变量线上走配置中心。下面这段是application.yml里的读取方式taotoken: api-key: ${TAOTOKEN_API_KEY} base-url: https://taotoken.net/api然后在启动脚本或 IDE 的运行配置里注入TAOTOKEN_API_KEY。这样做的另一个好处是工具分组过滤逻辑和鉴权逻辑解耦——分组决定「看到哪些工具」Key 决定「能不能连上服务」两者互不干扰。如果你还想在接入前先验证一下模型通道是否正常可以打开模型对话页面发一条测试消息地址是 https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 确认 Key 有效再往下走能省掉不少排查时间。3. 可复制配置注解、扫描、SSE 过滤三段骨架这一节是全文的核心按「注解定义 → 启动扫描 → 请求头传递 → SSE 响应过滤」四步给出代码。每一段都可以直接贴进工程。3.1 自定义 ToolGroup 注解与工具类写法先定义一个类级别的注解用来声明这个工具类属于哪个业务域Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Component public interface ToolGroup { String value(); }注意这里顺手加了Component这样被ToolGroup标注的类会自动注册成 Bean省掉再写一遍Component。工具类的写法保持不变只是多一个分组声明ToolGroup(finance) public class FinanceTools { McpTool(name queryBalance, description 查询账户余额) public String queryBalance(ToolParam(accountId) String accountId) { return balance of accountId; } McpTool(name queryInvoice, description 查询发票记录) public String queryInvoice(ToolParam(month) String month) { return invoice list for month; } }一个类下的所有McpTool方法自动归属到finance分组。这里有个设计取舍一开始考虑过在方法级别加注解但同一个类里的工具通常属于同一个业务域方法级声明会带来大量重复类级别就够了。3.2 启动时建立工具名与分组的双向映射Spring 提供了getBeansWithAnnotation()可以一次性拿到所有标注了某个注解的 Bean。启动时扫一遍建立两张 MapComponent public class ToolGroupRegistry { private final ApplicationContext context; private final MapString, String toolToGroup new ConcurrentHashMap(); private final MapString, SetString groupToTools new ConcurrentHashMap(); public ToolGroupRegistry(ApplicationContext context) { this.context context; } PostConstruct public void init() { MapString, Object beans context.getBeansWithAnnotation(ToolGroup.class); for (Object bean : beans.values()) { ToolGroup ann bean.getClass().getAnnotation(ToolGroup.class); if (ann null) continue; String group ann.value(); for (Method method : bean.getClass().getDeclaredMethods()) { McpTool tool method.getAnnotation(McpTool.class); if (tool null) continue; String toolName tool.name(); toolToGroup.put(toolName, group); groupToTools.computeIfAbsent(group, k - new HashSet()).add(toolName); } } } public SetString toolsOf(SetString groups) { SetString result new HashSet(); for (String g : groups) { result.addAll(groupToTools.getOrDefault(g, Set.of())); } return result; } public String groupOf(String toolName) { return toolToGroup.get(toolName); } }这段代码只在启动时跑一次运行时只读性能开销可以忽略。toolsOf用于过滤tools/listgroupOf用于tools/call前的二次校验。3.3 请求头传递分组与 ThreadLocal 清理客户端在请求里通过 HTTP 头声明自己属于哪些分组多分组用逗号分隔x-mcp-biz-group: finance,analytics服务端在过滤器里解析并存入 ThreadLocal方便后续任意位置读取public class GroupContext { public static final ThreadLocalSetString HOLDER new ThreadLocal(); public static SetString current() { return HOLDER.get() null ? Set.of() : HOLDER.get(); } public static void set(String raw) { if (raw null || raw.isBlank()) { HOLDER.set(Set.of()); return; } HOLDER.set(Arrays.stream(raw.split(,)) .map(String::trim) .filter(s - !s.isEmpty()) .collect(Collectors.toSet())); } public static void clear() { HOLDER.remove(); } }这里有个踩过的坑ThreadLocal 用完一定要清否则线程池复用时会拿到上一个请求的分组数据。清理动作放在过滤器的finally块里Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws ServletException, IOException { try { GroupContext.set(req.getHeader(x-mcp-biz-group)); chain.doFilter(req, resp); } finally { GroupContext.clear(); } }3.4 拦截 tools/list 的 SSE 响应并过滤工具MCP 走的是 SSE 传输响应体不是纯 JSON而是event:加data:的组合。所以拿到响应体后要先按行拆开找到data:开头的那一行解析 JSON过滤tools数组再重新拼回 SSE 格式。String respBody readResponse(wrapper); String jsonLine extractDataLine(respBody); JSONObject json JSON.parseObject(jsonLine); JSONArray tools json.getJSONObject(result).getJSONArray(tools); SetString allowed registry.toolsOf(GroupContext.current()); JSONArray filtered new JSONArray(); for (Object tool : tools) { String name ((JSONObject) tool).getString(name); if (allowed.isEmpty() || allowed.contains(name)) { filtered.add(tool); } } json.getJSONObject(result).put(tools, filtered); String newResp event:message\ndata: json.toJSONString() \n\n; wrapper.resetBuffer(); wrapper.getWriter().write(newResp);为什么不在业务层直接返回过滤后的列表而是在过滤器里做因为tools/list的响应是 Spring AI MCP 自动配置生成的覆盖或继承那些内部类成本高过滤器拦截是侵入性最低的方式后续升级 Spring AI 版本时也不容易出问题。3.5 config.toml 与 settings.json 骨架客户端侧如果用支持 TOML 配置的工具可以这样写[mcp_servers.spring_ai_server] command java args [-jar, mcp-server.jar] [mcp_servers.spring_ai_server.env] TAOTOKEN_API_KEY sk-你的Key TAOTOKEN_BASE_URL https://taotoken.net/api MCP_BIZ_GROUP finance,analytics如果客户端走 JSON 配置对应的settings.json骨架如下{ mcpServers: { spring-ai-server: { url: http://localhost:8080/sse, headers: { x-mcp-biz-group: finance,analytics, Authorization: Bearer sk-你的Key } } } }两个配置里x-mcp-biz-group是分组开关改这一个值就能切换工具组不用动服务端代码。Authorization头走 TaoToken 的统一 Key服务端在调用上游模型时复用同一个 Key 即可。4. 验证请求按业务域分组后的调用结果配置写完接下来验证分组是否真的生效。分三步走。第一步启动服务观察日志里ToolGroupRegistry扫描到的分组数量。正常情况下会打印类似groupfinance, tools2的记录。如果某个工具类没被扫到先检查类上是不是漏了ToolGroup或者包路径不在ComponentScan范围内。第二步用 curl 模拟客户端发起tools/list带上分组头curl -N http://localhost:8080/sse \ -H x-mcp-biz-group: finance \ -H Authorization: Bearer sk-你的Key返回的 SSE 流里data:那一行的 JSON 中result.tools应该只包含finance分组下的工具。把请求头换成x-mcp-biz-group: analytics再发一次工具列表应该完全切换。如果两次返回一样说明过滤器没生效检查ContentCachingResponseWrapper是否真的包住了响应。第三步验证tools/call的二次校验。故意用一个不属于当前分组的工具名发起调用curl -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -H x-mcp-biz-group: finance \ -d {jsonrpc:2.0,id:1,method:tools/call,params:{name:queryInvoice,arguments:{month:2024-01}}}如果queryInvoice属于finance分组应该正常返回如果它属于别的分组应该返回 JSON-RPC 错误。这一步是防止用户通过其他途径知道工具名后绕过列表过滤。实测下来工具列表从 40 多个降到 8 到 15 个取决于租户单次请求的入站 Token 消耗平均下降一半左右模型选错工具的概率也明显降低。更意外的是工具少的时候模型决策质量反而更稳。5. 本篇常见错排查SSE 换行、ThreadLocal 残留、分组为空这一节把几个高频问题集中列一下都是实际调试时容易卡住的地方。SSE 末尾换行丢失导致客户端收不到消息。这是最隐蔽的一个。SSE 格式对换行极其敏感标准消息必须是data:{json}\n\n末尾两个换行缺一不可。一开始只写了一个\n客户端一直收不到响应排查了很久。重新拼响应时务必确认末尾是两个换行。ThreadLocal 未清理导致分组串号。线程池复用场景下如果过滤器没有在finally里调用GroupContext.clear()下一个请求会继承上一个请求的分组表现为「A 租户看到了 B 租户的工具」。这个问题的特点是偶发很难复现所以清理动作一定要写死。客户端没传分组头时的兜底策略。有两种选择返回空列表或者返回全部工具。建议选后者并打警告日志方便定位是哪个调用方忘了配请求头。如果选返回空列表客户端会以为服务端没有工具排查方向容易跑偏。分组名大小写不一致。ToolGroup(Finance)和请求头里的finance会被当成两个分组。建议在ToolGroupRegistry初始化时统一转小写请求头解析时也转小写避免这种低级问题。过滤器顺序问题。如果工程里还有其他过滤器要确保分组解析过滤器在 MCP 响应包装过滤器之前执行否则GroupContext.current()拿不到值。可以通过Order注解控制顺序。多分组取并集时的空值处理。x-mcp-biz-group: finance,这种末尾带逗号的情况split 后会产生空字符串过滤掉即可否则会去查一个不存在的分组返回空集合表现为工具列表为空。6. 一次配置切换工具组把 Key 与分组收口到统一通道整套方案落地后日常维护的动作其实很少新增工具时在类上加ToolGroup客户端切换工具组时改x-mcp-biz-group请求头鉴权始终走同一个 TaoToken Key。服务端不需要为每个租户部署独立实例应用层过滤已经能满足大部分隔离需求。如果你的场景开始往长期编码或 Agent 方向走比如让模型在多个工具组之间自动切换、按任务动态加载工具可以考虑把 Key 和分组配置进一步收口到 Coding Plan 里统一管理地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 这样客户端侧只需要维护一份配置。接入过程中如果遇到 Key 校验或通道配置的问题接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API Keys 管理在 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。需要快速验证模型通道是否正常直接用模型对话页面发一条消息即可。最后留一个实用建议分组粒度别切太细。按业务域分财务、运维、数据分析通常够用切到方法级别会让映射表膨胀维护成本反而上升。工具数量在 10 到 20 个之间时模型的工具选择准确率是最稳的分组的目标就是让每个租户看到的工具数量落在这个区间。
企业数字化 ERP 产品动态
相关推荐
媒体库图片检索与显示实战:用 TaoToken 统一 Key 打通 AI 工具链 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 3:58:39
万字长文:仅花7天,用Cursor配TaoToken从0到1上线个人网站,保姆级教程! /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 3:58:39
奇安信天擎卸载全攻略:驱动残留与注册表清理实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 3:58:39
Kettle Web版部署实战:环境配置、转换执行与避坑指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 4:45:08
TracePro光学仿真软件安装教程与LED配光实战指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 4:45:08
物流管理系统开发实战:Spring Boot + Vue前后端分离设计 做物流管理系统这个选题的时候,我在Spring Boot和Vue之间来回权衡了很久。这套系统不像普通的CRUD项目,它涉及订单、运单、车辆、司机、仓库、结算多条业务线,状态流转复杂,角色权限层级多。如果你正在做毕业设计、或者公司内部要… · 2026/9/26 4:45:08
开源贡献入门指南:从零开始提 Pull Request 的完整实操路线 刚接触开源社区的朋友,十有八九都问过我同一个问题:"我也想给开源项目做贡献,但到底该从哪下手?" 每次收到这种消息,我都特别理解那种感觉——看着 GitHub 上成片的仓库,星星多、贡献者众&#x… · 2026/9/26 4:44:56
从零部署OpenClaw:接入本地模型与飞书渠道的完整实践 上个月我用了两个周末,把OpenClaw从零到一完整部署起来,接上了本地模型,跑通了飞书和终端两个渠道,还顺手解决了几个能把人逼疯的报错。这篇东西就是那段时间的完整记录,包括部署思路、能直接照着敲的命令、参数怎么定… · 2026/9/26 4:44:56
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46