)
1. 从 STDIO/SSE 迁移到 Streamable HTTP到底解决了什么问题如果你正在用 Spring AI 做 MCP 工具接入大概率踩过这两个坑STDIO 模式下 MCP Server 必须和 Client 跑在同一台机器、同一个进程组里一旦想把工具服务拆出去给多个应用复用进程管理立刻变成噩梦SSE 模式虽然能跨网络但它是单向推送客户端发请求还得另开一条 POST 通道连接状态在网关、负载均衡层经常被掐断日志里动不动就是connection reset。Streamable HTTP 传输的 MCP 就是冲着这些痛点来的。它把请求和响应都收敛到标准 HTTP 语义上服务端可以像普通 Web 服务一样部署客户端用一次 HTTP 调用就能完成工具发现和工具执行流式响应通过 chunked 传输或 SSE 帧在同一个端点上完成。说白了MCP Server 从必须贴着客户端跑的附属进程变成了可以独立部署、独立扩缩容的后端服务。这篇文章面向的是已经在用 Spring AI、想把 MCP 从本地 STDIO 或 SSE 迁移到 Streamable HTTP 的开发者。我会给出一套可复制的 Server/Client 配置、Streamable HTTP 端点声明、以及用 TaoToken 统一 Key 接入模型调用的完整示例最后附上启动日志和调用回显的验证动作。你跟着走一遍链路能一次跑通。核心检索词先明确Spring AI Streamable HTTP MCP 配置、MCP STDIO 迁移、SSE 转 Streamable HTTP、Spring AI MCP Server WebFlux。这几个词贯穿全文你搜的时候也能对上。先说清楚迁移的收益边界。Streamable HTTP 不是银弹它适合的是工具服务需要被多个客户端共享工具服务要独立部署和监控需要通过标准网关做鉴权和限流这三类场景。如果你的工具就是本地读个文件、跑个脚本STDIO 反而更省事。但只要你开始考虑多工具协作、远程服务调用、或者把 MCP 能力暴露给团队里其他应用Streamable HTTP 就是该走的路。我试过把一套用户管理工具从 STDIO 迁到 Streamable HTTP最直观的变化是Server 可以单独mvn spring-boot:run起来挂在 8080Client 在 8081 通过一个 URL 就能连上中间还能塞个 Nginx 做转发。调试的时候直接 curl 打端点就能看工具列表不用再去翻子进程的 stdout。下面按原问题与场景 → TaoToken 前置 → 可复制配置 → 验证请求 → 常见错排查 → CTA的顺序展开。每一段都给可执行的东西不空谈概念。2. TaoToken 统一 Key 前置准备与模型接入配置在动手改 MCP 传输之前先把模型调用这一层理顺。MCP 负责的是工具怎么被调用但工具调用完的结果最终要交给 LLM 去理解和生成回答所以模型接入的 Key 管理不能乱。我建议用 TaoToken 做统一 Key 接入原因是它把 OpenAI 兼容协议封装好了Spring AI 的spring-ai-starter-model-openai直接改base-url和api-key就能用不用为每个模型供应商写一套适配。先拿 Key。打开 TaoToken 控制台在 API Keys 页面创建一个新 Key复制出来。这个 Key 后面会同时用在 MCP Client 的模型调用配置里。控制台地址是 https://taotoken.net/console 创建 Key 的入口在 https://taotoken.net/api-keys 。如果你还没注册官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 可以先看一眼整体能力。拿到 Key 之后模型调用的 Base URL 用 https://taotoken.net/api 注意这个地址后面不加任何 UTM 参数直接写进配置。Model ID 按你实际要用的填比如claude-3-7-sonnet-latest或者gpt-4o这类 OpenAI 兼容命名。这三个要素——Base URL、API Key、Model ID——在 Spring AI 的application.yml里对应spring.ai.openai.base-url、spring.ai.openai.api-key、spring.ai.openai.chat.options.model。这里有个容易忽略的点MCP Client 和 MCP Server 是两个独立进程模型调用只发生在 Client 侧。Server 侧不需要配模型 Key它只负责暴露工具。所以 TaoToken 的 Key 只需要出现在mcp-client模块的配置里。如果你把 Key 写到了 Server 的配置里那是多余的而且增加了泄露面。再确认一下依赖。Client 侧需要spring-ai-starter-model-openai来驱动模型调用Server 侧只需要spring-ai-starter-mcp-server-webflux。两者都依赖 Spring AI 的 BOM 来统一版本。我用的版本组合是 Spring Boot 3.5.10 Spring AI 1.1.0-SNAPSHOTJDK 25。如果你用的是稳定版 Spring AI把版本号换成对应的 release 即可配置结构不变。关于 Key 的安全存放别直接硬编码在application.yml里提交到仓库。用环境变量注入配置里写${TAOTOKEN_API_KEY}启动时通过export TAOTOKEN_API_KEY你的Key传入。这样本地调试和 CI 都能复用同一份配置。如果你更习惯用命令行工具做模型对话验证TaoToken 的模型对话入口在 https://taotoken.net/models 可以先用它确认 Key 和 Model ID 是通的再去配 Spring AI。这一步能帮你排除掉到底是 Key 错了还是 MCP 配置错了的干扰。长期做编码和 Agent 场景的话Coding Plan 页面 https://taotoken.net/coding-plan 有对应的套餐说明按需看就行。前置准备清单JDK 25、Maven 3.8、PostgreSQL本文示例用 R2DBC 连 PostgreSQL你也可以换成 H2 先跑通、TaoToken API Key、一个能访问的 Model ID。这些齐了下面直接进配置。3. 可复制的 MCP Server/Client 配置与 Streamable HTTP 端点声明这一节是全文的核心所有配置都可以直接复制。我按父 POM → Server 配置 → Client 配置的顺序给路径和原文保持一致。3.1 父 POM 统一版本管理?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdorg.example/groupId artifactIdspring-ai-mcp-demo/artifactId version1.0-SNAPSHOT/version packagingpom/packaging properties maven.compiler.source25/maven.compiler.source maven.compiler.target25/maven.compiler.target spring-boot.version3.5.10/spring-boot.version spring-ai.version1.1.0-SNAPSHOT/spring-ai.version /properties modules modulemcp-server/module modulemcp-client/module /modules repositories repository idspring-milestones/id nameSpring Milestones/name urlhttps://repo.spring.io/milestone/url /repository repository idspring-snapshots/id nameSpring Snapshots/name urlhttps://repo.spring.io/snapshot/url /repository /repositories dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version${spring-boot.version}/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement /project3.2 MCP Server 的 Streamable HTTP 配置Server 侧的关键是spring.ai.mcp.server.protocol: STREAMABLE和streamable-http.mcp-endpoint。前者声明传输协议后者声明端点路径。完整application.ymlspring: application: name: mcp-user-server http: codecs: max-in-memory-size: 10MB ai: mcp: server: protocol: STREAMABLE enabled: true name: webflux-mcp-server version: 1.0.0 type: ASYNC instructions: This reactive server provides user management tools and resources annotation-scanner: enabled: true capabilities: tool: true resource: true prompt: true streamable-http: mcp-endpoint: /api/mcp keep-alive-interval: 30s r2dbc: url: r2dbc:postgresql://localhost:5432/chatdb username: postgres password: root pool: enabled: true initial-size: 5 max-size: 20 sql: init: mode: always server: port: 8080Server 的依赖只需要三块WebFlux、MCP Server WebFlux starter、R2DBC PostgreSQL。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-r2dbc/artifactId /dependency dependency groupIdorg.postgresql/groupId artifactIdr2dbc-postgresql/artifactId /dependency /dependencies工具定义用McpTool和McpToolParam注解方法返回MonoString以适配响应式链路。下面是一个创建用户的工具示例Component public class UserTools { private final UserRepository userRepository; public UserTools(UserRepository userRepository) { this.userRepository userRepository; } McpTool( name createUser, description 创建新用户需要用户名、邮箱、手机号和年龄 ) public MonoString createUser( McpToolParam(description 用户名必填, required true) String username, McpToolParam(description 邮箱地址必填, required true) String email, McpToolParam(description 手机号, required false) String phone, McpToolParam(description 年龄, required false) Integer age) { return userRepository.findByUsername(username) .flatMap(existing - Mono.just(创建失败用户名 username 已存在)) .switchIfEmpty( userRepository.findByEmail(email) .flatMap(existing - Mono.just(创建失败邮箱 email 已被使用)) .switchIfEmpty(Mono.defer(() - { User newUser new User(username, email, phone, age); return userRepository.save(newUser) .map(saved - 用户创建成功 saved.getUsername()); })) ); } }3.3 MCP Client 的 Streamable HTTP 连接配置Client 侧要同时配模型调用和 MCP 连接。模型调用走 TaoTokenMCP 连接指向 Server 的 Streamable HTTP 端点。完整application.ymlspring: application: name: mcp-user-client http: codecs: max-in-memory-size: 10MB ai: openai: api-key: ${TAOTOKEN_API_KEY} base-url: https://taotoken.net/api chat: options: model: claude-3-7-sonnet-latest retry: max-attempts: 5 backoff: initial-interval: 2000 multiplier: 2 mcp: client: name: user-server version: 1.0.0 enabled: true type: ASYNC request-timeout: 30s streamable-http: connections: user-server: url: http://localhost:8080 endpoint: /api/mcp server: port: 8081 logging: level: io.modelcontextprotocol: DEBUG org.springframework.ai.mcp: DEBUGClient 依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-client-webflux/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency /dependencies注意streamable-http.connections下面的 key这里是user-server要和spring.ai.mcp.client.name对应上否则注入McpAsyncClient时筛选会失败。Client 侧调用工具的核心代码Service public class UserMcpService { private final McpAsyncClient mcpAsyncClient; private final ChatClient.Builder chatClientBuilder; public UserMcpService(ListMcpAsyncClient clients, ChatClient.Builder builder) { this.mcpAsyncClient clients.stream() .filter(c - c.getClientInfo().name().equals(user-server - user-server)) .findFirst() .orElseThrow(() - new IllegalArgumentException(未找到 user-server 客户端)); this.chatClientBuilder builder; } public MonoString createUser(String username, String email, String phone, Integer age) { MapString, Object params new HashMap(); params.put(username, username); params.put(email, email); if (phone ! null) params.put(phone, phone); if (age ! null) params.put(age, age); return mcpAsyncClient.callTool(new McpSchema.CallToolRequest(createUser, params)) .map(this::extractResult); } private String extractResult(McpSchema.CallToolResult result) { if (result.isError()) return 调用出错 result.content(); return result.content().stream() .filter(c - c instanceof McpSchema.TextContent) .map(c - ((McpSchema.TextContent) c).text()) .findFirst() .orElse(无结果); } }三件套在这里体现得很清楚Base URL 是https://taotoken.net/apiKey 是${TAOTOKEN_API_KEY}环境变量Model ID 是claude-3-7-sonnet-latest。MCP 连接则是url: http://localhost:8080endpoint: /api/mcp。这两组配置各管各的别混在一起。4. 验证请求与成功结果启动日志与调用回显配置写完跑起来验证。先起 Server再起 Client顺序不能反因为 Client 启动时会去连 Server 拉工具列表。启动 Servercd mcp-server mvn spring-boot:run看到类似下面的日志说明 Streamable HTTP 端点已经挂上MCP 服务器已启动 Streamable HTTP endpoint registered at /api/mcp Netty started on port 8080启动 Clientexport TAOTOKEN_API_KEY你的Key cd mcp-client mvn spring-boot:runClient 启动时会打印工具发现结果MCP 客户端已启动 API 地址: http://localhost:8081 MCP 客户端已连接可用工具[createUser, getUserById, getAllUsers, updateUser, deleteUser, countUsers]这行可用工具[...]是链路通了的第一个信号。如果这里为空或者报连接失败直接跳到第 5 节排查。接下来用 curl 打 Client 的 REST 接口验证工具调用回显。创建用户curl -X POST http://localhost:8081/api/users \ -H Content-Type: application/json \ -d { username: testuser, email: testexample.com, phone: 13800138000, age: 25 }预期返回{ success: true, message: 用户创建成功testuser }查询所有用户curl http://localhost:8081/api/users预期返回带用户列表的文本包含刚创建的testuser。按 ID 查询curl http://localhost:8081/api/users/1更新用户curl -X PUT http://localhost:8081/api/users/1 \ -H Content-Type: application/json \ -d {phone: 13900139000, age: 26}删除用户curl -X DELETE http://localhost:8081/api/users/1最后验证模型调用链路走流式问答curl -X POST http://localhost:8081/api/users/ask/stream \ -H Content-Type: application/json \ -d {question: 统计一下用户情况并给出分析建议}这个请求会先通过 MCP 工具拉取用户数据再把数据塞进 prompt 交给 TaoToken 的模型生成回答响应以text/event-stream流式返回。如果你看到一段段文字陆续吐出来说明 MCP 工具调用 模型调用两条链路都通了。验证成功的三个标志Client 启动日志里工具列表非空、curl 创建用户返回success: true、流式问答有内容输出。三个都满足迁移就算完成。任何一个不满足对照下一节的报错排查。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth迁移过程中最容易撞上的几类报错我按实际遇到的频率排一下每个都给定位方法和修复动作。5.1 401 Unauthorized这个报错基本都出在模型调用侧也就是 TaoToken 的 Key 没配对。典型日志401 Unauthorized: {error:{message:Invalid API key}}排查顺序先确认spring.ai.openai.api-key读到的环境变量TAOTOKEN_API_KEY确实有值用echo $TAOTOKEN_API_KEY看一眼再确认base-url是https://taotoken.net/api末尾不要多斜杠也不要少路径最后确认 Model ID 是账号有权限的模型。三件套里任何一个错了都会 401。注意 MCP Server 侧不需要这个 Key如果 Server 日志里出现 401那是你误把模型调用配到了 Server 上。5.2 local proxy failed这个报错通常出现在 Client 连 MCP Server 的阶段日志类似local proxy failed: connection refused to http://localhost:8080/api/mcp意思是 Client 按配置去连 Server 的 Streamable HTTP 端点但连不上。先确认 Server 真的起来了、端口是 8080再确认streamable-http.connections.user-server.url和endpoint拼起来正好是http://localhost:8080/api/mcp如果 Server 换了端口Client 配置要同步改。还有一种情况是 Server 的mcp-endpoint写成了别的路径两边对不上。5.3 reading choices 相关报错流式问答时如果模型返回体解析失败会看到类似Error reading choices from response这多半是模型返回格式和 Spring AI 预期的不一致。检查spring.ai.openai.chat.options.model填的 Model ID 是否在 TaoToken 侧支持 OpenAI 兼容的 chat completions 格式。有些模型只支持特定接口形态换一个兼容性更好的 Model ID 通常能解决。另外确认base-url没有指向一个返回 HTML 错误页的地址那样解析必然失败。5.4 OAuth 相关报错如果你在 MCP 连接上启用了鉴权或者 Server 侧加了 OAuth 保护Client 没带 token 就会报OAuth authentication failed: missing bearer token本文的示例没有启用 MCP 层鉴权所以正常不该出现这个错。如果你自己加了鉴权确认 Client 的streamable-http.connections下配了对应的 header 或 token。生产环境建议在网关层做鉴权而不是在 MCP 协议层硬塞 OAuth这样 Client 配置更干净。5.5 工具列表为空Client 启动日志里可用工具[]但没报错。这通常是annotation-scanner.enabled没开或者工具类没被 Spring 扫描到。确认 Server 配置里spring.ai.mcp.server.annotation-scanner.enabled: true工具类上有Component方法上有McpTool。还有一种可能是 Client 筛选McpAsyncClient时名字对不上检查getClientInfo().name()的实际值用日志打出来看。5.6 R2DBC 连接池耗尽高并发下如果看到R2DBC pool exhausted, unable to acquire connection调大spring.r2dbc.pool.max-size并加上max-idle-time: 30m。同时检查工具方法里有没有阻塞调用响应式链路里混入阻塞 IO 会拖垮连接池。排查的核心思路是分层模型调用报错看 TaoToken 三件套MCP 连接报错看 url endpoint工具发现报错看注解扫描。把这三层分开定位大部分问题五分钟内能锁定。6. 语义一致的 CTA 与后续接入路径链路跑通之后接下来该往哪走取决于你的实际场景。如果你是在做工具接入和排障下一步是把 MCP Server 部署到能被团队访问的环境然后在 TaoToken 的 API Keys 页面 https://taotoken.net/api-keys 管理你的 Key接入文档在 https://taotoken.net/doc 有更细的协议说明。排障阶段遇到模型侧的问题用模型对话入口 https://taotoken.net/models 单独验证 Key 和 Model ID能快速区分是模型问题还是 MCP 问题。如果你是要长期做编码和 Agent 场景MCP 工具会越接越多模型调用量也会上来Coding Plan https://taotoken.net/coding-plan 的套餐更适合这种持续使用的形态。控制台 https://taotoken.net/console 可以统一看用量和 Key 状态。Claude Code 相关的接入Anthropic 兼容入口在 https://taotoken.net/claudecode-anthropic 如果你同时用 Claude Code 和 Spring AI 两套工具链可以共用同一个 TaoToken Key省去多套凭证管理的麻烦。回到技术本身Streamable HTTP 迁移完成后我建议你做的第一件事是把 MCP Server 的端点加上健康检查和基础监控。工具调用是有状态的业务操作出问题时你需要知道是网络层断了、工具执行超时了、还是模型返回异常。在 Server 侧加一个/actuator/health在 Client 侧记录每次callTool的耗时和结果状态这两条信息在排查线上问题时比日志管用得多。第二件事是给工具方法加幂等设计。Streamable HTTP 下客户端可能因为超时重试createUser这类写操作如果没有唯一约束保护会插入重复数据。本文示例里用findByUsername和findByEmail做了前置检查但高并发下仍有竞态窗口数据库层的唯一索引才是最终防线。第三件事是把request-timeout和模型调用的retry策略对齐。MCP 工具调用超时设 30s模型调用重试 5 次、退避 2s 起这两个参数要匹配你的实际业务耗时。工具执行慢但模型重试快会导致重复调用反过来则会让用户等太久。按你的 P99 耗时来定这两个值。迁移不是终点把链路跑稳、把边界情况处理好才是 Streamable HTTP 真正发挥价值的地方。