1. 为什么要把 MCP 配置从 yml 搬到 JSON 动态加载
Spring AI 官方示例里,MCP 服务基本都写在application.yml里,靠spring.ai.mcp.client.stdio或sse这类前缀自动装配。这个方式在单机 demo 阶段很舒服,但一旦进入真实项目就会卡住:每加一个 MCP 服务就要改配置文件、重启应用;多环境(开发/测试/生产)切换端点时,yml 里塞满不同 profile 的覆盖项,维护成本直线上升;更麻烦的是运营侧想临时挂一个工具链,还得让后端改代码重新发版。
我试过在一个知识库项目里同时挂了 6 个 MCP 服务,yml 写到 200 多行,改一个 SSE 端点要翻半天。后来参考 Cherry Studio 的做法,把 MCP 配置抽成 JSON 存到数据库,前端提交 JSON、后端解析后动态注册到McpSyncClient,热加载生效,不用重启。这就是本文要讲的「通过 JSON 动态添加 MCP 服务」的完整实现路径。
核心思路分三层:第一层是数据层,用一张ai_mcp表存config_json字段,JSON 结构直接沿用开源 MCP 服务给出的mcpServers模板,拿来即用;第二层是解析层,用 Jackson 把 JSON 拆成ServerConfig,区分sse和stdio两种传输类型;第三层是运行时层,DynamicMcpClientManager维护一个ConcurrentHashMap<String, McpSyncClient>,定时健康检查 + 断线重连,工具回调带 30 秒缓存。
这套方案适合谁?适合已经在用 Spring AI 做 Agent、需要多环境快速切换 MCP 端点的开发者;适合把 MCP 服务当运营配置项、希望前端可视化增删的产品;也适合想把 MCP 工具链统一走一个 API 通道、避免每个服务单独配 Key 的团队。下面从依赖、建表、解析、动态管理到验证,一步步给可复制的代码。
2. TaoToken 前置:统一 Key 与 API 通道的接入参数
在讲动态注册之前,先把 MCP 服务端点的来源说清楚。很多 MCP 服务(尤其是模型对话类、coding 类)需要填 Base URL 和 API Key,如果每个服务单独申请、单独配,Key 管理会非常散。TaoToken 提供的是统一 Key 和统一 API 通道,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
接入时你需要准备三件套,这三件套在后面的 JSON 配置里会直接体现:
| 配置项 | 值 | 说明 |
|---|---|---|
| Base URL | https://taotoken.net/api | 所有请求走这个入口,不要带 UTM 参数 |
| API Key | 在控制台创建 | 形如sk-xxxx,用于鉴权 |
| Model ID | 按需选择 | 例如对话类、coding 类模型 ID |
创建 Key 的入口在控制台的 API Keys 页面,路径是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite 。如果你只是想先验证模型能不能通,可以直接用模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite 发一条消息试试。长期做编码或 Agent 的,建议看 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite 。
这里要强调一点:TaoToken 是统一的 API 通道,不是让你绕过什么,而是把多个 MCP 服务需要的模型调用收敛到一个 Key 上。你在 JSON 里配置 MCP 服务时,如果这个服务本身要调模型,就把 Base URL 填https://taotoken.net/api,Key 填你创建的 Key,Model ID 填对应模型。这样前端提交的 JSON 里就只出现一个 Key,运维侧换 Key 只改一处。
注意:API 地址不要加 UTM 参数,只有官网和 deep link 才带
utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite。写进代码或 JSON 的 Base URL 必须是干净的https://taotoken.net/api。
3. 可复制配置:pom 依赖、建表 SQL 与 JSON 模板
这一节给三份可直接复制的配置:Maven 依赖、ai_mcp建表 SQL、以及前端提交的 JSON 模板。三份都对齐,后面解析才不会出错。
先看 pom 依赖。核心是spring-ai-starter-mcp-client,它包含ToolCallbackProvider和McpSyncClient;再补一个spring-ai-model拿ToolCallback接口。版本用你项目里统一的spring-ai.version。
<!-- Spring AI MCP 核心包 (包含 ToolCallbackProvider) --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency> <!-- Spring AI Model (ToolCallback 接口等) --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-model</artifactId> <version>${spring-ai.version}</version> </dependency>建表 SQL 用 MySQL,config_json字段类型是json,直接存前端提交的原始 JSON。content_type存sse或stdio,status用 1/0 表示启用禁用。
CREATE TABLE `ai_mcp` ( `id` bigint NOT NULL AUTO_INCREMENT, `content_type` varchar(64) NOT NULL COMMENT '连接类型,sse,stdio', `name` varchar(128) NOT NULL COMMENT 'mcp名称', `description` varchar(255) DEFAULT NULL COMMENT '功能描述', `config_json` json NOT NULL COMMENT '配置参数,json类型', `status` tinyint(1) DEFAULT NULL COMMENT '启用状态,1启用,0不启用', `update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;JSON 模板是重点。开源 MCP 服务基本都给mcpServers结构,你直接拿来用。下面这个模板里,url是服务端点,type是传输类型,sseEndpoint是 SSE 路径。如果这个 MCP 服务需要调模型,把headers里的 Authorization 填成 TaoToken 的 Key,Base URL 用https://taotoken.net/api。
{ "mcpServers": { "amap-amap-sse": { "url": "https://mcp.amap.com", "type": "sse", "sseEndpoint": "/sse?key=xxxxxxxxxxxxxxxxxx" }, "taotoken-model-sse": { "url": "https://taotoken.net/api", "type": "sse", "sseEndpoint": "/sse", "headers": { "Authorization": "Bearer sk-你的TaoTokenKey" }, "timeout": 30 } } }这份 JSON 提交到后端后,AiMcpService.addMcp会解析mcpServers,取第一个 server 名作为name,取type作为content_type,然后如果status=1,就调用dynamicMcpClientManager.addOrUpdateServerConfig动态注册。整个过程不需要重启,前端刷新工具列表就能看到新工具。
提示:
sseEndpoint如果服务方给的是完整 URL,就填完整;如果只给路径,就填/sse这种。ServerConfig.getFullSseUrl()会自动拼接 base URL 和 endpoint,避免双斜杠。
4. 动态加载与验证:从 JSON 到工具链调用的完整步骤
配置就绪后,验证分四步:解析 JSON、动态注册、查询工具、调用工具。每一步都有对应的接口和日志,出问题能定位。
第一步,解析 JSON。AiMcpService.addMcp里用ObjectMapper.readTree读configJson,取mcpServers节点,遍历每个 server,调ConversionUtil.parseServerConfigFromJson转成ServerConfig。这里要处理type大小写,sse和stdio都做equalsIgnoreCase。解析失败要打 warn 日志并跳过,不能让一条坏配置拖垮整个加载。
ObjectMapper objectMapper = new ObjectMapper(); JsonNode root = objectMapper.readTree(mcp.getConfigJson()); JsonNode serversNode = root.path("mcpServers"); if (serversNode.isObject()) { serversNode.fields().forEachRemaining(entry -> { String serverName = entry.getKey(); JsonNode serverNode = entry.getValue(); String type = serverNode.path("type").asText(""); if ("sse".equalsIgnoreCase(type) || "stdio".equalsIgnoreCase(type)) { ServerConfig config = ConversionUtil.parseServerConfigFromJson(serverName, serverNode, type); if (config != null) { dynamicMcpClientManager.addOrUpdateServerConfig(serverName, config); } } }); }第二步,动态注册。DynamicMcpClientManager.updateServerConfigs会对比新旧配置,新增或变更的 server 走connectServer。SSE 传输用HttpClientSseClientTransport.builder(config.getUrl()).sseEndpoint(config.getSseEndpoint()).build(),然后McpClient.sync(transport).requestTimeout(config.getTimeout()).build(),最后client.initialize()。连接成功放进activeClients,失败按MAX_RECONNECT_ATTEMPTS=3重试,重试间隔 2 分钟,三次都失败就把数据库里status置 0,避免死循环。
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(config.getUrl()) .clientBuilder(HttpClient.newBuilder()) .sseEndpoint(config.getSseEndpoint()).build(); client = McpClient.sync(transport) .requestTimeout(config.getTimeout()) .build(); client.initialize(); activeClients.put(serverName, client);第三步,查询工具。McpToolController暴露了/mcp/tools接口,返回当前所有可用工具。工具回调带 30 秒缓存,getAvailableToolCallbacks里判断now - lastToolRefresh > TOOL_CACHE_TTL才刷新。刷新时调SimpleMcpToolCallbackProvider.createToolCallbacks(clients),对每个 client 调listTools(),把McpSchema.Tool包成SyncMcpToolCallback。
curl -X GET "http://localhost:8080/mcp/tools"返回结构里toolCount是工具数,tools数组每项有name、description、inputSchema。如果toolCount是 0,先看/mcp/status里hasDynamicManager是不是 true,再看日志里有没有连接MCP服务器失败。
第四步,调用工具。用/mcp/call/{toolName},POST body 是 JSON 参数。比如高德地图的搜索工具,传{"keywords":"北京南站"},返回结果里success=true和result字段就是工具输出。
curl -X POST "http://localhost:8080/mcp/call/amap-amap-sse" \ -H "Content-Type: application/json" \ -d '{"keywords":"北京南站"}'最后一步是 ChatClient 接入。在getFluxVOFlux里,如果body.getUseTools()为 true,就取dynamicMcpClientManager.getAvailableToolCallbacks(),塞进chatModel.prompt(prompt).toolCallbacks(toolCallbacks).stream().chatResponse()。这样模型在流式输出时就能自动调工具,工具结果回填后继续生成。
if (body.getUseTools()) { List<ToolCallback> toolCallbacks = dynamicMcpClientManager.getAvailableToolCallbacks(); publisher = chatModel.prompt(prompt).toolCallbacks(toolCallbacks).stream().chatResponse(); } else { publisher = chatModel.prompt(prompt).stream().chatResponse(); }验证成功的标志:/mcp/tools返回的toolCount大于 0,/mcp/call/{toolName}返回success=true,ChatClient 流式输出里能看到工具调用痕迹。如果工具列表为空,先确认status=1,再确认 JSON 里type是sse或stdio。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
动态注册 MCP 时,报错集中在四类:鉴权 401、本地代理失败、响应解析 reading choices 报错、OAuth 授权失败。下面逐个对照真实日志给排查路径。
第一类,401 Unauthorized。日志里出现401或Unauthorized,通常是headers里的 Authorization 没带或 Key 失效。检查 JSON 里headers.Authorization是不是Bearer sk-xxx格式,Key 是不是在 TaoToken 控制台创建的。如果 MCP 服务本身不需要鉴权,就不要加headers,否则多余的头可能被服务端拒绝。还有一种情况是 Base URL 写成了带 UTM 的官网地址,正确写法是https://taotoken.net/api,不带任何参数。
第二类,local proxy failed。日志里出现local proxy failed或connection refused,说明url或sseEndpoint拼出来的地址连不上。先用curl手动测一下完整 SSE URL,比如curl -N https://mcp.amap.com/sse?key=xxx,看能不能收到event: endpoint。如果 curl 通但 Java 不通,检查HttpClientSseClientTransport的clientBuilder有没有设超时,默认超时可能太短。另外sseEndpoint如果服务方给的是/sse?key=xxx,要原样填,不要只填/sse。
第三类,reading choices 报错。日志里出现Error reading choices或JsonParseException,通常是 MCP 服务返回的不是标准 SSE 格式,或者listTools()返回体被截断。先确认type填对了,SSE 服务填sse,stdio 服务填stdio。如果服务方返回的是 JSON-RPC 但 content-type 不是text/event-stream,HttpClientSseClientTransport会解析失败。这时候要么换服务端点,要么在ServerConfig里加自定义 header 强制Accept: text/event-stream。
第四类,OAuth 授权失败。日志里出现OAuth或invalid_token,说明 MCP 服务走的是 OAuth 流程,不是简单 Bearer。这种服务需要先拿 access token,再放进headers.Authorization。如果服务方支持 API Key 直连,优先用 API Key,省掉 OAuth 刷新逻辑。TaoToken 的 Key 是长期有效的,直接填Bearer sk-xxx即可,不需要走 OAuth。
排查时有个通用技巧:把DynamicMcpClientManager的日志级别调到 DEBUG,connectServer里会打连接MCP服务器失败: {} - {} (url: {}, sseEndpoint: {}),四个占位符分别是 server 名、异常消息、url、sseEndpoint。对着这四个值逐个核对,基本能定位到是配置错还是网络错。
注意:如果三次重连都失败,
disableMcpInDatabase会把数据库status置 0。排查完配置后,记得把status改回 1,或者重新提交一次 JSON,否则服务不会重新加载。
6. 语义一致 CTA:把 Key、文档和模型验证串起来
动态 MCP 跑通后,下一步是把 Key 管理和模型验证固定成流程。建议按这个顺序操作:先在 API Keys 页面创建 Key https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite ,把 Key 填进 JSON 的headers.Authorization;然后对着接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite 核对 Base URL 和 Model ID 的写法;接着用模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite 发一条消息,确认 Key 和模型 ID 能通;最后回到/mcp/tools刷新工具列表,确认工具链加载正常。
如果你做的是长期编码或 Agent 场景,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite 里对模型 ID 和调用方式有更细的说明,配合动态 MCP 用起来更顺。整个链路里,TaoToken 只承担统一 Key 和 API 通道的角色,MCP 服务的注册、健康检查、工具回调还是由你的 Spring AI 应用管理,职责边界清晰,出问题也好定位。
最后留一个实用技巧:把ai_mcp表的config_json做成前端可编辑的文本框,提交前用ObjectMapper校验一次 JSON 合法性,不合法直接返回错误,不要入库。这样能挡掉大部分因为手抖导致的解析失败,比事后看日志排查省事得多。