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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意 API 地址后面不加 UTM 参数,直接拼路径即可。
你需要提前准备的东西不多:
- 一个可用的 TaoToken API Key,在控制台的 API Keys 页面创建,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- JDK 17 及以上,Spring Boot 3.2+,Spring 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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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。启动时扫一遍,建立两张 Map:
@Component public class ToolGroupRegistry { private final ApplicationContext context; private final Map<String, String> toolToGroup = new ConcurrentHashMap<>(); private final Map<String, Set<String>> groupToTools = new ConcurrentHashMap<>(); public ToolGroupRegistry(ApplicationContext context) { this.context = context; } @PostConstruct public void init() { Map<String, 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 Set<String> toolsOf(Set<String> groups) { Set<String> 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/list,groupOf用于tools/call前的二次校验。
3.3 请求头传递分组与 ThreadLocal 清理
客户端在请求里通过 HTTP 头声明自己属于哪些分组,多分组用逗号分隔:
x-mcp-biz-group: finance,analytics服务端在过滤器里解析并存入 ThreadLocal,方便后续任意位置读取:
public class GroupContext { public static final ThreadLocal<Set<String>> HOLDER = new ThreadLocal<>(); public static Set<String> 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"); Set<String> 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扫描到的分组数量。正常情况下会打印类似group=finance, tools=2的记录。如果某个工具类没被扫到,先检查类上是不是漏了@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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,这样客户端侧只需要维护一份配置。
接入过程中如果遇到 Key 校验或通道配置的问题,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API Keys 管理在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。需要快速验证模型通道是否正常,直接用模型对话页面发一条消息即可。
最后留一个实用建议:分组粒度别切太细。按业务域分(财务、运维、数据分析)通常够用,切到方法级别会让映射表膨胀,维护成本反而上升。工具数量在 10 到 20 个之间时,模型的工具选择准确率是最稳的,分组的目标就是让每个租户看到的工具数量落在这个区间。