1. 本地联调时 sse 与 stdio 到底差在哪
Spring AI 集成 MCP 之后,很多人在本地第一次跑通时都会卡在同一个地方:服务端明明起来了,客户端却拿不到工具列表。问题往往不在代码,而在你选了 sse 还是 stdio 这条链路。MCP 全称 Model Context Protocol,你可以把它理解成 AI 世界的 USB-C 接口,它把大模型和外部数据源、工具之间的交互标准化了。Spring AI 1.0.0-M6 这一版对 MCP 的支持已经比较完整,服务端可以用 WebMVC 暴露 sse 端点,也可以用标准输入输出走 stdio,客户端则通过spring-ai-mcp-client-spring-boot-starter统一接入。
sse 的本质是 HTTP 长连接,服务端跑成一个 Web 服务,客户端通过 URL 去连,适合跨进程、跨机器的场景,调试时你能直接用浏览器或 curl 看到端点。stdio 的本质是父子进程管道,客户端把 MCP Server 当成一个子进程启动,通过标准输入输出收发 JSON-RPC 消息,适合本地工具、命令行程序,比如百度地图那个 Python 脚本就是典型 stdio 服务。两者在 Spring AI 里的配置入口完全不同:sse 走spring.ai.mcp.client.sse.connections,stdio 走spring.ai.mcp.client.stdio.servers-configuration指向一个 JSON 文件。
我这次的目标很明确:一套 Spring Boot 客户端,同时挂上 sse 和 stdio 两个 MCP Server,再把底层对话模型的 endpoint 切到 TaoToken 的统一通道,用同一个 Key 跑通两条调用链路。环境是 JDK17、Maven 3.8.6、Spring Boot 3.4.4、Spring AI 1.0.0-M6。下面按服务端、客户端、配置、验证、排错的顺序来,每一步都能直接复制。
2. TaoToken 前置:统一 Key 与 API 通道准备
在动手写 MCP 配置之前,先把模型通道这件事定下来。Spring AI 默认对接的是各家自己的 endpoint,本地联调时如果每个模型都去申请一套 Key,切换成本很高。TaoToken 提供的是统一 Key 和统一 API 通道,你只需要一个 Key,就能在同一个客户端里切换不同模型,这对 MCP 这种需要频繁调工具的联调场景特别省事。
先到官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册账号,然后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 列表在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建完把 Key 复制出来,形如sk-xxxxxxxx,后面配置里会用到。
API 的基础地址是 https://taotoken.net/api ,注意这个地址不带任何查询参数,直接作为 base-url 使用。如果你用的是 OpenAI 兼容的客户端,通常需要拼成https://taotoken.net/api/v1这种形式,具体以接入文档为准,文档入口在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。模型 ID 可以在模型对话页面确认,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,常用的有 gpt-4o、claude 系列、qwen 系列等,选一个你账号里有权限的即可。
这里要强调一点:MCP 的 sse 和 stdio 解决的是「工具怎么被调用」,TaoToken 解决的是「模型请求发到哪里」。两者是叠加关系,不是替代关系。你完全可以让 MCP Server 跑在本地 stdio,而模型请求走 TaoToken 的远程通道,这样本地只负责工具执行,模型推理交给统一网关,联调时日志更干净。
如果你后面要做长期编码或 Agent 类项目,可以考虑 Coding Plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它更适合持续性的开发场景。现在先把基础 Key 拿到手,继续往下配。
3. 可复制配置:sse 与 stdio 双通道落地
这一节是全文的核心,所有片段都可以直接复制。先看服务端。服务端我建了一个独立的mcp-server模块,用 WebMVC 暴露 sse 端点。pom.xml里关键依赖是spring-ai-mcp-server-webmvc-spring-boot-starter,同时要显式引入mcp-spring-webmvc0.8.1,因为早期版本 sse 连接 30 秒后会断,这个版本修了那个问题。
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp-server-webmvc-spring-boot-starter</artifactId> <exclusions> <exclusion> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-spring-webmvc</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-spring-webmvc</artifactId> <version>0.8.1</version> </dependency>服务端的application.yaml里,sse 的消息端点要显式声明,否则客户端连上后找不到回传通道:
spring: application: name: mcp-server ai: mcp: server: name: webmvc-mcp-server version: 1.0.0 type: SYNC sse-message-endpoint: /mcp/messages工具的定义用@Tool注解最省事。我写了两个 Service,一个查书,一个查天气,天气那个用 mock 数据,方便你验证工具是否真的被调用:
@Service public class WeatherService { @Tool(name = "Weather0", description = "小龙电台,根据城市名称获取天气预报,格式:西安、北京、上海等") public String getWeatherByCity(String city) { System.out.println("MethodToolCallbackProvider 小龙电台,天气服务,查询城市:" + city); Map<String, String> mockData = Map.of( "西安", "晴天", "北京", "小雨", "上海", "大雨", "河北", "阴天", "延安", "多云", "邯郸", "暴雪" ); return mockData.getOrDefault(city, "抱歉:未查询到对应城市!"); } }然后在配置类里把 Service 注册成ToolCallbackProvider:
@Configuration @EnableWebMvc public class McpServerConfig implements WebMvcConfigurer { @Bean public ToolCallbackProvider openLibraryToolsOne(BookService bookService) { return MethodToolCallbackProvider.builder().toolObjects(bookService).build(); } @Bean public ToolCallbackProvider openLibraryToolsTwo(WeatherService weatherService) { return MethodToolCallbackProvider.builder().toolObjects(weatherService).build(); } }服务端启动后,sse 端点默认在http://127.0.0.1:8080/sse,消息回传在/mcp/messages。你可以先用浏览器访问/sse,看到event: endpoint就说明服务端没问题。
接下来是客户端,这是双通道的关键。客户端的application.yaml同时配 sse 和 stdio:
server: port: 9999 spring: ai: mcp: client: enabled: true request-timeout: 60s type: SYNC name: call-mcp-server stdio: servers-configuration: classpath:mcp-server.json sse: connections: server1: url: http://127.0.0.1:8080stdio 的mcp-server.json放在resources下,内容指向一个本地 Python 脚本:
{ "mcpServers": { "baidu-map": { "command": "uv", "args": [ "run", "--with", "mcp[cli]", "mcp", "run", "D:\\anzhuang\\baidu_map_mcp_server\\map.py" ], "env": { "BAIDU_MAPS_API_KEY": "你的百度地图Key" } } } }注意 stdio 的command必须是系统能直接找到的可执行文件,Windows 下uv要确保在 PATH 里,否则客户端启动子进程时会报Cannot run program。sse 的url只写到端口,不要带/sse,Spring AI 会自动拼路径。
最后把模型通道切到 TaoToken。在客户端application.yaml里加一段:
spring: ai: openai: base-url: https://taotoken.net/api api-key: sk-你的TaoTokenKey chat: options: model: gpt-4o如果你用的是 DashScope 那套,就把dashscope.api-key换成 TaoToken 的 Key,base-url指向 TaoToken 的 API 地址。三件套记牢:Base URL 是https://taotoken.net/api,Key 是控制台创建的sk-开头字符串,Model ID 在模型对话页面选。这三个值缺一个,请求都会失败。
4. 验证请求:两条链路各跑一次
配置写完,先启动服务端,再启动客户端。服务端日志里出现Registered tools之类的输出,说明工具注册成功。客户端启动时,你会看到它分别初始化 sse 连接和 stdio 子进程,日志里会有McpSyncClient相关的初始化信息。
先验证 sse。客户端里写一个 Controller,把ToolCallbackProvider注入 ChatClient:
@RestController @RequestMapping("/dashscope/chat-client") public class ChatController { private final ChatClient chatClient; private final ChatMemory chatMemory = new InMemoryChatMemory(); public ChatController(ChatClient.Builder builder, List<McpSyncClient> mcpSyncClients, ToolCallbackProvider tools) { this.chatClient = builder .defaultTools(tools) .build(); } @RequestMapping(value = "/generate_stream", method = RequestMethod.GET) public Flux<ChatResponse> generateStream(HttpServletResponse response, @RequestParam("id") String id, @RequestParam("prompt") String prompt) { response.setCharacterEncoding("UTF-8"); var advisor = new MessageChatMemoryAdvisor(chatMemory, id, 10); return this.chatClient.prompt() .user(prompt) .advisors(advisor) .stream() .chatResponse() .onErrorResume(e -> { System.out.println("Error: " + e.getMessage()); return Mono.empty(); }); } }启动后访问http://localhost:9999/dashscope/chat-client/generate_stream?id=01&prompt=西安天气怎么样,如果 sse 链路通了,你会看到流式返回里模型调用了Weather0工具,服务端控制台打印出「查询城市:西安」,返回「晴天」。这一步成功,说明 sse 通道 + TaoToken 模型通道都通了。
再验证 stdio。把 prompt 换成「帮我查一下从西安到北京的路线」,如果百度地图那个 stdio 服务正常,客户端会启动uv子进程,日志里能看到子进程的 stderr 输出。stdio 的调试比 sse 麻烦一点,因为你看不到 HTTP 请求,只能靠日志。建议在mcp-server.json的env里加一个DEBUG=1,让 Python 脚本多打点日志。
两条链路都跑通后,你可以做一个交叉验证:把 sse 的url临时改成一个不存在的端口,重启客户端,观察报错信息;再把 stdio 的command改成一个不存在的命令,观察另一种报错。这样你对两种链路的失败模式就有直觉了。
验证模型通道是否真的走了 TaoToken,最简单的办法是看客户端启动日志里的 base-url,或者在 TaoToken 控制台的用量页面看请求记录。如果请求记录里有你刚才的调用,说明通道切换成功。
5. 常见报错排查:401、local proxy failed、reading choices
联调阶段最容易撞上的几个报错,我按出现频率排一下。
第一个是401 Unauthorized。这个几乎都是 Key 的问题。检查三件事:Key 是不是复制完整了,有没有多余空格;base-url是不是写成了https://taotoken.net/api,有没有漏掉/api;模型 ID 是不是你账号里有权限的。如果 Key 没问题但还是 401,去控制台确认一下 Key 有没有被禁用或过期。注意不要在任何配置文件里把 Key 提交到 Git,用环境变量注入更安全。
第二个是local proxy failed或Connection refused。这个通常出现在 sse 链路,说明客户端连不上服务端的 sse 端点。先确认服务端真的起来了,用curl http://127.0.0.1:8080/sse看有没有event: endpoint返回。如果服务端正常但客户端还是连不上,检查spring.ai.mcp.client.sse.connections.server1.url是不是只写了http://127.0.0.1:8080,不要带/sse后缀。另外确认客户端和服务端不在同一个端口上,我这边服务端 8080、客户端 9999,如果你两个都设成 8080 会端口冲突。
第三个是Error reading choices或reading choices相关的解析错误。这个多半是模型返回格式和客户端预期不一致。Spring AI 的 OpenAI 客户端期望标准的choices数组,如果你用的模型返回了非标准结构,就会解析失败。解决办法是确认model字段填的是 TaoToken 支持的模型 ID,不要填一个不存在的名字。另外检查base-url有没有多写/v1,有些客户端会自动拼/v1/chat/completions,你多写一层就变成/v1/v1/...,返回的就不是标准结构了。
第四个是 stdio 子进程启动失败,报Cannot run program "uv"。这是 PATH 问题,Windows 下uv装完可能没进系统 PATH。你可以在mcp-server.json里把command写成绝对路径,比如C:\\Users\\你的用户名\\.local\\bin\\uv.exe。Mac 或 Linux 下用which uv确认路径。还有一个坑是args里的路径用了单反斜杠,JSON 里必须双写\\,否则解析会出错。
第五个是 sse 连接 30 秒后自动断开。这个就是前面提到的mcp-spring-webmvc版本问题,确保你显式引入了 0.8.1,并且把 starter 里的旧版本 exclude 掉。如果还是断,检查request-timeout是不是设得太短,我这边设的 60s。
排错时建议把日志级别调到 DEBUG,在application.yaml里加:
logging: level: org.springframework.ai.mcp: DEBUG io.modelcontextprotocol: DEBUG这样你能看到完整的 JSON-RPC 消息往来,定位问题快很多。如果排查完还是不通,可以去接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 对照一下参数,或者到 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 重新生成一个 Key 试试。
6. 把两条链路固定成你的本地开发模板
跑通一次不算完,真正省时间的是把它固化成模板。我的做法是建一个mcp-client-template仓库,application.yaml里 sse 和 stdio 两段配置都留着,用 Spring 的 profile 切换。本地联调时用localprofile,sse 指向127.0.0.1:8080,stdio 指向本地脚本;需要连远程工具时切到remoteprofile,只改 sse 的 url。模型通道统一走 TaoToken,Key 用环境变量TAOTOKEN_API_KEY注入,配置文件里写${TAOTOKEN_API_KEY},这样模板可以直接分享,不会泄露 Key。
stdio 的mcp-server.json建议每个工具单独一个文件,比如mcp-baidu-map.json、mcp-filesystem.json,在application.yaml里用逗号分隔加载多个。Spring AI 支持servers-configuration指向多个 classpath 资源,这样你加新工具时不用动主配置。
还有一个实用技巧:在客户端启动时打印一下实际加载的 MCP 客户端列表和工具列表,方便确认配置生效。可以在CommandLineRunner里遍历List<McpSyncClient>,调用listTools()把工具名打出来。这样每次启动你一眼就能看到 sse 和 stdio 各注册了哪些工具,比翻日志快。
最后提醒一句,MCP Server 不要直连生产数据库。本地联调就用 mock 数据或者测试库,工具描述里写清楚是测试环境。等链路稳定了,再考虑把工具服务单独部署,客户端通过 sse 连过去,stdio 只留给纯本地的命令行工具。这样职责清晰,出问题时也容易定位是哪一段的锅。