最近在做 AI 应用集成的时候,MCP 这个词几乎绕不开。它全称 Model Context Protocol,是一套开放协议,核心目的是让 AI 应用用标准化的方式调用外部工具和数据源。而 Spring AI 的 MCP Server 能力,正好解决了 Java 生态里接入 MCP 这个“最后一公里”的问题。这篇内容我基于自己的实际工程经验,从思路、配置、代码到排错,完整拆一遍 Spring AI MCP Server 的开发流程,希望能帮你少踩几个坑。
1. MCP Server 整体思路拆解:它到底解决了什么问题
1.1 没有 MCP 之前,工具接入是什么样子
在 MCP 出现之前,想让大模型调用外部系统,基本是两条路:要么在 Prompt 里硬塞工具描述,让模型输出结构化 JSON,再自己写解析逻辑去路由;要么给每个平台单独封装一套 function calling 的适配层,换一个前端、换一个协议就得重来。这样做最头疼的问题不是写代码,而是“契约”没法统一——工具怎么描述、参数怎么传、错误怎么返回,每家都有自己的写法。
MCP 把这件事抽象成了三个角色:Host 是 AI 应用本身(比如桌面客户端、IDE、后端服务),Client 负责和 Server 通信,Server 负责把能力暴露出来。协议层面的原语也固定下来,最常用的就是 tools(工具)、resources(资源)、prompts(提示词模板)。AI 客户端启动后,通过 tools/list 拉取能力清单,用户表达意图后,客户端通过 tools/call 调用对应能力。
1.2 Spring AI 在这里扮演了什么角色
Spring AI 是 Spring 生态里的 AI 集成框架,它对 MCP 做了比较彻底的支持:既可以把下游能力封装成 MCP Server 对外提供,也可以作为 Client 去连接别人家的 Server。对我们 Java 开发者来说,最大的好处是不用自己维护底层传输协议和 JSON-RPC 细节,只要关注业务方法怎么写。
我之所以在项目里选 Spring AI 而不是直接用官方 MCP Java SDK,核心原因有三个:第一,Spring AI 对 @Tool 注解的封装非常顺手,一个方法加个注解就能变成一个可被 AI 调用的工具;第二,默认集成了工具描述、参数 Schema 生成这类干活容易忽略的细节;第三,和 Spring Boot 的配置体系天然打通,后续接模型、接数据库、接各种中间件都不用再粘一层代码。
1.3 一条完整的调用链路长什么样
拿我最近做的一个订单查询助手举例:用户在小程序里问“最近三天有多少笔待发货订单”,请求先到 Spring AI 应用,模型判断需要查询订单系统,于是走 MCP Client 发起 tools/call;请求通过 SSE 通道到达 MCP Server,Server 定位到对应的 @Tool 方法,调用真实业务接口查询数据库,把结果返回给模型;模型组织成自然语言回给用户。
这条链路里,MCP Server 是要提前启动并注册的一个独立服务。理解好这个架构,后面写代码的时候就不容易绕晕。
2. 工程初始化与依赖配置:先搭出能跑的最小骨架
2.1 项目骨架与 Spring Boot 版本选择
先用 Spring Initializr 创建一个普通 Spring Boot 项目。版本选型上,建议用 Spring Boot 3.4.x 或 3.5.x,对应的 Spring AI 版本选择 1.0.0 之后的稳定版。早期我用过 0.8.x,那时候 MCP 还处于快速迭代期,接口变化很大,升级成本高,现在到了 1.0 之后,API 稳定多了,可以放心用。
注意一个细节:Spring AI 的 MCP Server 有两种 IO 模型,一种是基于 WebMVC 的同步实现,一种是基于 WebFlux 的响应式实现。如果项目里没有特殊要求,优先选 webmvc 版本,排查问题、打印日志都更直观。只有当你确定要面对高并发流式场景,再考虑 webflux。
2.2 Maven 依赖配置与版本号避坑
依赖的坑主要体现在版本对应关系上。Spring AI 1.0.0 对应 MCP Java SDK 1.0.0,如果你混用 Spring AI 0.9.x 和 MCP SDK 1.0.x,大概率会在启动时遇到 NoSuchMethodError 这类问题。这里给出一组我实测可用的依赖配置:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.5</version> <relativePath/> </parent> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MCP Server 同步实现 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> </dependency> </dependencies>有个细节需要特别提醒:Spring AI 的 BOM 主要是管 Spring AI 自己模块的版本,但 MCP SDK 的传递依赖版本不一定总能对齐,如果本地启动时报了 mcp 包内部类的编译错误,优先检查 Maven 依赖树,看io.modelcontextprotocol.sdk:mcp实际被解析到哪个版本。
另外,如果你需要把国内的智谱 AI、通义千问等模型接入 Spring AI 客户端,又不确定具体依赖坐标,可以在 search.maven.org 上直接搜spring-ai-alibaba或智谱对应的 starter,一般会明确标注支持的最低 Spring AI 版本。这里提个经验:MCP Server 本身和模型是哪家的没有关系,它只负责通过标准协议暴露工具,模型接入属于另一个模块。
2.3 配置文件里需要预留的关键参数
application.yml 中 MCP Server 的配置比较少,核心是服务标识:
server: port: 8081 spring: application: name: mcp-order-server ai: mcp: server: name: order-tool-server version: 1.0.0这个 name 和 version 会体现在 MCP 初始化握手的 serverInfo 里,客户端拿到后可以用来做版本判断。除此之外,如果你的工具方法要调用外部 API,注意预留好超时和连接池配置,不然工具本身被模型调用的时候,一个慢接口能把整条链路拖死。
3. 核心代码实现:把一个 Spring Bean 变成 AI 可调用的工具
3.1 注册 MCP Server 端点
当你在 pom 里引入了spring-ai-starter-mcp-server-webmvc之后,Spring Boot 会自动配置一个 SSE 端点,默认路径是/sse,新版规范里客户端会先 POST 到这个地址做握手,再通过返回的 event stream 建立长连接。
如果想自定义路径,可以通过配置项调整:
spring: ai: mcp: server: endpoint: /mcp/sse这时客户端连接地址就变成了http://localhost:8081/mcp/sse。我不太建议随意改路径,除非你有统一的网关前缀控制需求。保持默认,能减少联调时的认知成本。
3.2 用 @Tool 注解暴露业务能力
MCP Server 的核心代码非常简单,就是你平时写的 Service 方法,加个注解就行。拿订单查询工具举例:
@Component public class OrderToolService { private final OrderService orderService; public OrderToolService(OrderService orderService) { this.orderService = orderService; } @Tool(description = "根据订单状态查询订单列表,status 参数:PENDING-待支付,PAID-已支付,SHIPPED-已发货") public List<OrderInfo> queryOrders(@ToolParam(description = "订单状态") String status) { return orderService.listByStatus(status); } @Tool(description = "根据订单号查询订单详情") public OrderInfo getOrderDetail(@ToolParam(description = "订单编号") String orderId) { return orderService.getByOrderId(orderId); } }这里最考验功底的是 description 的写法。模型不像人一样能读代码,它只能靠 description 判断一个工具在什么场景下可用,如果描述太模糊,比如只写“查询订单”,模型根本不知道这个方法是按状态查还是按单号查,调用就会频繁失误。最好把参数的取值范围也写进去,像上面“PENDING-待支付”这种写法,实测能让模型准确率高不少。
3.3 工具的自动发现与注册原理
Spring AI 在启动的时候,会扫描容器里所有带有 @Tool 注解的方法,为每个方法生成一个 ToolCallback,然后组装成 tools/list 接口的返回数据。这里有一个隐性问题:被扫描的 Bean 必须能进入 Spring 容器,也就是你放 @Tool 方法的类要么被 @Component 标注,要么能被组件扫描覆盖到。
一个常见的坑是:新手把工具类放在启动类子包之外,导致扫描不到,启动日志里 MCP server 正常起来了,但客户端 tools/list 里永远只有空数组。排查的方法很简单,启动时看日志里有没有这样一行,类似Registered tool callbacks: [queryOrders, getOrderDetail],没有就说明扫描有问题。
3.4 参数类型与 JSON Schema 的映射
MCP 协议里工具参数是以 JSON Schema 形式暴露的,Spring AI 在生成 Schema 时会依赖 Jackson 的序列化规则。这就意味着你的参数和返回类型最好满足两个原则:参数尽量用 String、Integer、Boolean 这些简单类型;返回对象尽量用扁平结构的 DTO。
复杂的嵌套对象不是不能用,而是容易出问题。比如返回一个带泛型的 Result ,Jackson 在生成工具描述时可能把 T 解析成 Object,模型就无法理解里面该包含什么字段,调用出来的结果自然不对。踩过这个坑之后,我的原则是:工具方法返回外部类型一律先转成格式固定的字符串或简单 DTO,反正最终模型需要的也是可读文本,不追求消灭一切嵌套,但要让结构尽量简单。
如果是并发量比较大的场景,还可以给工具方法做缓存。MCP 调用粒度比普通 HTTP 接口粗,一次调用背后可能是一个复杂报表的查询,影响面大,值得在方法内做本地缓存或者 Redis 缓存。
4. 客户端调用与全链路联调:确保工具真的能被模型用起来
4.1 用 MCP Inspector 快速验证服务端
服务端代码写完之后,第一件事不是去接模型,而是先用官方提供的调试工具验证 MCP Server 是否符合协议规范。MCP Inspector 的启动方式是:
npx @modelcontextprotocol/inspector启动后打开浏览器工具界面,在 Server 配置里填写 transport type 为 SSE,URL 填http://localhost:8081/mcp/sse,连接成功后,Inspector 会自动发起 initialize 和 tools/list 请求,你就能直观看到服务端暴露了哪些工具,以及每个工具的 JSON Schema 长什么样。
这一步对排查问题非常有用。如果你在界面里看到了 queryOrders、getOrderDetail 这些工具,说明服务端注册没问题,接下来就可以放心接模型了。如果看不到工具,优先查日志,而不是去改客户端代码。
4.2 在 Spring AI 客户端中调用远程 MCP Server
如果你的 AI 应用本身也是 Spring Boot 项目,接入 MCP Server 会方便很多。在客户端项目里引入依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client-webmvc</artifactId> </dependency>然后在配置文件里指定要连接的 MCP Server:
spring: ai: mcp: client: connections: order-server: url: http://localhost:8081/mcp/sseSpring AI 启动时就会自动连接远程 MCP Server,把对端工具拉取到本地。接下来要做的,是把这些工具合并到 ChatClient 的 ToolCallback 列表里:
@Service public class ChatService { private final ChatClient chatClient; public ChatService(ToolCallbacksProvider provider, ChatClient.Builder builder) { // 从 MCP 客户端拉取到的工具 List<ToolCallback> mcpTools = provider.getToolCallbacks(); this.chatClient = builder .defaultTools(mcpTools.toArray(new ToolCallback[0])) .build(); } }这里有个容易忽略的点:如果同时配置了本地 @Tool 方法和远程 MCP 工具,要注意工具名冲突的问题。同名工具会后者覆盖前者,而且不会报错,排查起来很隐蔽。我的习惯是在命名上做约定,远程工具统一用模块名做前缀。
4.3 工具描述对模型决策的影响
联调的时候你会慢慢发现,模型调不调用某个工具,很大程度上取决于你写 description 的方式。同样是查询天气,如果你写“查询天气”,模型可能不知道怎么传城市名;如果你写“根据城市名称查询实时天气和未来三天预报,城市名称支持中文模糊匹配”,模型的判断就准确多了。
我建议在一个工具类完成后,专门花十分钟把 description 过一遍,逐字检查有没有歧义。这个投入产出比非常高,因为在真正跑的环节,模型一旦反复调用错工具,浪费的不只是时间,还有 Token 成本。
5. 常见问题与排查技巧实录
5.1 工具列表为空的三种可能
客户端连接成功但 tools/list 返回空,是我遇到最多的问题,主要分三类:一是服务端类没有被组件扫描到,解决方法是检查包路径;二是 Spring AI 版本和 MCP SDK 版本不对齐,导致工具注册过程抛异常被吞掉,升级到匹配版本就行;三是项目里存在多个 MCP Server 依赖,自动配置被覆盖。实际排查时,先看服务端启动日志有没有注册工具回调的提示,再决定往哪边找。
5.2 SSE 断连与超时控制
新版 MCP 的 Streamable HTTP 传输,客户端和服务端之间是长连接,受网络环境的影响很明显。如果你部署在公网环境,建议在网关层给 SSE 接口关掉缓冲,并在 Nginx 配置里调长 proxy_read_timeout,否则默认的 60 秒可能不够模型长时间思考后再发起调用。
另外,应用容器这边也要注意,Tomcat 的异步请求超时时间如果设置得太短,连接会被容器主动断开。我一般把server.tomcat.async-timeout设置为 300000 毫秒,测试阶段足够用了。
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 客户端连接 404 | SSE 端点路径不对 | 确认服务端 spring.ai.mcp.server.endpoint 配置 |
| 初始化握手失败 | MCP 协议版本不匹配 | 让 Spring AI 和 MCP SDK 版本保持同一版本线 |
| tools/list 返回空 | 工具类未被扫描注册 | 检查组件扫描范围与启动日志 |
| 工具调用超时 | 服务端方法执行超过容器超时 | 调大 async-timeout,优化工具方法耗时 |
| 中文参数乱码 | 客户端没有正确编码 | 确认 HTTP 请求头包含 UTF-8 编码 |
5.3 日志定位的小技巧
排查 MCP 问题时,强烈建议把 Spring AI 的日志级别打开:
logging: level: org.springframework.ai.mcp: DEBUG org.springframework.ai.tool: DEBUG打开之后,你能在日志里看到完整的 tools/list 请求、tools/call 请求,以及参数的一个真实结构。我之前排查一个“工具方法始终被调用但参数永远是默认值”的问题,就是靠 DEBUG 日志发现模型传进来的 JSON 字段名和 Java 方法参数名不一致导致的,这种问题在了解协议层数据之前很难定位。
5.4 生产环境部署要额外注意的事
MCP Server 作为一个独立服务部署时,要注意鉴权。因为是长连接通道,如果直接裸奔公网,任何人都能调用你的工具方法。目前 Spring AI 自带的能力比较有限,通常做法是在前面加一层网关,或者利用 Spring Security 对 SSE 端点做认证配置。
同时,工具方法内部一定要做权限校验,因为调用方是 AI,不是指定用户,上下文里可能没有身份信息。我的做法是在工具方法第一个参数里注入用户 ID,并忽略返回值中不该暴露的敏感字段。
最后再分享一个小经验
工具方法的设计和普通 Controller 非常不一样。写 Controller 时接口参数由前端决定,而 MCP 工具的参数由模型理解后生成,所以每个参数的描述都要经得住“一个不太聪明的人”来读。我在实际项目中,每次写完一个工具都会先打开 Inspector,自己手动调用一遍,检查返回格式是不是稳定的、可读的。MCP 的生态还在快速演进,工具注册、传输层实现这些细节未来可能会变,但把工具描述清楚、把边界考虑清楚这个核心原则,什么时候都不过时。