1. 为什么 Spring 里改 sse-endpoint 总是不生效
如果你正在用 Spring AI 搭 MCP Server,大概率踩过这个坑:在application.yml里老老实实写了spring.ai.mcp.server.sse-endpoint: /demo/sse,服务能正常启动,日志也没报错,但用 Postman 去请求http://localhost:8080/demo/sse,返回的却是 404。换成默认的/sse反而通了。
这不是你配置写错了,而是当前 Spring AI 的 MCP 自动配置里,sse-endpoint这个属性并没有被真正读取到WebMvcSseServerTransportProvider的构造参数里。换句话说,YAML 里那个 key 只是个“摆设”,框架内部始终用默认的/sse作为端点。这个问题在 MCP Client 侧同样存在——Client 的自动配置类里压根没有暴露自定义 sse-endpoint 的入口,你想改都找不到地方改。
这篇就围绕这个真实场景展开:怎么在 Spring 项目里通过自定义@Configuration覆盖官方自动配置,让 Server 和 Client 两端都能用上你想要的 sse-endpoint,并且把整条链路和统一 Key/API 通道(TaoToken)联调打通。适合已经能跑起 Spring Boot、正在接 MCP、需要把 AI 工具接到统一通道上的开发者。下面给的配置骨架可以直接复制,改改包名就能用。
2. 前置准备:TaoToken 通道与依赖确认
在动配置之前,先把“通道”这件事理清楚。MCP 本身解决的是工具调用协议,但模型请求最终要落到一个统一的 API 入口上。我这边习惯用 TaoToken 做统一 Key 管理,好处是多个 AI 工具、多个项目共用一个 Key,不用每个地方都去配一遍。
你需要先拿到一个可用的 API Key。登录官网后进入控制台,在 API Keys 页面创建一个新 Key,复制出来备用。地址是:
- 官网入口:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- 控制台 / API Keys:https://taotoken.net/console/api-keys?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=
API 的基础地址是https://taotoken.net/api(这个不加 UTM,直接用于代码里的 base_url)。
依赖方面,确认你的pom.xml里有 Spring AI 的 MCP 相关 starter。以 WebMvc 为例,核心是这两个:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>版本上建议用 1.0.0-M6 及之后的里程碑版本,早期版本里WebMvcSseServerTransportProvider的构造函数签名不太一样,照抄代码可能会编译不过。如果你不确定自己用的版本,先在 IDE 里点进这个类看一眼构造参数,再对照下面的代码调整。
注意:MCP 的 SSE 端点和普通 REST 端点不一样,它是长连接 + 事件流,Postman 请求时要用 GET 并且保持连接,不要用普通的短请求去测。
3. 可复制配置:Server 端自定义 sse-endpoint 骨架
先解决 Server 端。核心思路是:自己写一个@Configuration,手动 new 一个WebMvcSseServerTransportProvider,把从McpServerProperties里读到的sseEndpoint和sseMessageEndpoint传进去,并用@Primary让它覆盖官方自动配置里的那个 Bean。
package com.example.mcp.config; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.ai.mcp.server.autoconfigure.McpServerProperties; import org.springframework.ai.mcp.server.webmvc.transport.WebMvcSseServerTransportProvider; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.ServerResponse; @Configuration public class MyMcpServerConfig { @Bean @Primary public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider( ObjectProvider<ObjectMapper> objectMapperProvider, McpServerProperties serverProperties) { ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); return new WebMvcSseServerTransportProvider( objectMapper, serverProperties.getSseMessageEndpoint(), serverProperties.getSseEndpoint()); } @Bean public RouterFunction<ServerResponse> mvcMcpRouterFunction( WebMvcSseServerTransportProvider transportProvider) { return transportProvider.getRouterFunction(); } }对应的application.yml配置:
spring: ai: mcp: server: name: my-mcp-server version: 1.0.0 sse-endpoint: /demo/sse sse-message-endpoint: /demo/mcp/message这里有两个点容易忽略。第一,sse-message-endpoint也要一起配,因为 SSE 是双向的,客户端发消息走的是 message 端点,只改 sse-endpoint 会导致握手成功但消息发不出去。第二,@Primary必须加,否则容器里会有两个同类型的WebMvcSseServerTransportProvider,启动时直接报 Bean 冲突。
改完之后重启,再用 Postman 发一个 GET 请求到http://localhost:8080/demo/sse,你会看到连接保持住并返回event: endpoint开头的事件流,这就说明自定义端点生效了。
4. Client 端同步:自定义 SseClientProperties 与排除自动配置
Server 端通了不代表 Client 端能用。Client 的自动配置类SseHttpClientTransportAutoConfiguration里,读取的是McpSseClientProperties,而这个类里根本没有sseEndpoint这个字段,所以你在 YAML 里写sse-endpoint它也不认。解决办法是自己定义一个属性类,再写一个配置类手动构建 transport。
先定义属性类:
package com.example.mcp.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.Map; @Component @ConfigurationProperties(prefix = "spring.ai.mcp.client") public class SseClientProperties { private final Map<String, Connection> connections = new HashMap<>(); public Map<String, Connection> getConnections() { return connections; } public static class Connection { private String url; private String sseEndpoint; public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public String getSseEndpoint() { return sseEndpoint; } public void setSseEndpoint(String sseEndpoint) { this.sseEndpoint = sseEndpoint; } } }再写 Client 配置类:
package com.example.mcp.config; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.client.transport.NamedClientMcpTransport; import org.springframework.ai.mcp.client.autoconfigure.properties.McpSseClientProperties; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.net.http.HttpClient; import java.util.ArrayList; import java.util.List; import java.util.Map; @Configuration public class MyMcpSseConfig { private final SseClientProperties sseClientProperties; public MyMcpSseConfig(SseClientProperties sseClientProperties) { this.sseClientProperties = sseClientProperties; } @Bean public List<NamedClientMcpTransport> mcpHttpClientTransports( ObjectProvider<ObjectMapper> objectMapperProvider) { ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); List<NamedClientMcpTransport> transports = new ArrayList<>(); for (Map.Entry<String, SseClientProperties.Connection> entry : sseClientProperties.getConnections().entrySet()) { SseClientProperties.Connection conn = entry.getValue(); var transport = new HttpClientSseClientTransport( HttpClient.newBuilder(), conn.getUrl(), conn.getSseEndpoint(), objectMapper); transports.add(new NamedClientMcpTransport(entry.getKey(), transport)); } return transports; } }YAML 里对应加:
spring: ai: mcp: client: connections: my-server: url: http://localhost:8080 sse-endpoint: /demo/sse最后一步很关键:官方自动配置类SseHttpClientTransportAutoConfiguration也会注册一个mcpHttpClientTransportsBean,和你自己写的这个冲突。必须在启动类上把它排除掉:
@SpringBootApplication(exclude = { org.springframework.ai.mcp.client.autoconfigure.SseHttpClientTransportAutoConfiguration.class }) public class McpDemoApplication { public static void main(String[] args) { SpringApplication.run(McpDemoApplication.class, args); } }排除之后重启,控制台不再报 Bean 重复注册的错误,Client 就会用你自定义的 sse-endpoint 去连 Server 了。
5. 联调验证:从 Postman 到模型对话的完整链路
配置写完,得验证整条链路真的通。分三步走。
第一步,验证 Server 端点。用 Postman 新建一个 GET 请求,地址http://localhost:8080/demo/sse,Headers 里加Accept: text/event-stream。发送后不要断开,你会看到响应体里持续输出事件流,第一行通常是event: endpoint,后面跟着data:/demo/mcp/message?sessionId=xxx。这说明 SSE 握手成功,Server 已经准备好接收消息。
第二步,验证 Client 能拉到工具列表。启动 Client 应用,观察日志里有没有Discovered tools之类的输出。如果 Client 成功连上 Server,它会自动调用tools/list并把 Server 里注册的工具拉过来。你可以在 Server 端写一个简单的@Tool方法做测试:
@Component public class DemoTools { @Tool(description = "返回两个整数之和") public int add(int a, int b) { return a + b; } }第三步,走一次真实的模型对话。在 Client 端调用模型时,把 base_url 指向 TaoToken 的 API 地址,Key 用你在控制台创建的那个。配置大概长这样:
spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini然后发一句“帮我算一下 3 加 5 等于几”,如果模型正确调用了add工具并返回 8,说明 MCP 工具调用 + 统一通道整条链路都通了。想单独验证模型通道是否正常,可以直接用模型对话页面发一条消息测试:https://taotoken.net/model-chat?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=
6. 本篇常见报错排查
404 Not Found(Server 端):最常见的原因是只改了 YAML 没写自定义 Config,或者 Config 里忘了加@Primary。检查WebMvcSseServerTransportProvider这个 Bean 是不是你自定义的那个在生效,可以在启动日志里加断点确认。
Bean 重复注册 / NoUniqueBeanDefinitionException:Server 端是WebMvcSseServerTransportProvider冲突,Client 端是mcpHttpClientTransports冲突。前者加@Primary,后者在启动类exclude掉官方自动配置类。
Client 连不上 Server,日志报 connection refused:先确认 Server 的url和sse-endpoint拼起来是不是完整地址。注意url只写到端口,sse-endpoint以/开头,两者拼接后才是完整路径。另外确认 Server 和 Client 不在同一个端口上,避免自己连自己。
SSE 握手成功但工具调用无响应:大概率是sse-message-endpoint没配或者配错了。SSE 是单向事件流,客户端发消息走的是 message 端点,两个端点必须成对配置且路径不要重复。
模型请求返回 401:检查 TaoToken 的 API Key 是否复制完整,以及base-url是不是https://taotoken.net/api。如果 Key 没问题,去控制台确认一下这个 Key 的额度是否还有剩余。
编译报错找不到 HttpClientSseClientTransport 构造函数:这是 Spring AI 版本差异导致的。M6 之前和之后的构造函数参数顺序、个数都可能不同。解决办法是点进这个类看当前版本的构造签名,按实际参数调整,不要硬套本文代码。
排查完这些,基本就能稳定跑起来了。接入相关的完整参数说明可以对照官方文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=