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 + 应用,端到端跑场景:
- 给租户 A 授权
query_orders(5 个参数版本) - MCP client 拉
tools/list,确认看到 5 参数 schema ✅ - 调 Admin API 给租户 A 的
query_orders加一个自定义参数channel - 等 30 秒让缓存自然过期
- 再拉
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;
...
}
关键发现
tools是final CopyOnWriteArrayList------构造函数里一次性填充,之后无法替换- 有
addTool(...)方法但没有"重新拉取 provider"的方法 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
具体落地
原样透传] 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 个关键设计:
- 复用
AuthenticationService.verifyMcp:Filter 在 SpringHandlerInterceptor之前,无法靠AuthInterceptor.preHandle验 token。必须手动调验证服务 TenantContext.setCurrentTenant/clear成对:ThreadLocal 必须在 finally 清理,否则容器线程复用时下一个请求会看到上一个租户- 直接调 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();
}
坑 1 :ToolDefinition.inputSchema() 返回的是已经序列化的 JSON 字符串 。直接 tool.put("inputSchema", str) 会得到双重 escape 的字符串,AI 端解析会失败。必须 readTree 还原成 JsonNode 再 tool.set(...)。
坑 2 :id 字段必须原样回传(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() 时 TenantContext 是 null,会走 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
参考链接:
- Spring AI MCP Server 文档:docs.spring.io/spring-ai/r...
- MCP 协议规范:modelcontextprotocol.io/specificati...
- mcp-core SDK 仓库:github.com/modelcontex...