1. 从一次工具调用超时说起:Spring AI MCP WebFlux Server 到底解决什么问题
如果你正在用 Spring Boot 写 AI 应用,大概率遇到过这种场景:模型能聊天,但一让它查天气、读数据库、调内部接口,就开始胡编。原因很简单——模型本身没有执行能力,它需要一个标准协议把「想调用什么工具」告诉你的后端。MCP(Model Context Protocol)就是干这个的。
Spring AI 从 1.0 版本开始把 MCP 做进了框架,提供了spring-ai-mcp和mcp-spring-webflux两个关键模块。前者负责把普通 Java 方法变成 MCP 工具,后者负责在 WebFlux 上暴露 SSE 端点。所谓「Manual WebFlux Server」,就是不依赖 Spring Boot 自动装配,而是手动创建WebFluxSseServerTransportProvider、手动注册RouterFunction、手动构建McpSyncServer。这样做的好处是传输模式可切换、工具注册可控、排查问题时有明确的 Bean 边界。
这篇案例面向三类人:一是刚接触 MCP 想跑通端到端链路的 Spring 开发者;二是已经在用 Spring AI 但被 SSE 端点 404 或工具列表为空卡住的同学;三是想把模型侧调用统一走一个 Key 通道、不想在代码里散落多家 API Key 的团队。我会给出可复制的pom.xml、application.yml、工具 Bean 配置和 curl 验证命令,最后用 TaoToken 的统一 Key 把模型侧调用接上,跑通一次完整的「模型决定调用工具 → Server 执行 → 结果回传」链路。
需要提前说明的是,MCP Server 本身不负责调用大模型,它只负责暴露工具。模型侧调用是另一条链路,本文用 TaoToken 的 OpenAI 兼容接口来补上这一环,这样你拿到的就是一个能真正跑起来的闭环,而不是只有半截的 Server 代码。
2. 前置准备:依赖、TaoToken 统一 Key 与模型侧通道
2.1 技术栈与版本对齐
先把版本钉死,MCP SDK 和 Spring AI 的版本错配是新手最容易踩的坑。本文使用的组合是 Spring Boot 3.4.5、Spring AI 1.0.1、MCP SDK 0.10.0、Java 17。MCP SDK 在 0.10.x 之后 API 有过调整,如果你用的是 0.9.x,McpServer.sync()的链式写法可能对不上,建议直接对齐到 0.10.0。
pom.xml里用 BOM 统一管理版本,避免逐个依赖写 version:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.1</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-bom</artifactId> <version>0.10.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-model</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp</artifactId> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-spring-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>注意spring-ai-model和spring-ai-mcp是两个不同的 artifact,前者提供@Tool注解和ToolCallbacks,后者提供 MCP 协议适配。只引spring-ai-mcp会出现ToolCallbacks找不到的情况。
2.2 TaoToken 统一 Key 的定位
MCP Server 暴露工具之后,谁来调用这些工具?答案是模型。模型需要先看到工具列表,再决定调用哪个。这条「模型侧」链路需要一个 OpenAI 兼容的 API 通道。TaoToken 在这里扮演的角色就是统一 Key 通道:你不需要在代码里分别配置多家模型的 Key,而是通过一个 Base URL 和一个 Key 访问多种模型。
获取 Key 的入口在控制台的 API Keys 页面,登录后创建即可。模型侧调用的 Base URL 是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 OpenAI 客户端的 baseUrl 使用。如果你用的是 Spring AI 的 OpenAI Starter,配置项是spring.ai.openai.base-url和spring.ai.openai.api-key。
这里要强调一点:TaoToken 是模型调用的通道,不是 MCP 协议的传输层。MCP 的 SSE 端点仍然跑在你自己的 WebFlux 服务器上,两者是上下游关系。把这两件事分清楚,后面排查问题会轻松很多。
2.3 目录结构约定
为了让后面的配置片段路径一致,先约定项目结构:
mcp-weather-server/ ├── pom.xml └── src/main/ ├── java/org/springframework/ai/mcp/sample/server/ │ ├── McpServerApplication.java │ ├── McpServerConfig.java │ └── WeatherApiClient.java └── resources/ └── application.yml包名保持和官方示例一致,这样你对照文档时不会因为路径差异产生困惑。
3. 可复制配置:application.yml、MCP 工具 Bean 与 WebFlux SSE 端点
3.1 application.yml 完整配置
先给出一份可以直接复制的application.yml。这里用 YAML 而不是 properties,因为嵌套结构更清晰:
server: port: 8080 spring: main: banner-mode: "off" web-application-type: reactive ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7 transport: mode: sse logging: pattern: console: "" file: name: mcp.weather.log level: org.springframework.ai.mcp: DEBUG io.modelcontextprotocol: DEBUG几个关键点。banner-mode设为 off 是为了 stdio 模式准备的,SSE 模式下其实不影响,但保留它可以让两种模式共用一份配置。web-application-type设为 reactive 是 WebFlux 的前提。transport.mode是自定义属性,用来在 stdio 和 sse 之间切换。日志级别开到 DEBUG 是为了在验证阶段能看到 MCP 的握手报文,生产环境记得调回 INFO。
api-key用环境变量占位,不要把 Key 硬编码进配置文件。启动前执行export TAOTOKEN_API_KEY=你的Key即可。
3.2 MCP Server 配置类
McpServerConfig.java是整个案例的核心。它做了三件事:根据transport.mode条件化创建传输提供者、注册 WebFlux 路由、构建McpSyncServer并挂载工具。
package org.springframework.ai.mcp.sample.server; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.springframework.ai.mcp.McpToolUtils; import org.springframework.ai.support.ToolCallbacks; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.server.RouterFunction; @Configuration public class McpServerConfig { @Bean @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio") public StdioServerTransportProvider stdioServerTransportProvider() { return new StdioServerTransportProvider(); } @Bean @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse") public WebFluxSseServerTransportProvider sseServerTransportProvider() { return new WebFluxSseServerTransportProvider(new ObjectMapper(), "/mcp/message"); } @Bean @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse") public RouterFunction<?> mcpRouterFunction(WebFluxSseServerTransportProvider transportProvider) { return transportProvider.getRouterFunction(); } @Bean public WeatherApiClient weatherApiClient() { return new WeatherApiClient(); } @Bean public McpSyncServer mcpServer(McpServerTransportProvider transportProvider, WeatherApiClient weatherApiClient) { var capabilities = McpSchema.ServerCapabilities.builder() .tools(true) .logging() .build(); return McpServer.sync(transportProvider) .serverInfo("MCP Demo Weather Server", "1.0.0") .capabilities(capabilities) .tools(McpToolUtils.toSyncToolSpecifications( ToolCallbacks.from(weatherApiClient))) .build(); } }WebFluxSseServerTransportProvider的第二个参数/mcp/message是客户端发送 JSON-RPC 请求的路径,SSE 连接本身建立在/sse上。这两个路径是 SDK 内部约定的,不要随意改,否则客户端连不上。
capabilities里的.tools(true)表示支持工具列表变更通知,.logging()开启日志能力。如果你后续要加资源和提示,在这里追加.resources(true, false)和.prompts(true)。
ToolCallbacks.from(weatherApiClient)会自动扫描WeatherApiClient上所有带@Tool注解的方法,McpToolUtils.toSyncToolSpecifications把它们转成 MCP 工具规格。这一步是「手动装配」的关键——工具不是自动注册的,而是你显式传进去的。
3.3 工具类实现
WeatherApiClient.java定义两个工具:按经纬度查天气、按州查警报。为了聚焦 MCP 链路,这里用美国国家气象服务的公开接口作为数据源:
package org.springframework.ai.mcp.sample.server; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import org.springframework.ai.tool.annotation.Tool; import org.springframework.web.client.RestClient; public class WeatherApiClient { private static final String BASE_URL = "https://api.weather.gov"; private final RestClient restClient; public WeatherApiClient() { this.restClient = RestClient.builder() .baseUrl(BASE_URL) .defaultHeader("Accept", "application/geo+json") .defaultHeader("User-Agent", "WeatherApiClient/1.0 (your@email.com)") .build(); } @JsonIgnoreProperties(ignoreUnknown = true) public record Points(@JsonProperty("properties") Props properties) { @JsonIgnoreProperties(ignoreUnknown = true) public record Props(@JsonProperty("forecast") String forecast) {} } @JsonIgnoreProperties(ignoreUnknown = true) public record Forecast(@JsonProperty("properties") Props properties) { @JsonIgnoreProperties(ignoreUnknown = true) public record Props(@JsonProperty("periods") List<Period> periods) {} } @JsonIgnoreProperties(ignoreUnknown = true) public record Period( @JsonProperty("name") String name, @JsonProperty("temperature") Integer temperature, @JsonProperty("temperatureUnit") String temperatureUnit, @JsonProperty("windSpeed") String windSpeed, @JsonProperty("windDirection") String windDirection, @JsonProperty("detailedForecast") String detailedForecast) {} @Tool(description = "获取特定纬度/经度的天气预报") public String getWeatherForecastByLocation(double latitude, double longitude) { var points = restClient.get() .uri("/points/{latitude},{longitude}", latitude, longitude) .retrieve() .body(Points.class); var forecast = restClient.get() .uri(points.properties().forecast()) .retrieve() .body(Forecast.class); return forecast.properties().periods().stream() .map(p -> String.format("%s: %s %s, Wind %s %s, %s", p.name(), p.temperature(), p.temperatureUnit(), p.windSpeed(), p.windDirection(), p.detailedForecast())) .collect(Collectors.joining("\n")); } @Tool(description = "获取美国州的天气警报,输入是两个字母的州代码,例如 CA、NY") public String getAlerts(String state) { var alert = restClient.get() .uri("/alerts/active/area/{state}", state) .retrieve() .body(Map.class); return alert.toString(); } }@Tool的description非常重要,模型就是靠这段文字判断什么时候调用这个工具。描述写得含糊,模型就会乱调或者不调。建议用「动词 + 对象 + 参数说明」的格式。
3.4 启动类
启动类保持最简:
package org.springframework.ai.mcp.sample.server; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class McpServerApplication { public static void main(String[] args) { SpringApplication.run(McpServerApplication.class, args); } }到这里,配置部分就齐了。接下来是验证。
4. 验证请求:curl 打通 SSE 端点与工具调用链路
4.1 构建与启动
先构建:
./mvnw clean package -DskipTests然后以 SSE 模式启动:
export TAOTOKEN_API_KEY=你的Key java -Dtransport.mode=sse -jar target/mcp-weather-server-0.0.1-SNAPSHOT.jar启动日志里应该能看到 Netty 在 8080 端口监听,以及 MCP Server 初始化的 DEBUG 日志。如果看到Started McpServerApplication,说明 Server 起来了。
4.2 用 curl 验证 SSE 连接
MCP 的 SSE 传输分两步:先建立 SSE 长连接拿到 session,再往 message 端点发 JSON-RPC。用 curl 验证第一步:
curl -N -H "Accept: text/event-stream" http://localhost:8080/sse-N关闭缓冲,你会看到类似这样的输出:
event: endpoint data: /mcp/message?sessionId=8f3a2b1c-...这个sessionId就是后续发请求要带的。保持这个 curl 不关闭,另开一个终端发初始化请求:
curl -X POST "http://localhost:8080/mcp/message?sessionId=8f3a2b1c-..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "curl-client", "version": "1.0"} } }'如果返回 200 且 SSE 终端里出现了initialize的响应,说明握手成功。接着列出工具:
curl -X POST "http://localhost:8080/mcp/message?sessionId=8f3a2b1c-..." \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'SSE 终端里应该能看到getWeatherForecastByLocation和getAlerts两个工具。这一步能过,说明工具注册没问题。
4.3 调用工具
发一个工具调用请求:
curl -X POST "http://localhost:8080/mcp/message?sessionId=8f3a2b1c-..." \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "getWeatherForecastByLocation", "arguments": {"latitude": 47.6062, "longitude": -122.3321} } }'SSE 终端里会返回西雅图的天气预报文本。到这一步,MCP Server 侧的链路就完整了。
4.4 接上模型侧:TaoToken 统一 Key 调用
Server 能跑通不代表模型会用。要验证「模型决定调用工具」这一环,需要模型侧客户端。用 Spring AI 的 OpenAI 客户端指向 TaoToken:
@Bean public ChatClient chatClient(OpenAiChatModel chatModel, WeatherApiClient weatherApiClient) { return ChatClient.builder(chatModel) .defaultTools(ToolCallbacks.from(weatherApiClient)) .build(); }然后在 Controller 里:
@GetMapping("/chat") public String chat(@RequestParam String message) { return chatClient.prompt() .user(message) .call() .content(); }访问http://localhost:8080/chat?message=西雅图今天天气怎么样,模型会先返回一个工具调用意图,Spring AI 自动执行getWeatherForecastByLocation,再把结果喂回模型生成自然语言回答。这条链路跑通,才算真正端到端。
如果你更想手动验证模型侧,也可以用 curl 直接打 TaoToken 的接口:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "你好"}] }'返回正常说明 Key 和通道没问题。想在线对比不同模型的表现,可以直接用模型对话页面测试,不用写代码。
5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth
5.1 401 Unauthorized
最常见的 401 来自两个地方。一是 TaoToken 的 Key 没设对,检查TAOTOKEN_API_KEY环境变量是否导出、是否有多余空格。二是 MCP 的 SSE 端点本身没有鉴权,如果你在网关层加了认证,curl 请求会因为缺少 token 被拦。区分方法很简单:看 401 的响应体,TaoToken 返回的是 OpenAI 格式的 error 对象,网关返回的通常是 HTML 或纯文本。
还有一种隐蔽情况:Key 是对的,但base-url写成了https://taotoken.net/api/v1。Spring AI 的 OpenAI 客户端会自动拼/v1/chat/completions,所以 base-url 只写到/api即可,多写/v1会变成/api/v1/v1/chat/completions,返回 404 而不是 401,但表现上容易被误判。
5.2 local proxy failed
这个报错通常出现在客户端侧,提示连接本地代理失败。原因是你机器上配置了 HTTP_PROXY 或 HTTPS_PROXY 环境变量,而本地 8080 端口的请求被错误地走了代理。解决办法是在启动客户端前清掉代理变量:
unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy或者在 JVM 参数里加-Dhttp.proxyHost= -Dhttps.proxyHost=显式置空。这个坑在 CI 环境里尤其常见,因为 CI 镜像经常预置代理配置。
5.3 reading choices 相关报错
Error reading choices或Cannot deserialize value of type Choice这类报错,八成是模型返回的 JSON 结构和客户端预期不一致。常见诱因是模型名写错,TaoToken 返回了一个错误对象而不是正常的 chat completion 结构。检查spring.ai.openai.chat.options.model的值是否是通道支持的模型 ID。另一个诱因是流式和非流式混用,stream=true时返回的是 SSE 分片,用非流式解析器去读就会失败。
5.4 OAuth 与鉴权混淆
有些同学看到 401 就以为是 OAuth 问题,去配client-id、client-secret。实际上 MCP 协议本身在 2024-11-05 版本里没有强制 OAuth,SSE 端点默认是裸奔的。TaoToken 的 Key 是 Bearer Token 形式,不是 OAuth 流程。如果你确实需要给 MCP Server 加鉴权,应该在 WebFlux 层加SecurityWebFilterChain,而不是去动 MCP 的配置。
5.5 工具列表为空
tools/list返回空数组,但 Server 启动没报错。这种情况先检查ToolCallbacks.from()传入的对象是不是 Spring 管理的 Bean。如果你直接new WeatherApiClient()传进去,@Tool注解不会被扫描。其次检查@Tool注解的 import 是不是org.springframework.ai.tool.annotation.Tool,引错包(比如引成 MCP SDK 自带的注解)会导致扫描不到。
5.6 SSE 连接建立后立即断开
curl 的-N没加,或者中间有反向代理做了缓冲。Nginx 默认会缓冲 SSE,需要在 location 里加proxy_buffering off;和proxy_cache off;。本地开发一般不会遇到,但部署到测试环境后这个坑很常见。
6. 把这条链路用起来:从验证到长期编码的路径选择
跑通一次端到端调用只是起点。真正把它用起来,你会面临几个选择:模型侧是继续用按次调用的 API,还是换成更适合长期编码的套餐;MCP Server 是只暴露天气工具,还是接入内部系统。
如果你只是偶尔验证模型行为、对比不同模型的工具调用准确率,用模型对话页面手动测就够了,不用写客户端代码。如果你要把 MCP 工具接入到日常编码流程里,比如让 AI 助手直接调用你的内部接口,那模型侧的调用量会上来,这时候 Coding Plan 这类长期方案会比按次计费更划算。
接入文档里有完整的 Base URL、Key 配置和模型 ID 对照表,建议在写客户端之前先过一遍,避免在参数格式上反复试错。MCP Server 的代码本身不依赖具体模型通道,你换任何 OpenAI 兼容的通道,只需要改base-url和api-key两个配置项,工具注册和 SSE 端点都不用动。这种解耦正是手动装配 WebFlux Server 的价值——传输层、工具层、模型层各自独立,出问题时能快速定位是哪一层的事。