1. 为什么 Java 后端需要自己写一个 MCP-Server
MCP-Server 是什么?一句话解释:它把你的业务能力(查数据库、调内部接口、算指标)包装成 AI 客户端能直接调用的标准工具。适合谁?适合手里已经有一堆 Spring Boot 服务、想让 Claude Code、Cursor、Cline 这类客户端直接复用这些能力的 Java 后端。
我试过把公司内部的订单查询、库存校验、日志检索三个接口做成 MCP 工具,客户端侧只改一个 JSON 配置就能用,比给每个 AI 客户端单独写插件省事得多。核心原因在于 MCP(Model Context Protocol)把「工具描述 + 参数结构 + 调用结果」抽象成了统一协议,服务端只管注册工具,客户端只管发现和调用。
Spring AI 从 1.0.0-M6 开始提供了spring-ai-mcp-server-webmvc-spring-boot-starter,让你用注解就能把普通 Bean 方法暴露成 MCP 工具。整个链路是这样的:客户端通过 SSE 长连接拿到工具列表,模型决定调用哪个工具后,客户端把 JSON-RPC 请求 POST 回服务端,服务端执行方法并返回结果。
这篇要解决三个具体问题:依赖怎么配不踩版本坑、工具怎么注册才能被正确发现、客户端怎么验证工具列表和返回结果符合预期。全程用可复制的 pom、yml、Java 代码,最后给一次真实的调用验证。
需要说明的是,MCP-Server 本身只负责「暴露工具」,它不绑定任何模型。如果你希望客户端侧统一走一个 Key 和 API 通道来调用模型,可以用 TaoToken 的 API 通道(https://taotoken.net/api),把模型调用和工具调用分开管理,服务端专注做工具,客户端专注做编排。
2. 前置准备:Spring AI 版本、依赖与 TaoToken 通道
2.1 版本矩阵先定死
Spring AI 的 MCP starter 在里程碑版本里改过 artifactId,这是最容易踩的坑。M6 之前叫spring-ai-mcp-server-spring-boot-starter,M6 之后拆成了 webmvc 和 webflux 两个版本。如果你照着老博客抄依赖,大概率会报ClassNotFoundException。
我实测下来稳定可用的组合是:Java 17、Spring Boot 3.4.3、Spring AI 1.0.0-M6。JDK 必须 17 起步,Spring Boot 3.x 不支持 8 和 11。
<properties> <java.version>17</java.version> <spring-ai.version>1.0.0-M6</spring-ai.version> <spring-boot.version>3.4.3</spring-boot.version> </properties> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp-server-webmvc-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.36</version> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>注意spring-ai-bom必须用importscope,否则各 starter 版本会各拉各的,出现NoSuchMethodError。另外 Spring AI 的里程碑版本不在 Maven 中央仓库,需要在settings.xml或 pom 里加 Spring Milestone 仓库:
<repositories> <repository> <id>spring-milestones</id> <name>Spring Milestones</name> <url>https://repo.spring.io/milestone</url> <snapshots><enabled>false</enabled></snapshots> </repository> </repositories>2.2 TaoToken 通道在这里的角色
MCP-Server 只暴露工具,不负责模型推理。真正跑起来时,客户端(Cursor、Cline、Claude Code)需要一边调模型、一边调你的工具。模型这一侧如果每个客户端都单独配 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 。你可以把它理解成「模型调用的统一出口」,而你的 MCP-Server 是「工具调用的统一出口」,两者职责分离,互不干扰。
如果你用的是 Claude Code 这类编码客户端,可以在客户端侧配置 Base URL 指向 TaoToken 的 API 通道,Key 用 TaoToken 控制台生成的 Key,Model ID 按客户端要求填。这样模型调用走统一通道,工具调用走你自己的 MCP-Server,排查问题时边界清晰。
2.3 传输方式选 SSE 还是 STDIO
MCP 支持两种传输:STDIO 和 SSE。STDIO 是客户端把服务端当子进程启动,通过标准输入输出通信,适合本地单机;SSE 是服务端独立跑在 HTTP 端口上,客户端通过 Server-Sent Events 订阅消息,适合内网多客户端共享。
面向「本地或内网暴露可被 AI 客户端调用的工具服务」这个场景,选 SSE 更合适:服务端一次部署,多个客户端都能连,还能挂到 K8s 上做滚动更新。下面的配置都按 SSE 来。
3. 可复制配置:application.yml 与工具注册
3.1 application.yml 完整配置
spring: application: name: mcp-server-weather server: port: 8080 ai: mcp: server: enabled: true type: ASYNC sse-message-endpoint: /mcp/messages stdio: enabled: false sse: enabled: true几个参数的含义要讲清楚,不然改错了很难查:
type: ASYNC表示工具执行走异步线程池,避免阻塞 SSE 连接。如果你的工具是纯内存计算,用 SYNC 也行,但涉及 HTTP 调用建议 ASYNC。
sse-message-endpoint是客户端 POST 消息的路径,默认是/mcp/messages。客户端先 GET/sse建立事件流,拿到 sessionId 后,所有 JSON-RPC 请求都 POST 到这个 endpoint。
stdio.enabled: false和sse.enabled: true必须成对出现,两个都开会导致启动时报传输冲突。
3.2 用 @Tool 注解注册工具
Spring AI 的工具注册靠@Tool注解加ToolCallbackProviderBean。先写工具类:
@Component public class WeatherService { @Tool(description = "根据城市名称获取天气预报,返回该城市的天气状况") public String getWeatherByCity(String city) { Map<String, String> mockData = Map.of( "西安", "晴天,气温 18-26 度", "北京", "小雨,气温 12-20 度", "上海", "大雨,气温 15-22 度" ); return mockData.getOrDefault(city, "抱歉:未查询到该城市的天气数据"); } }description非常关键,模型就是靠这句话决定要不要调用这个工具。写得太模糊(比如「获取天气」)模型可能不调,写清楚「根据城市名称获取天气预报」命中率明显更高。
然后注册成 Provider:
@Component public class WeatherToolConfig { @Bean public ToolCallbackProvider weatherTools(WeatherService weatherService) { return MethodToolCallbackProvider.builder() .toolObjects(weatherService) .build(); } }MethodToolCallbackProvider会扫描toolObjects里所有带@Tool的方法,自动生成 JSON Schema。方法参数名会成为 schema 里的属性名,所以参数名不要用arg0这种,编译时记得加-parameters:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</parameters> </configuration> </plugin>不加这个参数,客户端看到的工具参数名会变成arg0,模型填参时容易出错。
3.3 客户端侧配置片段
服务端起在http://localhost:8080后,客户端配置长这样(以 Cursor 的mcp.json为例):
{ "mcpServers": { "weather": { "url": "http://localhost:8080/sse", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } }如果你在客户端里同时要配模型通道,把模型侧的 Base URL 指向 TaoToken 的 API 通道,Key 用控制台生成的 Key,Model ID 按客户端要求填。工具侧保持指向你自己的 MCP-Server,两边不要混。
4. 验证请求:确认工具列表与返回结果
4.1 先验证 SSE 端点活着
服务启动后,第一件事是确认 SSE 端点能建立连接:
curl -N -H "Accept: text/event-stream" http://localhost:8080/sse正常会看到类似输出,并且连接保持不关闭:
event: endpoint data: /mcp/messages?sessionId=8f3a2b1c-...这个sessionId就是后续 POST 消息要带的。如果这里直接返回 404,说明sse.enabled没生效或者路径被 Spring Security 拦了。
4.2 用 JSON-RPC 拉工具列表
拿到 sessionId 后,发一个tools/list请求:
curl -X POST "http://localhost:8080/mcp/messages?sessionId=8f3a2b1c-..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }'预期返回里能看到你注册的工具:
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "getWeatherByCity", "description": "根据城市名称获取天气预报,返回该城市的天气状况", "inputSchema": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] } } ] } }如果tools是空数组,八成是ToolCallbackProviderBean 没被扫描到,或者@Tool方法所在类没加@Component。
4.3 实际调用一次工具
再发tools/call:
curl -X POST "http://localhost:8080/mcp/messages?sessionId=8f3a2b1c-..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "getWeatherByCity", "arguments": { "city": "西安" } } }'预期返回:
{ "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "晴天,气温 18-26 度" } ], "isError": false } }到这里,工具列表和返回结果都符合预期,说明服务端注册和传输链路都通了。接下来在客户端里连上,模型就能自动发现并调用这个工具。
5. 常见报错排查:401、local proxy failed 与 reading choices
5.1 401 Unauthorized
客户端连上后报 401,通常有两个来源。一是客户端配置里的Authorizationheader 和服务端预期不一致;二是模型调用侧 Key 配错。先分清是工具侧还是模型侧:如果tools/list用 curl 能通,但客户端里报 401,那是客户端 header 没带上;如果 curl 也 401,检查服务端是否加了鉴权拦截器。
模型侧如果走 TaoToken 通道,Key 从控制台生成,Base URL 用 https://taotoken.net/api ,不要多加路径后缀。401 时先确认 Key 有没有多余空格。
5.2 local proxy failed
这个报错一般出现在客户端尝试连接 MCP-Server 时。常见原因是 URL 写成了http://localhost:8080但漏了/sse后缀,或者服务端只开了 STDIO 没开 SSE。检查application.yml里sse.enabled: true,以及客户端 URL 是否精确到/sse。
另一个原因是端口被占用,服务端实际没起来。用curl -N http://localhost:8080/sse确认一下,连不上就是服务端问题,不是客户端配置问题。
5.3 reading choices 相关报错
reading choices这类报错通常出现在模型响应解析阶段,说明模型返回的 JSON 结构不符合客户端预期。如果你在客户端里同时配了模型通道和工具通道,先确认模型通道的 Base URL 和 Model ID 是否匹配。Model ID 填错时,有些客户端会把错误响应当成正常响应解析,报出reading choices这种看起来和工具无关的错。
排查顺序:先用 curl 直接打模型通道的/v1/chat/completions,确认模型侧能返回标准结构;再单独测 MCP-Server 的tools/list;两边都通之后再在客户端里合起来用。
5.4 工具列表为空
tools/list返回空数组,按这个顺序查:@Tool方法所在类有没有@Component;ToolCallbackProviderBean 有没有注册;maven-compiler-plugin有没有加-parameters;spring-ai-bom有没有用importscope。这四个点覆盖了九成空列表问题。
5.5 参数名变成 arg0
客户端看到的工具参数是arg0、arg1,说明编译时没保留参数名。加上-parameters编译参数重新打包即可。这个坑很隐蔽,因为服务端本地测试用反射能拿到参数名,但打包后字节码里没有,客户端拿到的 schema 就退化了。
6. 把工具服务接进你的 AI 工作流
服务端跑通只是第一步,真正省事的是把它接进日常编码流程。如果你用 Claude Code 做长期编码,可以在客户端侧配置模型通道指向 TaoToken 的 API 通道,工具通道指向你自己的 MCP-Server。这样模型调用和工具调用各走各的,出问题时能快速定位是哪一侧。
需要生成 Key 的话,进 TaoToken 控制台(https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= )创建,接入细节看文档(https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= )。如果你更偏向长期编码和 Agent 场景,可以了解 Coding Plan(https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ),把模型调用统一管理起来。
最后给一个实用建议:MCP-Server 的工具描述要当成 API 文档来写,模型是靠 description 决定调不调的。我踩过的坑是描述写得太短,模型该调的时候不调,后来把「根据城市名称获取天气预报」改成带返回格式说明的完整句子,命中率立刻上来了。工具方法本身保持无状态、幂等,涉及写操作的一定要在 description 里标注清楚,避免模型误调。