Spring AI MCP 实战:tools/list 启动快照陷阱与完整解法

Enterprise Connector 系列第 01 篇。完整代码:enterprise-connector

适合谁读:用 Spring AI 做 MCP Server 的 Java 工程师、想给不同租户/用户暴露不同工具集的开发者、关心框架"启动快照"机制的同行。


一句话结论(先给答案)

Spring AI 1.1.4 + mcp-core 0.17 的组合有一个隐藏陷阱ToolCallbackProvider 只在启动时被调一次,之后永远不会被运行时的 tools/list 调用。

你以为按租户算的 schema 逻辑,运行时根本走不到------全是死代码

解决方案:在 servlet filter 层短路 tools/list 请求,自己读 header、调 provider、序列化响应直接写出。

下面是完整的踩坑记录、源码分析和最终方案。


一、背景:我为什么需要 per-session Tool Schema

我做的项目是一个多租户企业数据连接器------商家通过自然语言(如微信对话)→ AI Agent → 调本系统的 MCP tool → 查商家自己的数据库。

业务模型:

  • 系统内有一张全局的 action_template 表(运维维护的 SQL 模板,比如 query_orders / query_inventory
  • 每个租户通过 tenant_action_config 决定自己开通了哪些 action,能否覆盖参数 schema(PREMIUM 租户可以加自定义参数)
  • AI 拉 tools/list 时,不同租户应该看到不同的工具集 + 不同的 inputSchema

举个例子:

less 复制代码
租户 A (BASIC):    [query_orders, query_inventory]
租户 B (PREMIUM):  [query_orders, query_inventory, query_customers, send_promo_sms]
                    └── query_orders 的 inputSchema 还多了一个 channel 字段

为什么必须按租户隔离 schema

  • 如果 AI 看到的工具多于租户实际授权的(全局合集),会幻觉调用未授权 tool → 浪费 token、误导用户
  • 如果 AI 看到的工具少于租户实际授权的(漏了 PREMIUM 租户的专属字段),会跳过本能做的操作 → 功能损失
  • 必须精准对齐租户授权------多租户 SaaS 最经典的需求

二、第一次尝试:自实现 ToolCallbackProvider

Spring AI 的官方教程推荐这样注册:

java 复制代码
@Bean
public ToolCallbackProvider perTenantProvider(...) {
    return new PerTenantToolCallbackProvider(...);
}

我的第一版实现:

java 复制代码
public class PerTenantToolCallbackProvider implements ToolCallbackProvider {
    @Override
    public ToolCallback[] getToolCallbacks() {
        String tenantId = TenantContext.getCurrentTenant();   // 从 ThreadLocal 取
        if (tenantId == null) {
            return globalView();   // 没租户上下文时退回全局
        }
        return computeForTenant(tenantId);   // 按租户合并
    }
}

逻辑很直白------每次被调用时读 TenantContext 算结果。

单元测试全绿 :mock 不同的 tenantId,断言返回不同的 ToolCallback[],全过。

我以为这事就完了。


三、集成测试中崩塌:provider 启动后再也没被调用过

把模板表 / 授权表的种子数据准备好,启 PG + Redis + 应用,端到端跑场景:

  1. 给租户 A 授权 query_orders(5 个参数版本)
  2. MCP client 拉 tools/list,确认看到 5 参数 schema ✅
  3. 调 Admin API 给租户 A 的 query_orders 加一个自定义参数 channel
  4. 等 30 秒让缓存自然过期
  5. 再拉 tools/list------预期看到 6 参数

第 5 步永远是 5 参数。

排查过程:

第一反应:缓存问题

PerTenantToolCallbackProvider 内部用了 Caffeine 30s TTL。我把 TTL 改成 1s、加 @EventListener 监听写入事件------事件确实到了,缓存也确实清了,但 tools/list 返回完全没变

第二反应:ThreadLocal 没传递

TenantContext 是 ThreadLocal,会不会 spring-ai-mcp 内部切了线程导致 getCurrentTenant() 返回 null?加 log 验证:

java 复制代码
@Override
public ToolCallback[] getToolCallbacks() {
    log.info("[DEBUG] getToolCallbacks called, tenantId={}",
             TenantContext.getCurrentTenant());
    ...
}

重启应用,发了 5 个 tools/list 请求。日志里只有一行

ini 复制代码
[DEBUG] getToolCallbacks called, tenantId=null

而且这一行是应用启动时打的 ------之后 5 次 HTTP 请求没有触发任何新的 log

provider.getToolCallbacks() 在启动后根本没被调用过

我之前所有按租户算 schema 的代码、缓存策略、事件监听------全是死代码


四、扒源码:McpAsyncServer.tools 是启动时初始化的 CopyOnWriteArrayList

mcp-core-0.17.0.jar(Spring AI 1.1.4 用的 MCP SDK 实现)解开来 javap 看:

bash 复制代码
$ javap -p io/modelcontextprotocol/server/McpAsyncServer.class
public class io.modelcontextprotocol.server.McpAsyncServer {
  ...
  private final java.util.concurrent.CopyOnWriteArrayList<
      io.modelcontextprotocol.server.McpServerFeatures$AsyncToolSpecification
  > tools;
  ...
}

关键发现

  1. toolsfinal CopyOnWriteArrayList------构造函数里一次性填充,之后无法替换
  2. addTool(...) 方法但没有"重新拉取 provider"的方法
  3. toolsListRequestHandler() 返回的就是这个 list 转成 McpSchema.Tool 的 Flux

再追溯 Spring AI 的 autoconfiguration 层(spring-ai-starter-mcp-server-webmvc 1.1.4 版本):

java 复制代码
// 启动时
List<ToolCallback> all = new ArrayList<>();
for (ToolCallbackProvider provider : toolCallbackProviders) {
    all.addAll(Arrays.asList(provider.getToolCallbacks()));   // ← 调一次!
}
McpAsyncServer server = McpServer.async(...)
        .tools(all)   // ← 固化进 CopyOnWriteArrayList
        .build();

真实的调用链

启动阶段(仅一次)

ini 复制代码
Spring Boot 启动
  → AutoConfiguration 扫描 ToolCallbackProvider Bean
  → 调 provider.getToolCallbacks()  ← 此时 TenantContext == null
  → 把结果塞进 McpAsyncServer.tools (CopyOnWriteArrayList)
  → final 锁定

运行时(每次请求)

bash 复制代码
HTTP POST /mcp { method: "tools/list" }
  → spring-ai 的 servlet handler
  → 读 McpAsyncServer.tools 的快照
  → 直接返回
  ❌ provider 永远不会被再次调用

顺便验证:SSE / Streamable / Stateless 三种 transport 的 autoconfiguration 都是这套,行为一致。


五、为什么 MCP SDK 这么设计

这不是 bug,是协议的合理产物

原因 说明
协议层不带身份参数 MCP tools/list 规范假设 server 提供的是"全局工具清单"
提供了 tools/list_changed 通知 本意是 server 主动推送变更,client 重新拉。这是协议层留给"动态 tool 集合"的口子
_meta.session.id 未升级 MCP SDK 0.17 还没把它做成"per-session 视图"的一等公民
CopyOnWriteArrayList 性能最优 符合"启动一次性注册、运行时高频读取"的预期

站在 SDK 角度,启动快照 + 全局 list 是合理选型。问题是它假设的使用场景和我们的多租户场景不匹配------这不是谁的错,是协议成熟度还没覆盖到这一类需求。

结论 :在 MCP 协议把 "per-session schema" 升级为一等公民之前,任何"按调用方变 schema"的需求,都得在协议层之前自己拦


六、解决思路:servlet filter 是协议层之前唯一能控制的位置

看清楚启动快照机制后,我没花太多时间评估其他路径。McpAsyncServer.tools 是 final 字段,所有 spring-ai 提供的扩展点都在它的"上游"(启动注册阶段)------一旦应用启动完成,这个 list 就被 SDK 锁定了。

要在运行时按租户出不同结果,唯一不和框架内部状态搏斗的位置,是协议处理之前------也就是 servlet filter 层。

这是 Java Web 栈最古老的"逃生通道":

  • Filter 早于 DispatcherServlet 执行,先于 Spring AI 的任何 handler
  • Servlet 规范明确允许 Filter 不调用 chain.doFilter,自己写 response 直接返回
  • HTTP 缓存 / 维护页 / 鉴权拦截这些场景早就在用 Filter 做"短路",不是 hack

具体落地

flowchart TD A[HTTP 请求] --> B{POST /mcp ?} B -- 否 --> Z[chain.doFilter
原样透传] B -- 是 --> C[读 body
包装成 CachedBodyRequest] C --> D[解析 JSON-RPC method] D --> E{method == tools/list ?} E -- 否 --> F[chain.doFilter
透传给 spring-ai-mcp] F --> G[由 spring-ai 处理
initialize / tools/call/ping/ ...] E -- 是 --> H[verifyMcp 验 Bearer Token] H -- 失败 --> X[写 JSON-RPC error
短路返回] H -- 成功 --> I[读 X-Tenant-Id
设 TenantContext] I --> J[调 PerTenantToolCallback
Provider.getToolCallbacks] J --> K[序列化 ToolDefinition
构造 JSON-RPC 响应] K --> L[写 response body
不调 chain.doFilter] L --> M[finally: TenantContext.clear] style E fill:#fff4e6,stroke:#ff9800 style L fill:#e8f5e9,stroke:#4caf50 style F fill:#e3f2fd,stroke:#2196f3

核心思想

  • 拦截 POST /mcp 上的 tools/list 请求
  • Filter 内自己读 X-Tenant-Id → 设 TenantContext → 调 provider → 序列化响应直接写出
  • 其他 method(initialize / tools/call / ping / notifications/*)原样透传给 spring-ai

七、完整实现:McpToolsListInterceptFilter

完整代码在 McpToolsListInterceptFilter.java

7.1 Filter 注册顺序

java 复制代码
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
public class McpToolsListInterceptFilter extends OncePerRequestFilter {
    ...
}

为什么用 HIGHEST_PRECEDENCE + 100 (注:@Order 数值越小优先级越高):

  • 必须早于 spring-ai-mcp 的 servlet handler
  • 但要晚于一些更基础的 Filter(CORS / TraceId / 编码)
  • 不取最高优先级,留 100 单位空间给比我们更基础的 Filter(它们的 @Order 通常比 HIGHEST_PRECEDENCE 大一点点)

7.2 路由判断 + body 缓存

HTTP request body 是一次性 InputStream,读完就空。Filter 要解析 JSON-RPC method 决定是否短路,而透传路径下游 spring-ai 也要读 body。必须包装一层让 body 可重复读

java 复制代码
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                FilterChain chain) throws ServletException, IOException {
    if (!"POST".equalsIgnoreCase(request.getMethod())
            || !MCP_ENDPOINT.equals(request.getRequestURI())) {
        chain.doFilter(request, response);
        return;
    }

    byte[] body = readAllBytes(request);
    CachedBodyHttpServletRequest wrapped = new CachedBodyHttpServletRequest(request, body);

    JsonNode rpc = JsonUtils.mapper().readTree(body);
    String method = rpc.path("method").asText("");

    if (!TOOLS_LIST_METHOD.equals(method)) {
        chain.doFilter(wrapped, response);   // 透传, 用 wrapped 让下游能读 body
        return;
    }

    handleToolsList(request, response, rpc);   // 短路自处理
}

Spring 自带的 ContentCachingRequestWrapper 也能用,但 spring-ai 内部直接读原始 input stream(不通过 wrapper),所以必须自己实现 CachedBodyHttpServletRequest

7.3 短路处理 tools/list

java 复制代码
private void handleToolsList(HttpServletRequest request, HttpServletResponse response,
                             JsonNode rpc) throws IOException {
    // 1. 验 token (复用现有 AuthenticationService)
    try {
        authenticationService.verifyMcp(request);
    } catch (BaseException e) {
        writeJsonRpcError(response, rpc.get("id"),
                e.getErrorCode().getHttpStatus(), e.getMessage());
        return;
    }

    // 2. 设 TenantContext, 让 PerTenantToolCallbackProvider 能按租户合并
    String tenantId = request.getHeader(BusinessConstants.MCP_HEADER_TENANT_ID);
    boolean tenantSet = false;
    try {
        if (tenantId != null && !tenantId.isBlank()) {
            TenantContext.setCurrentTenant(tenantId.trim());
            tenantSet = true;
        }

        // 3. 调 provider 拿 per-tenant 合并后的 ToolCallback[]
        ToolCallback[] callbacks = toolCallbackProvider.getToolCallbacks();

        // 4. 构造 JSON-RPC 响应 + 写出
        String responseJson = buildToolsListResponseJson(rpc.get("id"), callbacks);
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("application/json;charset=UTF-8");
        try (PrintWriter writer = response.getWriter()) {
            writer.write(responseJson);
        }
    } finally {
        if (tenantSet) {
            TenantContext.clear();   // 防 ThreadLocal 泄漏
        }
    }
}

3 个关键设计

  1. 复用 AuthenticationService.verifyMcp :Filter 在 Spring HandlerInterceptor 之前,无法靠 AuthInterceptor.preHandle 验 token。必须手动调验证服务
  2. TenantContext.setCurrentTenant / clear 成对:ThreadLocal 必须在 finally 清理,否则容器线程复用时下一个请求会看到上一个租户
  3. 直接调 provider,不进 spring-ai 链:这就是"短路"------response 写完就返回

7.4 JSON-RPC 响应构造的两个坑

java 复制代码
static String buildToolsListResponseJson(JsonNode idNode, ToolCallback[] callbacks) {
    ObjectNode root = JsonUtils.mapper().createObjectNode();
    root.put("jsonrpc", "2.0");
    if (idNode != null && !idNode.isNull()) root.set("id", idNode);

    ObjectNode result = root.putObject("result");
    ArrayNode tools = result.putArray("tools");
    for (ToolCallback cb : callbacks) {
        ToolDefinition def = cb.getToolDefinition();
        ObjectNode tool = tools.addObject();
        tool.put("name", def.name());
        if (def.description() != null) tool.put("description", def.description());

        // ⚠️ 坑:inputSchema 是已序列化的 JSON 字符串,必须 readTree 还原成 JsonNode
        String inputSchema = def.inputSchema();
        if (inputSchema != null && !inputSchema.isBlank()) {
            try {
                tool.set("inputSchema", JsonUtils.mapper().readTree(inputSchema));
            } catch (Exception e) {
                ObjectNode fallback = tool.putObject("inputSchema");
                fallback.put("type", "object");
            }
        }
    }
    return root.toString();
}

坑 1ToolDefinition.inputSchema() 返回的是已经序列化的 JSON 字符串 。直接 tool.put("inputSchema", str) 会得到双重 escape 的字符串,AI 端解析会失败。必须 readTree 还原成 JsonNodetool.set(...)

坑 2id 字段必须原样回传(JSON-RPC 规范)。如果是 null 不能省略字段,但本系统 client 都传非 null id,简化处理。严格场景应区分缺失 vs null。


八、配套:让 PerTenantToolCallbackProvider 真正发挥作用

短路方案在 Filter 里调 toolCallbackProvider.getToolCallbacks(),这时 TenantContext 已经被设好,provider 内部按租户算就对了:

java 复制代码
public class PerTenantToolCallbackProvider implements ToolCallbackProvider {
    private final Cache<String, ToolCallback[]> tenantCache;   // Caffeine 30s TTL

    @Override
    public ToolCallback[] getToolCallbacks() {
        String tenantId = TenantContext.getCurrentTenant();
        if (tenantId == null || tenantId.isBlank()) {
            return globalView();   // admin 上下文没租户时退回全局
        }
        return tenantCache.get(tenantId, this::computeForTenant);
    }

    private ToolCallback[] computeForTenant(String tenantId) {
        // 1. 查 tenant_action_config 该租户所有 enabled 行
        // 2. 每条按 templateId 拿对应 ActionTemplate
        // 3. 合并 (template.paramSchema, actionConfig.customParams) 算 effectiveSchema
        // 4. 构造 ActionToolCallback 数组
        ...
    }

    /** 模板/授权变更时清缓存, 让所有 session 下次拉到最新视图 */
    @EventListener
    public void onTemplateChanged(TemplateChangedEvent event) {
        tenantCache.invalidateAll();
    }

    @EventListener
    public void onActionAuthChanged(ActionAuthChangedEvent event) {
        tenantCache.invalidateAll();
    }
}

缓存策略

  • Caffeine 内存缓存,key=tenantId,TTL=30 秒
  • 写入路径(admin grant/revoke action、改 template)发 Spring ApplicationEvent,本进程 listener 立即清空
  • 跨实例靠 30 秒 TTL 兜底

第一版我选择全清(任何一个租户的授权变更都让所有租户下次拉新视图),代价是 30 秒内的偶发额外计算。后续有性能问题再优化。


九、已知限制(不藏)

9.1 tools/call 没做实时重校验

如果客户端拿了 30 秒前的 schema,期间租户授权被 revoke,tools/call 时本系统当前还是会执行(只在请求进来后由 TenantStatusGuard 查表挡)。这层挡得住权限,但对 AI 来说体验是"我看到的工具突然不给用了"

修复方案:在 tools/call 的 handler 路径上也按当前最新 schema 校验参数。

9.2 tools/list_changed 推送未实现

理论上 admin 改了租户授权,应该主动给已连接的 MCP client 推 tools/list_changed 通知。依赖 Spring AI MCP 1.1.x 协议层 API(还在迭代),先不做。

9.3 PerTenantToolCallbackProvider 在启动时仍会被调一次

spring-ai-mcp 启动时调 getToolCallbacks()TenantContextnull,会走 globalView() 返回所有模板。这部分 被注册到 McpAsyncServer.tools,但运行时全被 Filter 短路了------是无害的"空注册"


十、性能影响

没跑过严格压测,凭感觉写的数字不如不写。直觉判断:

  • tool 数量 < 50 时,Filter 引入的额外开销在毫秒级
  • tools/list 是低频请求(一次会话一两次),高频是 tools/call------Filter 对后者只是判断 method 后透传,开销极小
  • 主要开销在 body 读取 + JSON 解析两处,理论上可以流式优化

真实关心性能的同行欢迎贡献压测数据,会回来补充。


十一、复盘:4 条可推广的经验

经验 1:协议规范 ≠ 实现约束

MCP 协议规范是开放的,但实现的 SDK 在某个版本会有约束 ------这次 Spring AI 1.1.4 + mcp-core 0.17 的组合就是。不要假设"协议允许"等于"SDK 支持",必须读源码确认。

经验 2:启动时快照是 Java 框架的常见模式

Spring Boot autoconfiguration 大量用"启动时枚举所有 Bean → 一次性配置"的模式(Filter 链、ConverterRegistry、HandlerMapping 都是这套)。遇到"运行时按上下文变化"的需求先看:是不是有个 list/map 在启动时被 final 化了------是的话基本只能在更外层拦截。

经验 3:Filter 是最终的"逃生通道"

当所有框架内的扩展点都不够用时,servlet Filter 是最后一道你能完全控制的层 ------比 Spring 的 HandlerInterceptor 还要靠前,能改 request、改 response、短路整条链。框架优雅 vs 业务正确,永远选后者

经验 4:短路不是脏 hack

我推这个方案时第一反应是"在 Filter 里直接写响应是不是太黑?" 后来想清楚:这本来就是 servlet 规范明确支持的用法,HTTP 缓存、404 兜底、维护页都靠这个。只要 Filter 职责清晰、可测、有日志,就不是 hack。


写在最后

这是 Enterprise Connector 系列第 01 篇。后续会一个个拆这套架构里的其他设计。

如果你正在做企业级 AI Agent 接入数据、Spring AI MCP 的多租户实践,或者遇到类似的"框架启动时快照"问题------这个坑中文圈的资料不多,欢迎评论区交流不同方案

完整代码enterprise-connector


参考链接:

相关推荐
吴声子夜歌1 小时前
Java——EnumMap和EnumSet
java·enumset·enummap
gjwjuejin1 小时前
从 Vue 2 到 Vue 3:一位前端工程师的实战学习笔记
java
3D探路人2 小时前
模灵 大模型聚合API 转发流程技术实现
java·大数据·开发语言·前端·人工智能·计算机视觉
程似锦吖2 小时前
无中生有 之 从0开始写一个动态定时任务管理
java·开发语言
techdashen2 小时前
dial9:给 Tokio 装上“飞行记录仪“
java·数据库·redis
ShiJiuD6668889993 小时前
springboot基础篇
java·spring boot·spring
砚底藏山河3 小时前
python、JavaScript 、JAVA,定制化数据服务,助力业务高效落地
java·javascript·python
qq_452396233 小时前
第六篇:《JMeter逻辑控制器:循环、条件和交替执行》
android·java·jmeter
humcomm3 小时前
Java 新特性2026年5月速览
java·开发语言