1. 为什么要在 Spring AI 里用 MCP 做联网搜索
大模型的语料库有截止时间,这是绕不开的硬伤。你问它今天某个框架发没发新版本、某个报错最近有没有人踩过,它要么答不上来,要么一本正经地编。解决办法就是给它接一个「联网搜索」的工具,让它先查再答。MCP(Model Context Protocol)就是干这个的:它把「模型怎么调用外部工具」这件事标准化了,你可以把它理解成 AI 应用和外部数据源之间的 USB-C 接口,插上就能用,不用为每个模型单独写一套适配。
Spring AI 从 1.0.0-M6 开始对 MCP 的支持已经比较完整,提供了 STDIO 和基于 HTTP 的 SSE 两种传输方式。本文走的是 WebFlux 路线,也就是spring-ai-mcp-client-webflux-spring-boot-starter和spring-ai-mcp-server-webflux-spring-boot-starter这一套。选它的原因很实际:SSE 是长连接,WebFlux 的非阻塞模型在高并发下资源占用更低,后续部署到容器里做水平扩展也省心,不像 STDIO 那样必须把服务端和客户端绑在同一台机器上。
但真正落地时还有第二个坑:Key 太分散。搜索工具要一个 Key(比如 Tavily),对话模型要一个 Key(OpenAI 或别的),换个模型就得改 Base URL、改配置、重启服务。我试过在三个模型之间来回切,每次都要翻配置文件,很烦。所以这篇的完整链路是:MCP 服务端封装联网搜索工具 → MCP 客户端用 WebFlux SSE 调用 → 对话模型统一走 TaoToken 的 Key 和 Base URL。这样你只需要维护一个 Key,换模型只改一个 model 字段。
适合谁看:正在用 Spring Boot 做 AI 应用、想让模型具备实时联网能力、又不想被多套 Key 和 Base URL 折腾的 Java 开发者。下面从依赖到配置到验证,一步步给全。
2. TaoToken 前置准备:统一 Key 与 Base URL 的接入方式
在写代码之前,先把「模型侧」的接入点定下来。TaoToken 在这里扮演的角色是统一的模型接入层:你拿到一个 API Key,配一个 Base URL,就能在 OpenAI 兼容的协议下调不同模型,不用为每个模型单独申请账号、单独记地址。对 Spring AI 来说,它底层就是走 OpenAI 兼容接口,所以只要把base-url和api-key指过去就行。
第一步,去控制台创建 Key。打开https://taotoken.net/console,登录后在 API Keys 页面新建一个,复制出来形如sk-xxxx的字符串。这个 Key 就是后面application.yml里要填的值,别提交到 Git。
第二步,确认 Base URL。TaoToken 的 API 入口是https://taotoken.net/api,注意这里不带任何查询参数,直接作为 OpenAI 客户端的 base-url 使用。Spring AI 的 OpenAI starter 会在后面自动拼/v1/chat/completions这类路径,所以你不要自己再加/v1,否则会变成/api/v1/v1/...这种重复路径,直接 404。
第三步,选模型 ID。在模型对话页面或者文档里能看到当前可用的模型列表,比如gpt-4o-mini、claude-3-5-sonnet之类。这个 ID 要原样填进配置,大小写和连字符都不能错。如果你不确定某个模型 ID 是否可用,最省事的办法是先去https://taotoken.net/models用网页版对话试一句,能出结果再把 ID 抄进配置。
这里有个容易混淆的点:MCP 服务端用的搜索工具(Tavily)和对话模型是两套独立的凭证。Tavily 的 Key 是给搜索 API 用的,TaoToken 的 Key 是给对话模型用的,两者不要混。本文的做法是搜索工具照常用 Tavily,对话模型统一走 TaoToken,这样既保留了联网能力,又解决了多模型切换的问题。
如果你后面要长期跑编码类 Agent,可以了解下 Coding Plan,它针对高频调用场景做了额度优化;只是临时验证模型通不通,用模型对话页面就够了。接入细节和参数说明都在接入文档里,遇到字段对不上时优先查文档。
3. 可复制配置:application.yml 与 MCP 工具注册代码
这一节是核心,配置和代码都给全,你直接抄改 Key 就能跑。整体分两个工程:mcp-server(封装搜索工具)和mcp-client(调用工具 + 对话)。先看服务端。
服务端pom.xml引入两个依赖,注意版本用1.0.0-M6:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp-server-webflux-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency>服务端application.yml,端口 8090,声明 MCP 服务名和版本:
server: port: 8090 spring: application: name: mcp-server ai: mcp: server: name: mcp-server version: 0.0.1工具注册靠一个@Bean把带@Tool注解的对象暴露出去。McpServerApplication里这样写:
@SpringBootApplication public class McpServerApplication { public static void main(String[] args) { SpringApplication.run(McpServerApplication.class, args); } @Bean public ToolCallbackProvider serverTools(MCPService mcpService) { return MethodToolCallbackProvider.builder() .toolObjects(mcpService) .build(); } }MCPService里定义搜索工具,方法上打@Tool和@ToolParam,描述写清楚,模型靠这个判断什么时候调用:
@Tool(name = "tavilySearch", description = "执行AI驱动的互联网搜索") public String tavilySearch( @ToolParam(description = "搜索关键词", required = true) String query, @ToolParam(description = "最大返回结果数") int maxResults) { try { TavilySearch tavilySearch = new TavilySearch(); List<Map<String, String>> results = tavilySearch.tavilySearch(query, maxResults); return new ObjectMapper().writeValueAsString(results); } catch (Exception e) { return "{\"error\":\"Search failed: " + e.getMessage() + "\"}"; } }TavilySearch用 OkHttp 发 POST 请求,把query和max_results塞进 body,Authorization 头带上 Tavily 的 Key。这里 Key 建议从环境变量读,别硬编码:
private final String baseUrl = "https://api.tavily.com/search"; private final String apiKey = System.getenv("TAVILY_API_KEY");请求体构造和响应解析:
Map<String, Object> requestBody = new HashMap<>(); requestBody.put("query", query); requestBody.put("max_results", maxResults); Request request = new Request.Builder() .url(baseUrl) .post(RequestBody.create( MediaType.parse("application/json"), objectMapper.writeValueAsString(requestBody))) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .build();拿到results数组后,逐条取title、url、content组装成 List 返回。注意原示例里"Bearer" + apiKey少了个空格,正确写法是"Bearer " + apiKey,否则会 401。
客户端pom.xml换成 client 版:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-mcp-client-webflux-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency>客户端application.yml是重点,MCP 连接和模型接入都在这里。注意base-url指向 TaoToken,api-key填你控制台拿到的 Key,model填模型 ID:
server: port: 8080 spring: ai: mcp: client: enabled: true name: mcp-client version: 1.0.0 request-timeout: 120s type: ASYNC sse: connections: server1: url: http://localhost:8090 openai: base-url: https://taotoken.net/api api-key: sk-你的TaoToken密钥 chat: options: model: gpt-4o-mini temperature: 0.7这份配置里三件套齐全:Base URL 是https://taotoken.net/api,Key 是sk-开头那串,Model ID 是gpt-4o-mini。换模型时只动model这一行,Base URL 和 Key 都不用碰,这就是统一接入的价值。
4. 验证请求:WebFlux 流式调用与联网搜索返回结果
配置写完,写一个 Controller 把 MCP 调用和模型流式响应串起来。核心思路是:先用 MCP 客户端调tavilySearch拿到搜索结果,把结果拼进 prompt,再让模型基于搜索结果流式回答。返回类型用Flux<String>,配合produces = MediaType.TEXT_EVENT_STREAM_VALUE实现 SSE 流式输出。
@RestController @RequestMapping("/mcp") public class MCPController { @Autowired private ChatClient chatClient; private final String mcpServerUrl = "http://localhost:8090"; @PostMapping(value = "/client", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> client(String message) { var transport = new WebFluxSseClientTransport( WebClient.builder().baseUrl(mcpServerUrl)); return Flux.using( () -> McpClient.sync(transport).build(), client -> { client.initialize(); List<McpSchema.Tool> tools = client.listTools().tools(); String name = "tavilySearch"; McpSchema.CallToolResult mcpResult = client.callTool( new McpSchema.CallToolRequest(name, Map.of("query", message, "maxResults", 5))); String content = mcpResult.content().toString(); String mcpResultString = "{" + name + " : " + content + "}, "; return Flux.concat( Flux.just(mcpResultString), chatClient.prompt() .user(mcpResultString + message) .stream() .content()); }, client -> client.close()); } }这里有几个细节值得说。Flux.using保证 MCP 客户端在流结束后自动关闭,避免连接泄漏。client.initialize()必须先调,否则listTools会报未初始化。callTool的参数用Map.of传,key 要和@ToolParam里的名字一致,也就是query和maxResults。
启动顺序:先起mcp-server(8090),再起mcp-client(8080)。用 curl 验证:
curl -N -X POST "http://localhost:8080/mcp/client" \ -H "Content-Type: text/plain" \ -d "Spring AI 1.0.0 正式版发布了吗"-N关闭缓冲,能实时看到流式输出。预期结果是:先吐出一段{tavilySearch : [...]}的搜索结果 JSON,紧接着是模型基于这些结果生成的回答。日志里会打印Available Tools = [tavilySearch],说明工具注册成功。
如果模型回答里引用了搜索结果里的链接和内容,说明整条链路通了。这时候你换model字段成另一个模型 ID,重启客户端,同样的请求会由新模型回答,而 Base URL 和 Key 完全没动。这就是把 endpoint 改到 TaoToken 之后最直观的收益。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
跑不通的时候,报错基本集中在几个地方。下面按真实遇到的顺序列。
401 Unauthorized。两种可能:一是 TaoToken 的 Key 填错或过期,去控制台重新生成一个;二是 Tavily 的 Authorization 头少了空格,"Bearer" + apiKey会变成Bearertvly-xxx,服务端认不出来。改成"Bearer " + apiKey。另外确认base-url是https://taotoken.net/api,不要自己加/v1。
local proxy failed / Connection refused。客户端连不上 8090,先确认mcp-server已经启动,再确认sse.connections.server1.url写的是http://localhost:8090而不是别的端口。如果服务端和客户端不在同一台机器,把 localhost 换成实际 IP,并确认防火墙放行。
Error reading choices / 返回体解析失败。这个多半是 Base URL 拼错导致返回了 HTML 错误页,模型客户端按 JSON 解析就炸了。检查base-url末尾有没有多余的斜杠,正确是https://taotoken.net/api,不是https://taotoken.net/api/。还有一种情况是模型 ID 写错,服务端返回错误结构,同样会报 reading choices。
OAuth / 认证方式不匹配。如果你用的是需要 OAuth 的模型接入方式,而配置里只填了 api-key,会握手失败。TaoToken 走的是 API Key 方式,确认没有混入其他认证配置。Codex 的auth.json那套是另一条链路,本文不涉及,别把两种配置混在一个工程里。
MCP 工具没被调用。日志里Available Tools是空的,说明服务端的ToolCallbackProviderBean 没生效。检查@Tool注解的方法所在类是否被toolObjects引用,以及该类是否是 Spring Bean。还有一种情况是模型没触发工具调用,把@Tool的 description 写得更明确,比如加上「当需要实时信息时调用」。
流式输出卡住不返回。request-timeout设太短,搜索加模型推理超过阈值就断了。设成120s或更长。另外type: ASYNC要保留,同步模式在 WebFlux 下会阻塞事件循环。
排查顺序建议:先单独 curl Tavily API 确认搜索 Key 有效,再单独 curl TaoToken 的对话接口确认模型 Key 有效,最后跑整条链路。这样能把问题定位到具体哪一段,不用瞎猜。
6. 把 Key 收拢到一处,后续换模型只改一行
整条链路跑通之后,你会发现维护成本降了很多。搜索工具那边 Tavily 的 Key 用环境变量注入,对话模型这边 TaoToken 的 Key 和 Base URL 写在application.yml一处,换模型只动model字段。MCP 的 WebFlux SSE 传输让服务端可以独立部署,客户端按需扩容,不用把搜索逻辑和对话逻辑耦在一起。
如果你要接着往下做,几个方向可以试:把tavilySearch的maxResults做成可配置,避免每次搜太多拖慢响应;给 MCP 客户端加连接池复用,减少每次请求重建 transport 的开销;把搜索结果做一层缓存,相同 query 短时间内不重复打 Tavily。这些都是在现有骨架上加,不用改协议。
需要再确认 Key 和模型列表,去控制台和模型对话页面看;接入字段对不上时查接入文档;长期跑编码类任务可以了解 Coding Plan。地址都在上面配置里出现过,照着填就行。