最近把 Spring AI Alibaba 的实战训练营做到第 28 期,主题是“用 SAA + Nacos 构建分布式 MCP 应用”。这个题目我压了很久,因为很多同学在跑通本地 MCP 之后,一旦想上多环境、多实例,就开始卡壳。MCP 本身只是一个协议,它解决“模型怎么调用外部工具”的问题,但放到分布式环境里,工具的发现、注册、动态开关、灰度发布这些事,它一律不管。所以这次我直接拿 Spring AI Alibaba(后面简称 SAA)配合 Nacos,把一套完整的分布式 MCP 方案跑通了。
这篇内容主要适合这些人:已经在用 Spring Boot 做 AI 应用,想把自己的业务接口暴露成 MCP 工具给大模型用的团队;被“MCP Server 地址写死在配置里、一变更就要重启、多实例没法负载均衡”这些问题折磨过的开发者;还有在搞智能体应用,需要一套可观测、可管理、可动态调整的工具调度层的同学。整个方案的核心就一句话:让 MCP Server 变成注册中心里的一个普通微服务,让 SAA 通过 Nacos 做服务发现,拿到 MCP Server 的真实地址再去连接。
先把实操过程中最重要的几个组件跑通,后面再逐个说细节。
1. 先搞清楚 MCP 和分布式 MCP 到底在解决什么问题
1.1 MCP 的本质:给 AI 加“外接设备”
MCP(Model Context Protocol,模型上下文协议)在去年火起来之后,被类比成“AI 界的 USB-C 接口”。这个类比挺准确:以前每个 AI 应用要接一个工具,就得单独写一套调用逻辑,工具方也要为每个 AI 应用适配不同的接口格式;有了 MCP 之后,模型应用侧只需要实现 MCP Client,工具方只需要实现 MCP Server,两边只要遵守同一套协议,就能即插即用。
MCP 协议规定了几种核心通信能力:
- 工具发现(Tools/List):客户端先问服务端“你有哪些工具”,拿到工具名、描述、参数结构。
- 工具调用(Tools/Call):客户端按协议格式传参,服务端执行并返回结果。
- 资源与提示词(Resources/Prompts):支持静态资源读取和提示词模板管理,不过实际项目里用得最多的还是工具调用。
所以单看协议本身,MCP 是一个偏“点对点”的设计:一个 Client 连一个 Server。这在单机演示、本地开发环境完全够用,但一旦进入生产,问题就全暴露出来了。
1.2 单机 MCP 的三座大山
第一座大山是地址写死。我见过好几个项目的 MCP Server 地址直接配在 application.yml 里:
mcp: server-url: http://127.0.0.1:8083/mcp本地跑没问题,但上了 K8s 之后 Pod IP 天天变,每变一次就要改配置、重启服务。第二天模型突然说工具不可用,查了半天发现是半夜发版把 IP 换了。
第二座大山是单点故障。MCP Server 挂了,整个 Agent 能力就瘫了。没有健康检查、没有摘除机制,客户端还在傻傻地往死掉的地址上发请求。
第三座大山是工具管理失控。一个 MCP Server 里注册了十几个工具,其中有个工具是“查数据库”,线上跑得好好的,某天突然被模型连续调了 200 次,直接打爆了业务库。你没有一个地方能动态地禁用这个工具,只能改代码、重新部署。
分布式 MCP 要做的事,本质上就是把这三大问题交给基础设施层来解决:服务注册与发现解决地址漂移,健康检查与摘除解决单点故障,配置中心解决工具动态开关。
1.3 分布式 MCP 的架构长什么样
我们最终实现的架构是这样一条链路:
模型应用(SAA Client) -> Nacos 注册中心 -> Nacos 配置中心 ↑ 服务发现 / 动态刷新 MCP Server 实例 A MCP Server 实例 BSAA 的 Agent 层通过 Nacos 发现可用的 MCP Server 实例,再通过配置中心读取“哪些工具当前可用”的策略,最后通过 MCP Client 远程调用工具。服务端每个 MCP Server 启动时把自己注册到 Nacos,并携带协议类型、端口等元数据;下线时自动摘除。
这套结构跑起来之后,你会发现 MCP 应用终于能像普通微服务一样被管理了:扩容就是多起几个实例,发版就是摘流量再滚动更新,故障会自动转移。模型应用完全感知不到底层变化。
2. 方案选型:为什么是 SAA + Nacos,而不是自己写路由
2.1 MCP 天生不管服务发现,所以必须引入基础设施
MCP 协议本身没有服务发现的概念,也没有注册中心的规范。客户端拿到一个 URI 就能连,但“这个 URI 从哪里来”是应用层的事。你可以写死,可以建一个配置表,也可以用一个注册中心统一管理。
在 Spring 生态里做这事,最顺理成章的选择就是 Nacos。它不是专门为 MCP 设计的,但它有两个能力恰好是 MCP 分布式化急需的:
- 服务发现:MCP Server 作为微服务注册到 Nacos,客户端通过服务名获取实例列表。
- 配置中心:MCP 的工具开关、超时参数、灰度策略都可以放在 Nacos 配置里动态调整。
哪怕不用 SAA,单纯用原生 MCP Java SDK,只要你自己写一段 DiscoveryClient 代码也能接 Nacos。但 SAA 的价值在于把很多脏活累活提前封装好了。
2.2 SAA 到底帮我们做了什么
Spring AI Alibaba 是阿里在 Spring AI 基础上的增强实现,它对 MCP 的支持分几层:
- MCP Client 封装:SAA 内置了 MCP Client 的自动配置,你只需要提供 Server 的 URI,它就能自动拉取工具并注册成 Spring Bean,模型的 Tool Calling 可以直接调用。
- Agent 编排:SAA 提供了一个简单的 Agent 抽象,支持把 MCP 工具直接注入到 ChatClient 的 Tool 列表中。
- 与 Nacos/Spring Cloud Alibaba 的整合:这是重点。SAA 本身不绑定 Nacos,但因为它跑在 Spring Boot 环境里,天然可以和 Spring Cloud Alibaba 的 DiscoveryClient、Config 组件无缝协作。
所以选 SAA + Nacos,不是因为它俩做了深度绑定,而是因为它们都是 Spring 生态里的“标准件”,组合成本最低。
2.3 三种方案对比,直观看到差距
我自己试过三种 MCP Server 的发现方案,放在一起对比最直观:
| 方案 | 改地址是否需重启 | 多实例负载均衡 | 故障自动摘除 | 动态开关工具 | 落地成本 |
|---|---|---|---|---|---|
| 硬编码 URI | 需要 | 不支持 | 不支持 | 不支持 | 最低 |
| 自建数据库配置表 + 定时刷新 | 免重启,但轮询延迟高 | 能,但自己写算法 | 需自己实现 | 需自己实现 | 中 |
| Nacos 注册中心 + 配置中心 | 免重启,实时推送 | 原生支持 | 原生支持 | 原生支持 | 低 |
很明显,Nacos 是收益最高、成本最低的方案,所以这个训练营我直接选它。
3. 环境准备:版本选择与核心依赖
3.1 版本选型,踩过的版本坑
我建议以下组合,都是测试过能稳定跑的:
- JDK 17 以上(21 也完全没问题)
- Spring Boot 3.4.x
- Spring AI Alibaba 1.0.0-M7.1 或更新稳定版
- Spring Cloud Alibaba 2023.0.3.2
- Nacos 2.5.x(如果本机资源有限,2.4.x 也可以,ARM 架构建议用 2.5.0 以上,启动更顺)
- MySQL 8.4.11 作为 Nacos 配置持久化存储(单机演示用 Nacos 内置 Derby 也行,但生产别这么干)
为什么强调版本?因为 Spring Cloud Alibaba 和 Spring Boot 的版本矩阵一旦对不上,启动直接给你报一堆 NoSuchMethodError,而且是那种极具迷惑性的错误,不是在NacosServiceManager里炸,就是在Ribbon相关类里炸。所以照着官方版本矩阵选,别自己创作组合。
3.2 Maven 依赖怎么加
如果你是标准 Spring Boot 工程,依赖大概长这样:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2023.0.3.2</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2023.0.3.2</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>1.0.0-M7.1</version> </dependency>配置里面比较关键的是 namespace 一定要显式指定。很多人不写 namespace,那默认就是 public,跨环境一多起来到处都是隐性问题。我习惯每个环境一个 namespace,生产用独立 namespace,并且必须开启鉴权,不然任何人只要能访问 Nacos 控制台,就能把你们的注册中心当通讯录翻个底朝天。
Nacos 开启鉴权的方式也顺便说一下。在 Nacos 的application.properties里加上:
nacos.core.auth.enabled=true nacos.core.auth.plugin.nacos.token.secret.key=你的自定义Base64密钥 nacos.core.auth.system.type=nacos nacos.core.auth.server.identity.key=serverIdentity nacos.core.auth.server.identity.value=security然后客户端配置里要带上用户名密码:
spring: cloud: nacos: discovery: username: nacos password: nacos123 config: username: nacos password: nacos123注意:Nacos 的鉴权不是默认开启的,生产环境一定要主动打开。之前不少团队出过“namespaces 未授权访问”这类告警,本质上就是鉴权没开、任意人可读配置。要当回事,别给安全测试留把柄。
3.3 准备一个 MCP Server 作为实验对象
为了演示完整链路,我们自己写一个简单的 MCP Server,提供两个工具:一个“echo”用来验证连通性,一个“查询订单状态”模拟业务工具。
用 Java MCP SDK 写起来很直接,Spring Boot 工程里定义一个工具类:
@Component public class OrderTools { @Tool(description = "根据订单号查询订单状态") public String getOrderStatus(@ToolParam(description = "订单号,例如 O2025001") String orderId) { if ("O2025001".equals(orderId)) { return "订单已发货,物流单号 SF123456"; } return "未找到订单"; } @Tool(description = "回显文本,用于连通性测试") public String echo(@ToolParam(description = "任意文本") String text) { return "echo: " + text; } }然后通过 MCP Server 的自动配置暴露成 MCP 服务端点。在 Spring AI 体系里,@Tool注解的方法会被自动扫描并注册到 MCP Server 的工具列表里。
这个 MCP Server 就是一个普通的 Spring Boot 服务,我们给它起名叫mcp-server-order,端口 8083。下一步就是让它注册进 Nacos,成为一个可被发现的服务。
4. 把 MCP Server 变成“注册中心里的一个服务”
4.1 改造注册配置
在mcp-server-order工程里加入 Nacos Discovery 依赖,然后在application.yml里配置:
spring: application: name: mcp-server-order cloud: nacos: discovery: server-addr: ${NACOS_ADDR:127.0.0.1:8848} namespace: ${NACOS_NAMESPACE:mcp-prod} group: MCP_GROUP metadata: mcp-protocol: streamable-http mcp-transport: http这里有两个细节值得展开。
第一,服务名就是 MCP Server 的逻辑标识。SAA 客户端将来只认mcp-server-order这个服务名,不关心具体 IP 和端口。所以服务名的命名规范要提前定好,建议用mcp-server-业务域的格式,一眼能看懂。
第二,metadata 元数据非常关键。MCP Server 实例在 Nacos 里注册时,默认只带 host 和 port,这两个信息还不足以让客户端确认“这个实例是不是 MCP Server、走哪种协议”。所以我特意在 metadata 里放了mcp-protocol: streamable-http,客户端在拉取实例时可以通过这个元数据做过滤,避免拿到一个普通微服务的实例当成 MCP Server 去调用。
4.2 实例类型:用临时实例还是持久实例
Nacos 区分临时实例和持久实例:
- 临时实例:客户端主动上报心跳,心跳断了 Nacos 自动摘除。
- 持久实例:服务端记录实例信息,不依赖心跳,下线需要主动调用注销接口。
MCP Server 这种无状态服务,强烈建议用临时实例,默认就是临时实例。这样的话,假如 MCP Server 所在的机器突然宕机、进程被 kill -9,Nacos 会在几十秒内自动把它的实例摘掉,SAA 客户端就不会再把请求路由到一个死掉的节点上。如果你误配成了持久实例,宕机之后 Nacos 列表里还挂着这个实例,请求会一直超时,而且特别难排查。
4.3 验证注册结果
启动mcp-server-order后,打开 Nacos 控制台,在“服务管理 -> 服务列表”里应该能看到mcp-server-order,详情里能看到两个实例信息以及我们塞进去的 metadata。
这一步验证通过后,MCP Server 的“服务化改造”就完成了。接下来进入 SAA 客户端的配置。
5. SAA 端配置:通过 Nacos 服务发现连接 MCP Server
5.1 客户端需要做什么
SAA 客户端要连接 MCP Server,核心逻辑其实就三步:
- 从 Nacos 拿到
mcp-server-order的实例列表。 - 选一个可用实例(加上简单的负载均衡策略,比如轮询或随机)。
- 拼出 MCP 的 URI,交给 SAA 的 MCP Client 去做协议握手。
第一步和第二步是自己写的业务逻辑,第三步是 SAA 的能力。
5.2 代码实现:从服务发现到 MCP Client
先注入 Spring Cloud 的 DiscoveryClient:
@Service public class McpServerDiscovery { @Autowired private DiscoveryClient discoveryClient; public String resolveMcpServerUrl(String serviceName) { List<ServiceInstance> instances = discoveryClient.getInstances(serviceName); if (instances == null || instances.isEmpty()) { throw new RuntimeException("Nacos 中没有发现 MCP 服务: " + serviceName); } // 从所有实例中过滤出真正带 MCP 元数据的 List<ServiceInstance> mcpInstances = instances.stream() .filter(i -> "streamable-http".equals(i.getMetadata().get("mcp-protocol"))) .collect(Collectors.toList()); if (mcpInstances.isEmpty()) { throw new RuntimeException("服务 " + serviceName + " 存在,但没有 MCP 协议元数据"); } ServiceInstance instance = mcpInstances.get(new Random().nextInt(mcpInstances.size())); return "http://" + instance.getHost() + ":" + instance.getPort() + "/mcp"; } }拿到 URI 之后,交给 SAA 的 MCP Client 构建器:
@Configuration public class McpToolsConfig { @Bean public McpSyncClient mcpOrderClient(McpServerDiscovery discovery) { String mcpUri = discovery.resolveMcpServerUrl("mcp-server-order"); return McpClient.using(new HttpClientTransport(URI.create(mcpUri))) .requestTimeout(Duration.ofSeconds(30)) .sync(); } }然后把这个 MCP Client 的工具列表注入到 ChatClient 里,让模型能调用这些工具:
List<Tool> mcpTools = mcpOrderClient.listTools(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(mcpTools) .build();到这里,模型问“订单 O2025001 什么状态”,Agent 就会自动去 Nacos 找到mcp-server-order,调用 MCP 工具拿结果。
5.3 负载均衡那点事
上面的代码里我用了一个最简单的随机选择。如果你有更复杂的策略,比如按权重、按机房优先、按版本灰度,可以引入 Spring Cloud LoadBalancer,在ServiceInstanceListSupplier层做定制。MCP Server 实例的权重可以提前在 Nacos 控制台配好,灰度发布时给新版本实例设一个低权重,让少量流量先验证。
不过提醒一句:虽然 MCP 调用是走 HTTP 的,但不要直接拿 Spring Cloud OpenFeign 或者 RestTemplate 去调 MCP Server 的 HTTP 接口,除非你想自己把 MCP 协议那套 JSON-RPC 编解码重新造一遍。用 SAA 内置的McpClient才是省力气的做法。
5.4 首次加载慢的问题
MCP Client 创建时会调用listTools()拉取工具列表,如果工具数量多、或者 Server 响应慢,这个初始化过程可能长达几秒。我建议把 MCP Client 的创建挪到应用启动完成之后异步去做,或者给listTools()设置一个合理的超时时间(30 秒),别用默认值一直傻等。
6. MCP 工具的动态开关:Nacos 配置中心 + 热更新
6.1 为什么需要工具开关
分布式 MCP 方案如果只能解决“服务发现”,那价值还减半了。真正让运维省心的是“配置中心联动”:某一款工具突然出问题了,或者某个工具被模型过度调用要临时熔断,不需要改代码、不需要重启应用,在 Nacos 控制台改一行配置,全链路立刻生效。
举个例子:mcp-server-order提供了两个工具,echo 是测试用的,生产上其实不希望模型调用它。那就在 Nacos 配置中心里配置一个禁用名单。
6.2 在 Nacos 配置中心里建配置
新建一个 Data ID:mcp-tool-switch.yaml,Group:MCP_GROUP,内容:
mcp: tool: disabled: - echoSAA 客户端引入 Nacos Config 依赖,通过@NacosValue或者@RefreshScope监听这个配置项。
6.3 动态刷新工具列表
我在 SAA 侧做了一个简单的 MCP 工具管理器,监听配置变化后重新构建工具集合:
@Component @RefreshScope public class McpToolManager { @Value("${mcp.tool.disabled:[]}") private List<String> disabledToolNames; private List<Tool> availableTools; public List<Tool> getAvailableTools(McpSyncClient mcpClient) { if (availableTools == null) { refreshTools(mcpClient); } return availableTools; } public void refreshTools(McpSyncClient mcpClient) { List<Tool> allTools = mcpClient.listTools(); availableTools = allTools.stream() .filter(tool -> !disabledToolNames.contains(tool.name())) .collect(Collectors.toList()); } }然后在 Nacos 控制台把disabled列表改一下,比如把getOrderStatus也加进去,配置中心推送之后,SAA 客户端下一次获取工具列表时,就会自动把被禁用的工具过滤掉。模型再问订单状态,就会收到“没有可用工具”的响应,而不会报错。
这个开关的真正价值在于:你需要的是一个快速止血手段,而不是一个完整的工具治理平台。当模型开始乱调工具的时候,你能在两秒内把它关掉,比什么都重要。
6.4 动态刷新和缓存刷新的权衡
配置中心的推送是实时的,但工具列表的刷新不一定要实时。因为listTools()是一个相对重的操作,每次配置变化都立刻重新拉取,在高并发场景下没必要。我实际的做法是:配置推送后只记录一个“待刷新”标记,然后延迟 5 秒再刷新工具列表,并且加了单线程锁,避免多个请求同时触发重复刷新。
这里有一个实用性很强的技巧:用一个本地缓存 + 一个版本号,配置中心只更新版本号,真实的工具列表在版本号变化后的下一个请求才去懒加载,这样既保证了变化能及时生效,又不会在每一次模型请求时都去远程拉工具列表。
7. 常见问题速查表与踩坑实录
整套方案跑通的过程中,我踩了不少坑,很多坑你只有在线上真实环境才会遇到。我整理成一个速查表,方便以后排查。
| 现象 | 原因 | 排查方法 |
|---|---|---|
| SAA 客户端启动时找不到 MCP 服务 | namespace 不一致 | 检查 MCP Server 和 SAA 端配置的 namespace 是否相同,不要盯着服务名 |
| MCP Server 注册成功但连不上 | 注册的是内网 IP,客户端跨网段访问不到 | 看 Nacos 实例详情里的 IP,如果是 127.0.0.1 或内网段,需要配置spring.cloud.nacos.discovery.ip指定可访问地址 |
| 每次调用工具都超时 30 秒以上 | MCP Client 默认超时时间过长 | 显式设置requestTimeout,比如 10 秒,快速失败比慢成功更有价值 |
| 工具列表一直加载不出来 | MCP Server 端listTools中有插件或动态加载逻辑卡住 | 直接用浏览器访问 MCP Server 的/mcp端点,手动发一个tools/listJSON-RPC 请求看返回 |
| Nacos 鉴权开启后客户端疯狂报错 | 客户端版本太低,或者密码/Token 配错 | 先关鉴权排除,确认客户端依赖版本和 Nacos 2.5.x 匹配 |
| 多个 MCP Server 服务名一样,但工具重复注册 | 不同分组下同名服务导致客户端拉到了跨组实例 | 检查 group 配置,SAA 端和 MCP Server 端spring.cloud.nacos.discovery.group必须写同一个值 |
| 模型老是调用一个已经不存在的工具 | 工具缓存没有失效 | 检查本地缓存逻辑,工具列表要按版本号失效,不能一次加载用到天荒地老 |
除了表格里的,再说两个我感受最深的点。
第一个坑:Nacos 实例详情里显示的 IP 是网卡 IP,不是你想暴露的 IP。在多网卡机器上(最常见的是本机装了 Docker,虚拟网卡一堆),MCP Server 注册时可能会选错网卡 IP,导致 SAA 客户端拿到一个根本不通的地址。解决办法是显式指定:
spring: cloud: nacos: discovery: ip: 192.168.1.100第二个坑:Nacos 控制台能看见服务,但服务详情里 metadata 是空的。这通常是因为你用的是老版本 Spring Cloud Alibaba,元数据配置不生效。升级到 2023.0.3.2 版本以上就没问题。
第三个坑:MCP Server 的 port 和 actuator 端口搞混。如果你的服务同时暴露了 actuator 和 MCP 端点,注册到 Nacos 的是 Spring Boot 的 web 端口。如果 MCP 端点不是跑在同一个 server 上(比如单独开了 netty 端口),你必须在 metadata 里携带mcp-port,客户端拼接 URI 时优先用mcp-port而不是port。我用这个方式支持过同一个服务里既有普通 REST API 又有 MCP 端点的情况。
8. 从单机到分布式,还应该考虑什么
8.1 不要把 AI 工具直接裸奔到生产环境
当你的 MCP Server 开始暴露“查询数据库”“执行订单操作”这类高权限工具时,你至少要做几件事:
- 权限控制:MCP 协议本身不提供鉴权,你需要在 MCP Server 前加一层拦截,至少要校验来源 IP 或 Token。
- 审计日志:记录哪个模型调用过哪个工具、传了什么参数、返回了什么结果。出事故的时候没有日志,你连甩锅的资格都没有。
- 限流降级:针对单个 MCP 工具做频控。我见过模型逻辑写错导致死循环,把一个信息查询工具每秒调用 50 次,直接把上游库的连接池打满。MCP Server 里必须做限流。
这些不是 SAA 或者 Nacos 能替你解决的,属于应用层的基本功。
8.2 MCP 的多环境管理
Nacos 的命名空间天然适合做环境隔离。我建议这样规划:
mcp-dev:开发环境,对应一个 Nacos namespacemcp-test:测试环境,独立 namespacemcp-prod:生产环境,独立 namespace,且开启鉴权
每个环境里注册的 MCP Server 实例互不可见,SAA 客户端只需通过spring.profiles.active切换对应 namespace,一套代码全环境通用。
8.3 版本管理与灰度发布
MCP 工具一旦被模型依赖,改工具的参数结构就是很大的兼容性问题——模型在训练时不会因为你改了一个字段名就自动调整。所以 MCP Server 的发布要遵循严格的版本管理:
- 大版本变化(工具参数不兼容)时,服务名用
mcp-server-order-v2,旧服务保留一段时间。 - 小版本变化(文档描述调整、内部实现优化)时,用 Nacos 的权重控制灰度。先给新版本实例配 10% 权重,跑一段时间没问题再提到 100%。
这个思路本质上就是微服务的灰度发布,只是因为 MCP 工具的参数变动会影响模型行为,所以要更谨慎。
9. 这轮实操的一些个人体会
配置中心和注册中心这套组合,其实很多团队早就在用了,但很少人把它和 MCP 结合起来。我做这个训练营最大的感受是:MCP 协议本身不复杂,分布式 MCP 的复杂度全都在“连接管理”上。地址怎么发现、实例怎么摘除、工具怎么开关、权限怎么控制,这些才是线上稳定性的关键。
最后再分享一个小技巧,是我后来无意中发现特别好用的:Nacos 的临时实例天然带健康检查,所以你不需要给 MCP Server 额外做一套心跳检测,它挂了以后自动从注册中心消失。但要注意,MCP Client 建立的长连接不会自动感知服务端下线,你必须要在 SAA 客户端做一个定期重建 McpClient 的机制(比如每 5 分钟检查一次实例列表,如果实例变了就重建连接),否则注册中心摘除了故障节点,客户端还握着旧的连接不放。这个细节我一开始没注意,压测时总是出现“第一个请求成功,挂了节点后后面请求全部超时”的诡异现象,加了重建机制后整个世界安静了。