ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring AI 1.1.2 动态接入MCP实战:用TaoToken统一Key打通自定义实时加载

Spring AI 1.1.2 动态接入MCP实战:用TaoToken统一Key打通自定义实时加载 1. 为什么 Spring AI 1.1.2 的 MCP 加载方式在多租户场景下会卡住Spring AI 1.1.2 里MCPModel Context Protocol客户端的接入方式基本围绕spring-ai-starter-mcp-client-webflux展开配置写在application.yml里应用启动时一次性把McpSyncClient建好再交给SyncMcpToolCallbackProvider生成ToolCallback[]。这套流程在单租户、工具集固定的项目里很顺但一旦落到「同一个智能助手要服务多个租户、每个租户的 MCP Server 地址和工具集都不一样」的场景就会立刻暴露两个硬伤。第一个硬伤是加载时机。配置文件驱动的 MCP Server 在 Spring 容器启动阶段就完成初始化之后想根据用户 ID、租户标识或地区去换一套 MCP Server只能重启应用。可现实是用户在对话里提问的那一刻系统才知道他是哪个租户、该用哪套工具启动期根本拿不到这个维度。第二个硬伤是 Key 与通道分散。每个 MCP Server 往往对应一套独立的鉴权信息多租户叠加多 Server配置项会膨胀成一张难以维护的矩阵。切换环境、轮换 Key、排查某个租户工具不生效都要在多个配置块之间来回翻。这篇要解决的就是这两件事用 TaoToken 把多 MCP 客户端的 Key 收敛成一条统一 API 通道再在 Spring AI 1.1.2 之上自己实现一套「按租户维度动态注册、实时加载、可刷新可驱逐」的 MCP 客户端注册表。核心检索词先摆清楚Spring AI 1.1.2 动态接入 MCP、自定义实时加载、多租户 MCP 工具、TaoToken 统一 Key。适合正在做智能助手、Agent 平台、需要按用户维度挂载不同工具集的 Java 后端同学。我试过把 MCP Server 配置直接塞进application.yml再配合RefreshScope结果是连接对象没法优雅重建旧连接泄漏、新工具不生效最后还是回到自建注册表这条路。下面按可跟做的顺序展开。2. TaoToken 前置把多 MCP 客户端的 Key 收敛成一条通道在动手写注册表之前先把「Key 从哪来、请求走哪条通道」定下来。多租户项目里最烦的不是写代码而是每个 MCP Server 一套鉴权、一套 baseUrl配置一多就容易串。TaoToken 在这里的角色是统一入口你拿到一个 API Key通过统一的 API 通道去访问模型与工具相关能力MCP 客户端的 baseUrl 和鉴权头都指向这一层业务代码里不再散落一堆不同厂商的 Key。先做三件准备。第一注册并登录 TaoToken 官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 完成账号后进入控制台。控制台入口https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。第二在控制台里创建 API Key。Key 管理页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。创建后立刻复制保存页面刷新后通常不再完整显示。这个 Key 就是后面application.yml里要填的统一凭据。第三确认 API 基地址。TaoToken 的 API 入口是 https://taotoken.net/api 注意这个地址不带任何查询参数直接作为 baseUrl 使用。模型对话调试页在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 接入前可以先去那里确认 Key 能正常发起请求避免把鉴权问题误判成 MCP 代码问题。注意Key 只放在服务端配置或环境变量里不要写进前端、不要提交到 Git 仓库。多租户场景下建议一个环境一套 Key便于按环境隔离和轮换。如果你后续要做长期编码或 Agent 类任务可以了解 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入细节和参数说明统一看文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。3. 可复制配置application.yml 与 MCP 客户端骨架这一节给出能直接抄的配置和依赖骨架。目标是把「统一 Key 统一 baseUrl」落到配置里同时保留按租户动态建连的能力。3.1 依赖引入Spring AI 1.1.2 的 MCP 客户端用 WebFlux 版本pom.xml里加dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-client-webflux/artifactId /dependency如果你用的是 Gradleimplementation org.springframework.ai:spring-ai-starter-mcp-client-webflux版本跟随 Spring AI BOM 管理不要单独指定避免和 1.1.2 的其他模块版本打架。3.2 application.yml 配置骨架spring: ai: mcp: client: enabled: true name: tenant-mcp-client version: 1.0.0 request-timeout: 60s type: SYNC taotoken: api: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY:} connect-timeout: 60s request-timeout: 60s这里把 TaoToken 的 baseUrl 和 Key 抽成独立配置块Key 用环境变量注入。spring.ai.mcp.client下的基础项保留但真正的多租户 MCP Server 不写死在这里而是从数据库配置表读取交给自定义注册表动态构建。3.3 数据库配置表结构MCP Server 配置落库按租户维度查询。以 STREAM_HTTP 传输为例表结构大致如下字段类型说明idbigint主键flagvarchar启用标识如 Yagent_codevarchar业务代理编码factoryvarchar工厂/环境维度namevarcharMCP Server 名称base_urlvarcharServer 地址指向统一通道end_pointvarchar端点路径如 /mcptransport_typevarchar传输类型STREAM_HTTP示例数据McpServerA https://sm.mcp.com/mi5Y/mcp TenantA Guizhou McpServerB http://a.adc.com/mcp TenantB Xian实际接入时base_url统一指向 TaoToken 的 API 通道鉴权头由统一 Key 提供业务侧只关心租户与 Server 的映射关系。3.4 自定义工具包装器MCP Server 的 Tools 本质是对 OpenAPI JSON 的包装通用快捷所以入参改写和返回值处理不能塞进 Server 定义里得在客户端包一层ToolCallbackpublic class CustomMcpToolWrapper implements ToolCallback { private final ToolCallback delegate; private final ChatService chatService; public CustomMcpToolWrapper(ToolCallback delegate, ChatService chatService) { this.delegate delegate; this.chatService chatService; } Override public ToolDefinition getToolDefinition() { return delegate.getToolDefinition(); } Override public String call(String toolInput, ToolContext toolContext) { String toolName delegate.getToolDefinition().name(); String modifiedInput toolInput; MapString, Object ctx toolContext.getContext(); String id String.valueOf(ctx.get(id)); String uid String.valueOf(ctx.get(uid)); String agentCode String.valueOf(ctx.get(agentCode)); AiMcpToolT toolCfg chatService.getMcpToolBy(toolName); if (ObjectUtils.isNotEmpty(toolCfg)) { JSONObject inputJson JSON.parseObject(toolInput); inputJson.put(uid, uid); modifiedInput inputJson.toString(); } String rawResponse; try { rawResponse delegate.call(modifiedInput, toolContext); JSONArray array JSON.parseArray(rawResponse); JSONObject firstObj array.getJSONObject(0); rawResponse firstObj.getString(text); } catch (Exception e) { updateChatRecordStatus(id, A); throw e; } if (ObjectUtils.isNotEmpty(toolCfg)) { handleOpenApiResponse(id, agentCode, rawResponse); } return rawResponse; } Override public String call(String toolInput) { return delegate.call(toolInput); } }入参限定比如只能查自己的打卡记录和返回值落库都在这一层做MCP Server 定义保持通用。3.5 租户上下文与动态注册表用一个TenantMcpContext持有某个租户维度下的客户端集合与工具提供者public final class TenantMcpContext implements AutoCloseable { private final String key; private final ListMcpSyncClient clients; private final SyncMcpToolCallbackProvider provider; public TenantMcpContext(String key, ListMcpSyncClient clients, SyncMcpToolCallbackProvider provider) { this.key key; this.clients List.copyOf(clients); this.provider provider; } public String key() { return key; } public ToolCallback[] toolCallbacks() { return provider.getToolCallbacks(); } Override public void close() { for (McpSyncClient client : clients) { try { client.closeGracefully(); } catch (Exception ignored) { } } } }注册表负责按 key 缓存、加锁构建、刷新与驱逐Service public class TenantMcpRegistry { private static final Logger log LoggerFactory.getLogger(TenantMcpRegistry.class); private final WebClient.Builder webClientBuilder; private final ObjectMapper objectMapper; private final AiMcpConfigTService aiMcpConfigTService; private final ConcurrentMapString, TenantMcpContext cache new ConcurrentHashMap(); private final ConcurrentMapString, ReentrantLock locks new ConcurrentHashMap(); public TenantMcpRegistry(WebClient.Builder webClientBuilder, ObjectMapper objectMapper, AiMcpConfigTService aiMcpConfigTService) { this.webClientBuilder webClientBuilder; this.objectMapper objectMapper; this.aiMcpConfigTService aiMcpConfigTService; } public ToolCallback[] resolveToolCallbacks(String flag, String agentCode, String factory) { return getOrLoad(flag, agentCode, factory).toolCallbacks(); } public TenantMcpContext refresh(String flag, String agentCode, String factory) { String key buildKey(flag, agentCode, factory); ReentrantLock lock locks.computeIfAbsent(key, k - new ReentrantLock()); lock.lock(); try { TenantMcpContext fresh buildContext(flag, agentCode, factory); TenantMcpContext old cache.put(key, fresh); closeQuietly(old); return fresh; } finally { lock.unlock(); } } public void evict(String flag, String agentCode, String factory) { String key buildKey(flag, agentCode, factory); ReentrantLock lock locks.computeIfAbsent(key, k - new ReentrantLock()); lock.lock(); try { closeQuietly(cache.remove(key)); } finally { lock.unlock(); } } private TenantMcpContext getOrLoad(String flag, String agentCode, String factory) { String key buildKey(flag, agentCode, factory); TenantMcpContext cached cache.get(key); if (cached ! null) { return cached; } ReentrantLock lock locks.computeIfAbsent(key, k - new ReentrantLock()); lock.lock(); try { TenantMcpContext again cache.get(key); if (again ! null) { return again; } TenantMcpContext created buildContext(flag, agentCode, factory); cache.put(key, created); return created; } finally { lock.unlock(); } } private TenantMcpContext buildContext(String flag, String agentCode, String factory) { ListAiMcpConfigT dbConfigs aiMcpConfigTService.getMcpConfigByFlag(flag, agentCode, factory); if (dbConfigs null || dbConfigs.isEmpty()) { log.info(数据库中无有效的 MCP Server 配置。); return createEmptyContext(flag, agentCode, factory); } ListMcpSyncClient clients new ArrayList(); for (AiMcpConfigT config : dbConfigs) { McpSyncClient client null; try { HttpClient directHttpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(60)).build(); WebClient.Builder builderClone webClientBuilder.clone() .baseUrl(config.getBaseUrl()) .clientConnector(new JdkClientHttpConnector(directHttpClient)); var transport WebClientStreamableHttpTransport .builder(builderClone) .endpoint(config.getEndPoint()) .jsonMapper(new JacksonMcpJsonMapper(objectMapper)) .build(); client McpClient.sync(transport) .requestTimeout(Duration.ofSeconds(60)) .build(); client.initialize(); clients.add(client); log.info(成功加载并初始化 MCP Server: {} (URL: {}), config.getName(), config.getBaseUrl()); } catch (Exception ex) { log.warn(MCP Server 连接或初始化失败跳过: name{}, url{}, 原因: {}, config.getName(), config.getBaseUrl(), ex.getMessage()); if (client ! null) { try { client.closeGracefully(); } catch (Exception ignored) { } } } } if (clients.isEmpty()) { return createEmptyContext(flag, agentCode, factory); } try { SyncMcpToolCallbackProvider provider SyncMcpToolCallbackProvider.builder() .mcpClients(clients).build(); return new TenantMcpContext(buildKey(flag, agentCode, factory), clients, provider); } catch (Exception ex) { closeClients(clients); throw new IllegalStateException(构建 MCP 执行环境失败, factory factory, ex); } } private String buildKey(String flag, String agentCode, String factory) { return flag | agentCode | factory; } private void closeQuietly(TenantMcpContext ctx) { if (ctx ! null) { try { ctx.close(); } catch (Exception ignored) { } } } private void closeClients(ListMcpSyncClient clients) { for (McpSyncClient client : clients) { try { client.closeGracefully(); } catch (Exception ignored) { } } } PreDestroy public void destroy() { for (TenantMcpContext ctx : cache.values()) { closeQuietly(ctx); } cache.clear(); } private TenantMcpContext createEmptyContext(String flag, String agentCode, String factory) { SyncMcpToolCallbackProvider emptyProvider SyncMcpToolCallbackProvider.builder() .mcpClients(Collections.emptyList()).build(); return new TenantMcpContext(buildKey(flag, agentCode, factory), Collections.emptyList(), emptyProvider); } }几个关键点值得单独说。getOrLoad用双重检查加锁避免同一租户并发首次请求时重复建连。buildContext里单个 Server 初始化失败只记警告并跳过不拖垮整个租户的工具集全部失败时返回空上下文保证对话链路不因 MCP 不可用而崩。refresh和evict提供运行时更新能力配置变更后调一次即可实时生效。3.6 实际调用ToolCallback[] toolCallbacks tenantMcpRegistry.resolveToolCallbacks(Y, agentCode, NA); ListToolCallback wrappedTools Arrays.stream(toolCallbacks) .map(tool - new CustomMcpToolWrapper(tool, chatRecordService)) .collect(Collectors.toList());拿到wrappedTools后交给 ChatClient 的toolCallbacks即可。每个租户第一次请求触发建连之后命中缓存配置变更时调refresh重建。4. 验证请求与动态加载生效检查配置写完得确认它真的按预期工作。分三步验证。4.1 启动阶段验证应用启动后观察日志里是否出现「成功加载并初始化 MCP Server」。如果数据库里该租户没有配置应看到「数据库中无有效的 MCP Server 配置」且应用正常启动不抛异常。这一步确认空配置兜底逻辑生效。4.2 首次请求触发动态加载用一个测试接口按租户发起对话请求观察日志成功加载并初始化 MCP Server: McpServerA (URL: https://sm.mcp.com/mi5Y/mcp)第一次请求会触发buildContext日志出现建连记录。第二次同租户请求不应再出现建连日志说明缓存命中。你可以用下面这段代码快速验证工具是否挂上ToolCallback[] callbacks tenantMcpRegistry.resolveToolCallbacks(Y, tenantA, NA); System.out.println(工具数量: callbacks.length); for (ToolCallback cb : callbacks) { System.out.println(工具名: cb.getToolDefinition().name()); }输出里能看到该租户对应的工具列表说明动态加载生效。4.3 配置变更实时刷新验证改数据库里某租户的 MCP Server 地址或工具配置然后调用tenantMcpRegistry.refresh(Y, tenantA, NA);再次请求该租户日志应出现新的建连记录工具列表随之更新。旧连接在refresh里被closeQuietly关闭不会泄漏。这一步是「自定义实时加载」的核心验证点。4.4 用 TaoToken 通道做连通性确认在接入 MCP 之前先用统一 Key 确认通道可用。可以到模型对话页 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 发一条测试消息确认 Key 有效、baseUrl 可达。通道没问题再排查 MCP 层能省掉大量「到底是 Key 错还是代码错」的纠结。5. 本篇常见错排查5.1 MCP Server 初始化失败但应用不报错这是设计如此单个 Server 失败只记警告并跳过。如果你发现某租户工具为空先看日志里的MCP Server 连接或初始化失败那条重点核对base_url和end_point是否拼对。end_point常见坑是漏了前导斜杠或多了重复路径。5.2 工具调用返回结构解析异常CustomMcpToolWrapper里对返回值做了JSONArray解析并取第一个元素的text字段。如果某个 Server 返回的不是这个结构会抛异常并走updateChatRecordStatus(id, A)。排查时先把rawResponse原样打出来确认实际结构再调整解析逻辑不要盲目套用。5.3 并发首次请求重复建连如果日志里同一租户出现多次建连检查getOrLoad的双重检查是否被改动。locks用computeIfAbsent保证每个 key 一把锁锁对象不能提前移除否则并发下会退化成无锁。5.4 配置变更后工具没更新refresh必须显式调用注册表不会自动感知数据库变化。如果你希望自动刷新可以在配置更新接口里调refresh或加定时任务按 key 轮询。注意refresh会关闭旧连接正在执行的调用可能受影响建议在低峰或加优雅等待。5.5 Key 相关报错如果日志出现鉴权失败先确认TAOTOKEN_API_KEY环境变量是否注入成功再确认 baseUrl 是 https://taotoken.net/api 且没有多余路径。Key 管理页 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 可以重新生成轮换后记得同步环境变量并重启或刷新。5.6 连接泄漏TenantMcpContext.close()和PreDestroy的destroy()都要保证执行。如果你在refresh之外手动替换了 cache 里的对象务必自己关闭旧对象否则连接会累积。6. 接入路径与后续动作把上面的骨架跑通后接入侧还有几件事可以按需推进。需要重新生成或管理 Key去 API Keys 页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。参数、鉴权头、错误码这些细节统一查接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。如果你要验证模型侧行为模型对话页可以直接试https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。长期做编码或 Agent 任务Coding Plan 值得看一眼https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。最后留一个实操建议先把TenantMcpRegistry的缓存 key 设计成flag|agentCode|factory三段后续要加地区维度时直接扩成四段buildKey改一处即可别把维度拼进字符串里散落各处。另外refresh建议包一层带超时的异步避免配置更新接口被建连阻塞。这两点是我在真实项目里踩过之后固定下来的做法。
返回列表