Spring AI 2.0.1 深度解析-第 15 章 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理
第 14 章解决了"MCP 服务端的工具从哪来"------注解怎么被扫、
SyncToolSpecification怎么被造出来、返回值怎么变成CallToolResult。但还有一个更前置的问题没回答:客户端的请求是怎么进来的,进来之后服务端要不要记住它?
这看起来是个配置问题(
spring.ai.mcp.server.protocol填个值就行),实际上它是 Spring AI 2.0.1 里条件判定最绕 的一块:字段默认值和实际生效值相反,两种配置组合会"静默什么都不装配",而会话相关的两个关键参数根本没有配置属性。
15.0 本章的五句总纲
-
protocol的字段默认值与实际生效值相反。McpServerProperties.protocol初始化为STREAMABLE(McpServerProperties:100),但NonStatelessServerCondition里 SSE 分支是matchIfMissing = true、STREAMABLE 分支是matchIfMissing = false(McpServerAutoConfiguration:349-356)。于是不写这个属性时生效的是 SSE ------而McpServerSseWebMvcAutoConfiguration带着@Deprecated(forRemoval = true)(:76)。 -
有状态与无状态是两套完全不同的装配,不是同一个开关的两个值。
McpServerAutoConfiguration与McpServerStatelessAutoConfiguration各自定义capabilitiesBuilder、各自的 spec 来源、各自的 server Bean。STATELESS 下变化通知被硬编码为 false ,连McpSyncServerCustomizer与roots回调都没有入口。 -
Streamable HTTP 的会话是纯内存的,且默认永不回收。
sessions是一个ConcurrentHashMap(WebMvcStreamableServerTransportProvider:112);maxSessions默认100_000、没有配置属性 ;sessionIdleTimeout默认null、也没有配置属性 ,且为 null 时连清理调度器都不启动(:196-199)。 -
SSE 与 Streamable 的会话标识机制完全不同。 SSE 用 query param (
?sessionId=,通过endpoint事件告知客户端,WebMvcSseServerTransportProvider:110、:347、:365-372);Streamable 用Mcp-Session-Id请求头 (WebMvcStreamableServerTransportProvider:463、:571)。SSE 的会话在连接断开时自动移除(:332-343),Streamable 的必须显式DELETE。 -
WebMVC 与 WebFlux 的传输实现是两套代码,且 WebMVC 那套大量
block()。WebMvcStreamableServerTransportProvider.handlePost里session.accept(...).block()、session.responseStream(...).block()(:476-478、:505-507),源码注释直言 "Block for WebMVC compatibility"。加上immediateExecution在两种环境下的取值不同(15.5.4、15.6),选型时不能只看"我用的是 MVC 还是 WebFlux"这一个维度。
本章的行文范围:
protocol与stdio的条件矩阵(15.1)、STDIO(15.2)、Streamable HTTP(15.3)、SSE(15.4)、STATELESS(15.5)、SYNC/ASYNC 与配置属性全集(15.6)、两类 Web 栈的对照与选型(15.7)、上线清单(15.8)。与第 14 章的分工:第 14 章讲"工具",本章讲"通道"。两章的引用会互相穿越,凡是形如"第 14 章 14.4.1"的引用都指向上一章。
15.1 三种协议形态:条件矩阵与实际默认值
15.1.1 protocol 枚举与它的三个值
java
public enum ServerProtocol { SSE, STREAMABLE, STATELESS }
(McpServerProperties:136-140)
java
private ServerProtocol protocol = ServerProtocol.STREAMABLE; // :100
字段默认是 STREAMABLE。但这个默认值与自动配置的判定逻辑不一致,这是本节最重要的结论。
15.1.2 四个条件类
| 条件类 | 位置 | 判定 |
|---|---|---|
McpServerStdioDisabledCondition |
McpServerStdioDisabledCondition:30-48 |
enabled=true(缺省 true)且 stdio=false(缺省 false) |
NonStatelessServerCondition |
McpServerAutoConfiguration:343-361 |
protocol=SSE(matchIfMissing=true )或 protocol=STREAMABLE(matchIfMissing=false) |
EnabledSseServerCondition |
McpServerAutoConfiguration:363-381 |
enabled=true 且 protocol=SSE(matchIfMissing=true) |
EnabledStreamableServerCondition |
McpServerAutoConfiguration:383-401 |
enabled=true 且 protocol=STREAMABLE(matchIfMissing=false) |
EnabledStatelessServerCondition |
McpServerStatelessAutoConfiguration:254-272 |
enabled=true 且 protocol=STATELESS(matchIfMissing=false) |
关键点在 matchIfMissing:
NonStatelessServerCondition是AnyNestedCondition(或 )。当protocol未配置 时,SSE 子条件因为matchIfMissing=true而成立 ,于是整个条件成立 →McpServerAutoConfiguration生效(有状态)。- 但具体用哪个传输 ,看
EnabledSseServerConditionvsEnabledStreamableServerCondition:同样在未配置时,SSE 成立、STREAMABLE 不成立 →McpServerSseWebMvcAutoConfiguration生效。
于是:
不写
spring.ai.mcp.server.protocol时,你得到的是 SSE 传输(McpServerSseWebMvcAutoConfiguration),而这个类已经被标记为@Deprecated(since = "2.0.0", forRemoval = true)(:76)。同时
McpServerProperties.protocol字段的值却是STREAMABLE------如果你在代码里@Autowired McpServerProperties然后getProtocol(),你读到的是STREAMABLE,而实际跑的是 SSE。字段值与生效值相反。
这一条值得写成配置规范:所有 MCP 服务端项目都必须显式写 spring.ai.mcp.server.protocol。
15.1.3 完整矩阵
stdio |
protocol |
生效的传输配置 | 生效的 server 配置 | 备注 |
|---|---|---|---|---|
false(默认) |
未配置 | McpServerSse*AutoConfiguration |
McpServerAutoConfiguration |
默认路径,但走的是已废弃的 SSE |
false |
SSE |
McpServerSse*AutoConfiguration |
同上 | 显式 SSE |
false |
STREAMABLE |
McpServerStreamableHttp*AutoConfiguration |
同上 | 推荐 |
false |
STATELESS |
McpServerStateless*AutoConfiguration |
McpServerStatelessAutoConfiguration |
无状态 |
true |
未配置 | 无(web 传输被 McpServerStdioDisabledCondition 挡掉) |
McpServerAutoConfiguration + 兜底的 STDIO transport |
STDIO 服务端 |
true |
STREAMABLE |
无(同上) | 同上(兜底 STDIO) | protocol 被忽略,静默 |
true |
STATELESS |
无 | 都没有 ------McpServerStatelessAutoConfiguration 需要 McpServerStdioDisabledCondition |
什么都没有,静默 |
最后一行是真正的坑:stdio=true + protocol=STATELESS 时,McpServerStatelessAutoConfiguration 的 @Conditional(:62-63)里包含 McpServerStdioDisabledCondition,它要求 stdio=false → 条件不成立;而 McpServerAutoConfiguration 的 NonStatelessServerCondition 要求 SSE 或 STREAMABLE → 也不成立。结果是既没有 server Bean 也没有 transport Bean,应用正常启动,日志里没有任何 MCP 相关的行。
诊断方法:启动后搜索日志里的 Registered tools:(McpServerAutoConfiguration:143)或 Enable tools capabilities(:134)------一个都没有就是没装配上。
15.1.4 STDIO 的两个细节
① 兜底 Bean 无条件注册。 前面 14.2.1 说过,stdioServerTransport()(:91-96)只有 @ConditionalOnMissingBean,不检查 stdio 属性。所以任何"没有 web 传输 Bean"的场景都会得到 STDIO。
② immediateExecution 不会被设置。 McpServerAutoConfiguration 里那个 servletMcpSyncServerCustomizer(:219-225)是 @ConditionalOnWebApplication(type = SERVLET) 的,STDIO 应用(非 web)不会命中。这意味着 STDIO 服务端的同步工具在调用线程上直接执行------对 STDIO 是合理的(单连接串行),不需要额外线程池。
15.1.5 SSE 的废弃状态
与客户端一致(第 13 章 13.2),服务端 SSE 也带着 forRemoval:
java
@Deprecated(since = "2.0.0", forRemoval = true)
public class McpServerSseWebMvcAutoConfiguration { ... } // :76
@Deprecated(since = "2.0.0", forRemoval = true)
public class McpServerSseProperties { ... } // McpServerSseProperties:30
McpServerSseProperties 的 CONFIG_PREFIX 是 spring.ai.mcp.server(:33),与 McpServerProperties 相同前缀 。也就是说 spring.ai.mcp.server.base-url、sse-endpoint、sse-message-endpoint、keep-alive-interval 这些属性属于 SSE properties,而 name/version/type/protocol 属于 McpServerProperties------它们共享同一个 spring.ai.mcp.server.* 命名空间,靠不同的字段名区分。
注意 McpServerSseProperties.baseUrl 默认是空字符串 (:37),sseEndpoint 默认 /sse,sseMessageEndpoint 默认 /mcp/message(:43、:48)。
15.2 STDIO:最"原始"也最容易被日志搞坏的传输
STDIO 是 MCP 最早支持的传输形态,也是唯一一个不依赖任何 Web 栈 的形态。它的模型极其简单:客户端 fork/exec 一个子进程,通过子进程的 stdin 发 JSON-RPC、从 stdout 读 JSON-RPC。进程即会话。
15.2.1 装配路径
回顾第 14 章 14.2.1 的那张图:STDIO 有两个入口。
入口 A(显式) :spring.ai.mcp.server.stdio=true,McpServerStdioDisabledCondition 不成立 → 三个 Web 传输配置都不生效 → McpServerAutoConfiguration.stdioServerTransport() 作为兜底注册:
java
@Bean
@ConditionalOnMissingBean
public McpServerTransportProviderBase stdioServerTransport(
@Qualifier("mcpServerJsonMapper") JsonMapper mcpServerJsonMapper) {
return new StdioServerTransportProvider(new JacksonMcpJsonMapper(mcpServerJsonMapper));
}
(McpServerAutoConfiguration:91-96)
入口 B(隐式) :你没有 设 stdio=true,但 classpath 上没有可用的 Web 传输实现(比如只引了 spring-ai-starter-mcp-server,没有 web starter),且 protocol 未配置或配成了 STREAMABLE------此时 Web 传输配置不生效,兜底 Bean 依然会被注册,你得到一个 STDIO 服务端。
入口 B 是"静默"的:应用照常启动,日志里会有 Enable tools capabilities / Registered tools: N(:134、:143),然后静静地在 stdin 上等待输入 。如果它是个 web 应用,你会看到端口正常监听但 /mcp 404。
15.2.2 必须的三条工程约束
① stdout 只能承载 JSON-RPC。
这是 STDIO 传输的硬性协议要求:客户端把子进程 stdout 的每一行当作一个 JSON-RPC 消息解析。任何非 JSON-RPC 的输出(Spring Boot 的启动 banner、INFO 日志、异常堆栈、JVM 警告)都会让客户端解析失败。
Spring Boot 默认把日志写到 stdout ,默认情况下连 ASCII banner 都会打出来。所以一个"能跑起来"的 Spring Boot 应用直接改成 STDIO 服务端必然失败。
最小配置:
yaml
spring:
main:
banner-mode: off # 关掉 banner
web-application-type: none # 不要起 web 服务器
ai:
mcp:
server:
stdio: true
logging:
pattern:
console: # 置空:console appender 不再输出
file:
name: /var/log/mcp-server.log # 日志全部走文件
更彻底的做法是用 logback-spring.xml 把 ConsoleAppender 换成输出到 stderr 的 appender(System.err)------STDIO 的约定里 stderr 是给人看的,stdout 是给机器看的:
xml
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder><pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern></encoder>
</appender>
<root level="INFO"><appender-ref ref="STDERR" /></root>
</configuration>
顺带说一句:这也是 STDIO 服务端难以排障 的根源------你最想要的日志恰恰是你最不能输出的东西。开发期建议先用
protocol=STREAMABLE把工具调通,最后再切 STDIO。
② web-application-type: none。
spring-ai-starter-mcp-server 只依赖 spring-boot-starter(不含 web),所以默认就是 NONE。但如果你的应用同时还要暴露 actuator 或别的 HTTP 端点,就会引入 web starter,Spring Boot 推断出 SERVLET 类型并起 Tomcat。此时要特别注意 McpServerAutoConfiguration 的类级条件只有 NonStatelessServerCondition (:84-86),它完全不看 stdio 属性 ------所以 stdio=true 时 McpServerAutoConfiguration 照样生效,它会拿兜底的 STDIO transport 建 server。
于是你会同时得到:一个在 stdin 上等输入的 MCP 服务端 + 一个空转的 Tomcat(占端口、耗资源)。这不是 bug,是"条件设计没有覆盖到的组合"。显式设 web-application-type: none 可以避免。
③ 不要依赖 immediateExecution。
第 14 章 14.2.1 说过,servletMcpSyncServerCustomizer(McpServerAutoConfiguration:219-225)是 @ConditionalOnWebApplication(type = SERVLET) 的,STDIO 应用不会命中。所以 STDIO 服务端的同步工具在调用线程上直接执行 。这对 STDIO 是合理的(单连接、天然串行),但意味着:一个慢工具会阻塞整个服务端 ------后续所有请求(包括 ping)都要等。
如果你的工具有阻塞 IO,STDIO + SYNC 的组合下没有任何并发保护。给工具加超时 (CompletableFuture.orTimeout、@TimeLimiter、或在工具内部用带超时的 HTTP 客户端)。
15.2.3 STDIO 没有会话,也没有"变化通知"
STDIO 的传输模型里,进程生命周期 = 会话生命周期。所以:
Mcp-Session-Id/sessionId都不存在;spring.ai.mcp.server.change-notification.*(McpServerChangeNotificationProperties,默认全true)依然会被读进capabilitiesBuilder(McpServerAutoConfiguration:136、:151、:179),因为McpServerAutoConfiguration不区分传输类型。能力声明里会带上listChanged: true,但实际上永远不会发通知------STDIO 下客户端进程已经持有连接,工具列表也不会变(spec 是启动时注册的)。这属于"声明了但做不到",无害但有误导性。notifyClients/notifyClient是McpStreamableServerTransportProvider接口的方法,STDIO 传输的实现(SDK 内)行为不在本仓库可见范围内。
15.2.4 STDIO 与 native image 是天生一对
STDIO 服务端通常被分发成一个命令行可执行文件 ,由客户端(Claude Desktop、IDE 插件、自研 Agent)按需启动。这个场景对启动时间极敏感------JVM 冷启动 1-2 秒会被用户感知。
native image 是这里的最优解,但要注意第 14 章 14.10.1 的两个空白:
- 配置属性类与工具 DTO 没有 被自动注册反射(服务端模块没有
aot.factories); - 自定义
MetaProvider需要 AOT 注册(AOT processor 已处理,:93-106); McpSchema的内部类由McpHints覆盖(mcp/common/src/main/resources/META-INF/spring/aot.factories)。
所以一个 native 化的 STDIO 服务端至少要补:
java
@RegisterReflectionForBinding({ WeatherRequest.class, WeatherResult.class })
@ImportRuntimeHints(MyHints.class) // 如有必要
@SpringBootApplication
public class StdioMcpServerApplication { ... }
15.2.5 客户端侧的对应配置
第 13 章 13.2 讲过客户端的 STDIO 传输会 fork/exec 子进程。服务端这边能配合的:
- 用绝对路径的命令与参数 (客户端配置里的
command/args),不要依赖 shell 环境; - 不要打印任何 banner(15.2.2 ①);
- 退出码语义 :进程退出即连接断开,客户端会重试或报错------所以服务端要做的是不要轻易退出 。捕获所有
Error级别异常,try/catch (Throwable)包住 main 是个务实的做法。
15.3 Streamable HTTP 传输:会话是怎么被记住的
这是目前推荐的传输形态。本节以 WebMvcStreamableServerTransportProvider 为例(WebFlux 版本结构一致,见 WebFluxStreamableServerTransportProvider)。
15.3.1 三个端点
java
this.routerFunction = RouterFunctions.route()
.GET(this.mcpEndpoint, this::handleGet)
.POST(this.mcpEndpoint, this::handlePost)
.DELETE(this.mcpEndpoint, this::handleDelete)
.build();
(WebMvcStreamableServerTransportProvider:180-184)
mcpEndpoint 默认 /mcp(McpServerStreamableHttpProperties:36)。三个方法:
| 方法 | 用途 | 关键校验 |
|---|---|---|
GET |
建立 SSE 监听流,或带 Last-Event-ID 做断线重放 |
必须 Accept: text/event-stream;必须有 Mcp-Session-Id |
POST |
发 JSON-RPC 消息(initialize / 请求 / 通知 / 响应) |
必须同时 Accept: text/event-stream 和 application/json |
DELETE |
显式删除会话(可被 disallowDelete 关闭) |
必须有 Mcp-Session-Id |
POST 要求同时接受两种 media type (:434-441),这是 MCP Streamable HTTP 规范的要求(响应可能是 JSON 也可能是 SSE 流)。很多手写 HTTP 客户端(比如 curl 不指定 -H "Accept: ...")会直接拿到 400。
GET 只要求 text/event-stream(:330-332)。
15.3.2 会话表与四个字段
java
private final ConcurrentHashMap<String, McpStreamableServerSession> sessions = new ConcurrentHashMap<>(); // :112
private final long maxSessions; // :132
private final @Nullable Duration sessionIdleTimeout; // :138
private final ConcurrentHashMap<String, Instant> sessionLastAccessTimes = new ConcurrentHashMap<>(); // :143
initialize 时的流程(handleInitRequest,:596-642):
java
if (hasMcpSessionIdHeader) { // ① 已经有会话头还来 initialize
return ServerResponse.status(BAD_REQUEST)...message("Session already initialized");
}
if (this.sessions.size() >= this.maxSessions) { // ② 会话数达上限
return ServerResponse.status(SERVICE_UNAVAILABLE)...message("Max number of sessions reached");
}
McpStreamableServerSession.McpStreamableServerSessionInit init = sf.startSession(initializeRequest);
this.sessions.put(init.session().getId(), init.session());
if (this.sessionIdleTimeout != null) {
this.sessionLastAccessTimes.put(init.session().getId(), Instant.now());
}
try {
McpSchema.InitializeResult initResult = init.initResult().block();
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.MCP_SESSION_ID, init.session().getId()) // ③ 下发会话 ID
.body(new McpSchema.JSONRPCResponse(JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null));
}
catch (Exception e) { ... 500 ... }
会话 ID 由 SDK 的 sessionFactory.startSession(...) 生成,通过 Mcp-Session-Id 响应头下发给客户端。之后客户端每个请求都要带这个头。
关键:会话是纯内存的。 ConcurrentHashMap 没有任何分布式支持。所以:
- 多实例部署时,客户端的第二次请求打到另一个实例 →
Session not found: xxx→ 404 (:466-471)。 - 必须做会话亲和(sticky session) ,或者干脆用
STATELESS(15.5)。
15.3.3 maxSessions 与 sessionIdleTimeout:两个不可配置的默认值
java
private long maxSessions = 100_000L; // Builder 默认,:864
private @Nullable Duration sessionIdleTimeout; // Builder 默认 null,:866
而自动配置只传了四个参数:
java
return WebMvcStreamableServerTransportProvider.builder()
.jsonMapper(new JacksonMcpJsonMapper(jsonMapper))
.mcpEndpoint(serverProperties.getMcpEndpoint())
.keepAliveInterval(serverProperties.getKeepAliveInterval())
.disallowDelete(serverProperties.isDisallowDelete())
.build();
(McpServerStreamableHttpWebMvcAutoConfiguration:57-62;WebFlux 版 McpServerStreamableHttpWebFluxAutoConfiguration 完全一致)
maxSessions 与 sessionIdleTimeout 没有任何配置属性可以改 (McpServerStreamableHttpProperties 只有 mcpEndpoint / keepAliveInterval / disallowDelete 三个字段,:36、:41、:43)。
后果:
maxSessions = 100_000:单个实例最多 10 万个会话。每个会话占一个McpStreamableServerSession对象(内部还有消息队列等)。10 万个会话对 JVM 堆是实实在在的压力,但它不会在你预期的地方失败------它会在 10 万时才返回 503。sessionIdleTimeout = null:evictIdleSessions()直接 return(:663-665),且构造函数里if (sessionIdleTimeout != null)不成立,连清理调度器都不会启动 (:196-199)。
这是 Streamable HTTP 有状态模式最大的运维风险 :客户端
initialize之后如果不发送 DELETE 就消失 (进程被 kill、网络断开、客户端 bug),它的会话会永远留在sessions里,直到进程重启。唯一的救赎是
keepAliveInterval(spring.ai.mcp.server.streamable-http.keep-alive-interval)------它启动一个KeepAliveScheduler(:186-194)周期性对所有会话发 ping,会话关闭失败时会被记 error log,但不会把死会话从sessions里移除 (notifyClients/ keepAlive 的失败只打日志,:238-241)。
要在生产上控制这个风险,只能自己定义 transport Bean (覆盖 @ConditionalOnMissingBean):
java
@Bean
public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransportProvider(
@Qualifier("mcpServerJsonMapper") JsonMapper jsonMapper,
McpServerStreamableHttpProperties props) {
return WebMvcStreamableServerTransportProvider.builder()
.jsonMapper(new JacksonMcpJsonMapper(jsonMapper))
.mcpEndpoint(props.getMcpEndpoint())
.keepAliveInterval(props.getKeepAliveInterval())
.disallowDelete(props.isDisallowDelete())
.maxSessions(5_000) // ← 自己加
.sessionIdleTimeout(Duration.ofMinutes(30)) // ← 自己加
.build();
}
Builder 有个校验(:986-990):
java
if (this.sessionIdleTimeout != null && this.keepAliveInterval != null) {
Assert.isTrue(this.sessionIdleTimeout.compareTo(this.keepAliveInterval) > 0,
"sessionIdleTimeout must be greater than keepAliveInterval, otherwise a connected client "
+ "may be evicted even though keep-alive pings are being sent successfully.");
}
sessionIdleTimeout 必须大于 keepAliveInterval,否则正常保持连接的客户端也会被驱逐。这个 Assert 只在两者都非 null 时生效。
15.3.4 断线重放:Last-Event-ID
java
if (!request.headers().header(HttpHeaders.LAST_EVENT_ID).isEmpty()) {
String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID);
try {
session.replay(lastId)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.toIterable()
.forEach(message -> {
try { sessionTransport.sendMessage(message)...block(); }
catch (Exception e) { ...; sseBuilder.error(e); }
});
}
catch (Exception e) { ...; sseBuilder.error(e); }
}
else {
// 建立新的监听流
McpStreamableServerSession.McpStreamableServerSessionStream listeningStream =
session.listeningStream(sessionTransport);
sseBuilder.onComplete(() -> listeningStream.close());
}
(:364-404)
这是 Streamable HTTP 相对 SSE 的一个实质增强:断线后可以用 Last-Event-ID 续传,不丢消息。
但注意 replay 分支与 listeningStream 分支是互斥 的(if/else):带上 Last-Event-ID 的 GET 只做重放,不会建立新的监听流 。所以客户端的正确姿势是"重放完再发一个不带 Last-Event-ID 的 GET 建立监听"。
另外 replay 用的是 .toIterable().forEach(...) + block()------在 WebMVC 的 SSE 线程上逐条阻塞发送。消息多时会拉长这次 GET 的响应时间。
15.3.5 POST 的四种消息分支
java
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest
&& jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) {
return handleInitRequest(jsonrpcRequest, hasMcpSessionIdHeader(request)); // ①
}
if (!hasMcpSessionIdHeader(request)) { ...400 "Session ID missing"... } // ②
...
if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) {
session.accept(jsonrpcResponse)...block();
return ServerResponse.accepted().build(); // ③ 202
}
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
session.accept(jsonrpcNotification)...block();
return ServerResponse.accepted().build(); // ③ 202
}
else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
return ServerResponse.sse(sseBuilder -> { ... session.responseStream(...) ... }); // ④ SSE 流
}
- ①
initialize:唯一不需要Mcp-Session-Id的请求,且带了 该头反而报 400(:607-612)。 - ② 其他消息必须有会话头 ,否则 400(注意它用的是
METHOD_NOT_FOUND这个错误码,:458,语义上有点怪)。 - ③ 响应与通知 :
202 Accepted,无 body。异步确认。 - ④ 普通请求 :用 SSE 流返回(即使结果只有一个)。这是 Streamable HTTP 的"统一 SSE 响应"模式。
15.3.6 disallowDelete
java
if (this.disallowDelete) { return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build(); }
(:561-563)
spring.ai.mcp.server.streamable-http.disallow-delete=true(默认 false,McpServerStreamableHttpProperties:43)可以关掉 DELETE 端点。在反向代理/网关不允许 DELETE 方法的环境里有用。但关掉之后唯一的会话回收手段就没了(配合 15.3.3 看)。
15.3.7 安全校验钩子
三个 handler 的第一件事都是:
java
try {
var headers = HeaderUtils.collectHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
var message = e.getMessage() != null ? e.getMessage() : "";
return ServerResponse.status(e.getStatusCode()).body(message);
}
(:425-432、:320-327、:552-559)
ServerTransportSecurityValidator 是 SDK 的接口,默认 NOOP(Builder 里 :239)。自动配置没有暴露 这个 builder 方法(15.3.3 那段只传了四个),所以想做 DNS rebinding 防护之类的校验,必须自己定义 transport Bean。
HeaderUtils.collectHeaders(request)(HeaderUtils.java,45 行)把 ServerRequest 的头收集成 Map 交给 validator。
15.3.8 关闭时序
java
@Override
public Mono<Void> closeGracefully() {
return Mono.fromRunnable(() -> {
this.isClosing = true;
this.sessions.values().parallelStream().forEach(session -> {
try { session.closeGracefully().block(); }
catch (Exception e) { ... }
});
this.sessions.clear();
this.sessionLastAccessTimes.clear();
}).then().doOnSuccess(v -> {
if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); }
if (this.idleSessionScheduler != null) { this.idleSessionScheduler.dispose(); }
});
}
(:264-294)
isClosing 是 volatile,三个 handler 开头都检查它并返回 503 Service Unavailable(:316-318、:421-423、:548-550)。
注意 parallelStream() + block():关闭时对所有会话并行阻塞关闭 。会话多时关闭会慢。而且 parallelStream 用的是公共 ForkJoinPool,与业务请求争抢线程。
15.4 SSE:已废弃,但默认路径仍然是它
15.1 的结论是"不写 protocol 时走 SSE"。所以即使它被标记 forRemoval,你也得懂它------因为它是当前的默认行为。
15.4.1 两个端点,两种 HTTP 方法
java
this.routerFunction = RouterFunctions.route()
.GET(this.sseEndpoint, this::handleSseConnection)
.POST(this.messageEndpoint, this::handleMessage)
.build();
(WebMvcSseServerTransportProvider:183-186)
默认 sseEndpoint = /sse、messageEndpoint = /mcp/message(McpServerSseProperties:43、:48)。注意两个端点是不同的路径 ------这与 Streamable 的"GET/POST/DELETE 同一个 /mcp"形成鲜明对比。
三个常量(:103、:108、:110):
java
public static final String MESSAGE_EVENT_TYPE = "message";
public static final String ENDPOINT_EVENT_TYPE = "endpoint";
public static final String SESSION_ID = "sessionId";
15.4.2 GET /sse:建立连接并下发 endpoint
java
return ServerResponse.sse(sseBuilder -> {
WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sseBuilder);
var sf = this.sessionFactory;
if (sf == null) {
sseBuilder.error(new IllegalStateException("SessionFactory not configured"));
return;
}
McpServerSession session = sf.create(sessionTransport);
String sessionId = session.getId();
...
sseBuilder.onComplete(() -> { ...; this.sessions.remove(sessionId); });
sseBuilder.onTimeout(() -> { ...; this.sessions.remove(sessionId); });
this.sessions.put(sessionId, session);
try {
sseBuilder.event(ENDPOINT_EVENT_TYPE).data(buildEndpointUrl(sessionId));
}
catch (Exception e) {
...;
this.sessions.remove(sessionId);
sseBuilder.error(e);
}
}, Duration.ZERO);
(:320-356)
关键点是最后那个 endpoint 事件:
java
private String buildEndpointUrl(String sessionId) {
// for WebMVC compatibility
return UriComponentsBuilder.fromUriString(this.baseUrl)
.path(this.messageEndpoint)
.queryParam(SESSION_ID, sessionId)
.build()
.toUriString();
}
(:365-372)
会话 ID 是通过 SSE 事件的内容告诉客户端的,不是 HTTP 头。 客户端拿到 /mcp/message?sessionId=xxx,后续所有 POST 都打到这个 URL。
这就是 spring.ai.mcp.server.base-url 存在的原因------baseUrl 默认 ""(McpServerSseProperties:37),UriComponentsBuilder.fromUriString("") 拼出来的是相对路径 /mcp/message?sessionId=xxx。多数客户端能处理相对 URL(相对它连 SSE 的那个 host),但经过反向代理、或客户端不在同一 origin 时就会出错。代理场景下必须显式配:
yaml
spring:
ai:
mcp:
server:
base-url: https://mcp.example.com
15.4.3 POST /mcp/message?sessionId=xxx
java
if (request.param(SESSION_ID).isEmpty()) {
return ServerResponse.badRequest()
.body(McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND)
.message("Session ID missing in message endpoint").build());
}
String sessionId = request.param(SESSION_ID).get();
McpServerSession session = this.sessions.get(sessionId);
if (session == null) {
return ServerResponse.status(HttpStatus.NOT_FOUND)
.body(McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Session not found: " + sessionId).build());
}
try {
final McpTransportContext transportContext = this.contextExtractor.extract(request);
String body = request.body(String.class);
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, body);
session.handle(message)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block(); // ← Block for WebMVC compatibility
return ServerResponse.ok().build();
}
catch (IllegalArgumentException | IOException e) { ... 400 ... }
catch (Exception e) { ... 500 ... }
(:385-446)
三处细节:
- 会话 ID 在 query param 里 (
request.param(SESSION_ID)),不是请求头。这意味着 sessionId 会出现在访问日志、浏览器历史、代理日志 里。从安全角度看,把会话凭证放在 URL 里不如放在请求头里------这也是 Streamable HTTP 改用Mcp-Session-Id头的原因之一。 session.handle(message).block()------ 后面那行注释(:424-426)是源码里写着的 "Block for WebMVC compatibility"(被 formatter 拆成了四行)。WebMVC 是 Servlet 阻塞模型,这里必须 block;WebFlux 版本不需要。- POST 永远返回
200 OK(无 body) ,即使工具执行失败。工具的错误在CallToolResult.isError里(第 14 章 14.7),不在 HTTP 状态码里。想从 HTTP 层观测工具失败率------观测不到。
15.4.4 会话生命周期:与 Streamable 的三点不同
| 维度 | SSE | Streamable HTTP |
|---|---|---|
| 会话标识位置 | SSE endpoint 事件 + POST 的 ?sessionId= query param |
Mcp-Session-Id 请求/响应头 |
| 会话移除时机 | 连接断开即移除 (onComplete / onTimeout,:332-343) |
仅 DELETE 或空闲驱逐(默认不驱逐) |
| 上限与回收 | 无 maxSessions、无 sessionIdleTimeout |
有 maxSessions(100_000,不可配)、有 sessionIdleTimeout(null,不可配) |
| 断线重放 | 不支持 | 支持(Last-Event-ID,15.3.4) |
| 端点数 | 2(/sse + /mcp/message) |
1(/mcp,三种方法) |
| 单请求响应 | 只能走 SSE 长连接(异步推回) | 可以直接用 SSE 响应本次请求(15.3.5 ④) |
有意思的是:SSE 反而是"不容易泄漏"的那个。
因为 SSE 的会话在 TCP 连接断开(onComplete)或超时(onTimeout)时被移除------只要客户端进程死了,连接就会断,会话就被清掉。而 Streamable 的会话只靠客户端主动 DELETE(默认还没有空闲驱逐),客户端异常退出就会留下僵尸会话(15.3.3)。
所以"Streamable 更新更先进"这个判断在会话回收 这个具体维度上并不成立。选 Streamable 的理由应该是:单端点、支持断线重放、会话 ID 在头里(更安全)、以及 SSE 已被标 forRemoval。
15.4.5 notifyClients 的实现差异
SSE 版(:220-230)用的是 Reactor 流:
java
return Flux.fromIterable(this.sessions.values())
...
Streamable 版(:222-244)用的是 Mono.fromRunnable + parallelStream() + 逐条 block():
java
return Mono.fromRunnable(() -> {
this.sessions.values().parallelStream().forEach(session -> {
try { session.sendNotification(method, params).block(); }
catch (Exception e) { ... error log ... }
});
});
两个实现都能"广播到所有会话",但 Streamable 版是阻塞 + 并行 的,SSE 版是响应式串行 的。会话数多时,Streamable 的广播会占用 ForkJoinPool.commonPool 的线程。
(同样的 parallelStream() + block() 组合也出现在 Streamable 的 closeGracefully 里,15.3.8。)
15.4.6 迁移到 Streamable 的清单
markdown
1. spring.ai.mcp.server.protocol: STREAMABLE (必须显式写)
2. 端点合并:/sse 与 /mcp/message → /mcp
- 客户端配置里的 URL 要改
- 反向代理的路由规则要改
3. base-url 不再需要(Streamable 没有 endpoint 事件)
- 但 streamable-http.mcp-endpoint 可以改路径(默认 /mcp)
4. keep-alive-interval 换前缀:
spring.ai.mcp.server.keep-alive-interval (SSE,McpServerSseProperties)
→ spring.ai.mcp.server.streamable-http.keep-alive-interval
5. 检查客户端是否支持 Mcp-Session-Id 头
6. 评估会话泄漏风险(15.3.3):考虑自定义 transport Bean 加 sessionIdleTimeout
15.5 STATELESS:一整套降级
15.5.1 传输:只有一个 POST
java
this.routerFunction = RouterFunctions.route()
.GET(this.mcpEndpoint, this::handleGet)
.POST(this.mcpEndpoint, this::handlePost)
.build();
(WebMvcStatelessServerTransport:87-90)
java
private ServerResponse handleGet(ServerRequest request) {
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build();
}
(:119-121)
GET 直接 405。 无状态模式不支持 SSE 监听流,也不支持 Last-Event-ID 重放。客户端只能一问一答。
POST 的校验(:139-143):
java
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON)
&& acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) {
return ServerResponse.badRequest().build();
}
和有状态的 POST 一样要求两种 Accept。但响应永远是 application/json (:163),从不返回 SSE。这个"要求 SSE 却从不给 SSE"的设计是为了与有状态端点保持客户端兼容。
POST 的处理(:157-197):
java
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
McpSchema.JSONRPCResponse jsonrpcResponse = handler.handleRequest(transportContext, jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
String json = this.jsonMapper.writeValueAsString(jsonrpcResponse);
return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(json);
}
catch (Exception e) { ...500... }
}
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
handler.handleNotification(transportContext, jsonrpcNotification)...block();
return ServerResponse.accepted().build();
}
注意:没有 initialize 分支 。无状态模式下 initialize 就是个普通请求(由 McpStatelessServerHandler 处理),不产生会话 ID。
15.5.2 能力:全部硬编码为 false
java
if (serverProperties.getCapabilities().isTool()) {
capabilitiesBuilder.tools(false); // ← 硬编码,:94
...
}
if (serverProperties.getCapabilities().isResource()) {
capabilitiesBuilder.resources(false, false); // ← :107
...
}
if (serverProperties.getCapabilities().isPrompt()) {
capabilitiesBuilder.prompts(false); // ← :131
...
}
if (serverProperties.getCapabilities().isCompletion()) {
capabilitiesBuilder.completions(); // ← :143(completions 没有变化通知参数)
...
}
(McpServerStatelessAutoConfiguration:92-152)
对照有状态版:
java
capabilitiesBuilder.tools(changeNotificationProperties.isToolChangeNotification()); // :136
capabilitiesBuilder.resources(false, changeNotificationProperties.isResourceChangeNotification()); // :151
capabilitiesBuilder.prompts(changeNotificationProperties.isPromptChangeNotification()); // :179
spring.ai.mcp.server.change-notification.* 三个属性在 STATELESS 下完全无效 (它们甚至没被 @EnableConfigurationProperties 引入------McpServerStatelessAutoConfiguration:61 只引入了 McpServerProperties)。这是必然的:没有会话就不知道该通知谁。
有状态版的资源能力里第一个参数恒为 false(:151、:164)说明 subscribe 恒关,这点两边一致。
15.5.3 无 customizer、无 roots
McpServerStatelessAutoConfiguration 的 mcpStatelessSyncServer / mcpStatelessAsyncServer 方法签名里没有 McpSyncServerCustomizer 参数,也没有 rootsChangeConsumers 参数。所以:
@McpTool里不能注入McpSyncServerExchange(前面 第 14 章 14.4.1 说过会被过滤掉);McpSyncServerCustomizerBean 在 STATELESS 下不会被调用 (有状态版的Optional<McpSyncServerCustomizer>也不存在)。
15.5.4 immediateExecution 的环境判断
java
if (environment instanceof StandardServletEnvironment) {
serverBuilder.immediateExecution(true);
}
(McpServerStatelessAutoConfiguration:159-161)
而 Spring Boot 的 Environment 类型取决于 WebApplicationType:
| 应用类型 | Environment | instanceof StandardServletEnvironment |
immediateExecution |
|---|---|---|---|
| SERVLET(WebMVC) | ApplicationServletEnvironment |
true | true |
| REACTIVE(WebFlux) | ApplicationReactiveEnvironment |
false | 保持默认(false) |
| NONE | StandardEnvironment |
false | 保持默认 |
WebMVC 与 WebFlux 的无状态服务端在这一个开关上行为不同。 immediateExecution(true) 意味着请求在传输线程上立即执行(不等队列),对 Servlet 容器(线程池模型)是必要的;WebFlux 走事件循环,不需要(也不应该)这样做。
有状态版用的是另一种写法(McpServerAutoConfiguration:219-225):
java
@Bean
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(prefix = ..., name = "type", havingValue = "SYNC", matchIfMissing = true)
McpSyncServerCustomizer servletMcpSyncServerCustomizer() {
return serverBuilder -> serverBuilder.immediateExecution(true);
}
它用 @ConditionalOnWebApplication(type = SERVLET) + 一个 McpSyncServerCustomizer Bean。两种写法结果一致,但路径不同,这引出下一节的陷阱。
15.6 SYNC / ASYNC 与配置属性全集
15.6.1 配置属性全集
| 属性 | 默认值 | 说明 |
|---|---|---|
spring.ai.mcp.server.enabled |
true |
总开关 |
spring.ai.mcp.server.stdio |
false |
STDIO 传输 |
spring.ai.mcp.server.name |
mcp-server |
serverInfo.name |
spring.ai.mcp.server.version |
1.0.0 |
serverInfo.version |
spring.ai.mcp.server.instructions |
null |
给客户端的指引文本 |
spring.ai.mcp.server.type |
SYNC |
SYNC / ASYNC |
spring.ai.mcp.server.protocol |
STREAMABLE(字段值,实际默认走 SSE) |
SSE / STREAMABLE / STATELESS |
spring.ai.mcp.server.request-timeout |
20s |
请求超时 |
spring.ai.mcp.server.expose-mcp-client-tools |
false |
是否转发下游 MCP 工具 |
spring.ai.mcp.server.tool-callback-converter |
true |
是否启用 ToolCallback 转换 |
spring.ai.mcp.server.tool-response-mime-type.<name> |
空 | 单工具的响应 MIME |
spring.ai.mcp.server.capabilities.resource |
true |
资源能力 |
spring.ai.mcp.server.capabilities.tool |
true |
工具能力 |
spring.ai.mcp.server.capabilities.prompt |
true |
提示词能力 |
spring.ai.mcp.server.capabilities.completion |
true |
补全能力 |
spring.ai.mcp.server.change-notification.resource |
true |
仅有状态生效 |
spring.ai.mcp.server.change-notification.tool |
true |
同上 |
spring.ai.mcp.server.change-notification.prompt |
true |
同上 |
spring.ai.mcp.server.annotation-scanner.enabled |
true |
注解扫描总开关 |
spring.ai.mcp.server.streamable-http.mcp-endpoint |
/mcp |
有状态 & 无状态共用 |
spring.ai.mcp.server.streamable-http.keep-alive-interval |
null |
仅有状态生效 |
spring.ai.mcp.server.streamable-http.disallow-delete |
false |
仅有状态生效 |
spring.ai.mcp.server.sse-endpoint |
/sse |
仅 SSE |
spring.ai.mcp.server.sse-message-endpoint |
/mcp/message |
仅 SSE |
spring.ai.mcp.server.base-url |
"" |
仅 SSE |
spring.ai.mcp.server.keep-alive-interval |
null |
仅 SSE(与上面同名不同前缀?见下) |
注意最后一行:
McpServerSseProperties与McpServerStreamableHttpProperties都有keepAliveInterval,但前缀不同------前者spring.ai.mcp.server(:33),后者spring.ai.mcp.server.streamable-http(:32)。但它们的字段名都是keep-alive-interval,而McpServerSseProperties的前缀spring.ai.mcp.server与McpServerProperties相同。Spring Boot 的 relaxed binding 会把spring.ai.mcp.server.keep-alive-interval绑到McpServerSseProperties,把spring.ai.mcp.server.streamable-http.keep-alive-interval绑到McpServerStreamableHttpProperties。两者不存在冲突,但极易看错。 且McpServerSseProperties已被标记forRemoval。
15.6.2 Optional<McpSyncServerCustomizer> 的多候选陷阱
java
public McpSyncServer mcpSyncServer(...
ObjectProvider<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers,
Optional<McpSyncServerCustomizer> mcpSyncServerCustomizer) { // :116
参数类型是 Optional<McpSyncServerCustomizer>。很多人的直觉是"Optional 意味着'有就用,没有就跳过,多个也没关系'"------这是错的。
Spring 对 Optional<T> 的处理是:把 required 置为 false(createOptionalDependency 里的 NestedDependencyDescriptor 覆写了 isRequired()),但候选解析逻辑不变 。当容器内存在多个 McpSyncServerCustomizer 且没有 @Primary 时,determineAutowireCandidate 返回 null,随后 resolveNotUnique 抛出 NoUniqueBeanDefinitionException。
而默认情况下(SERVLET web 应用 + type=SYNC),容器里已经有一个 servletMcpSyncServerCustomizer(:223-225)。所以:
在 WebMVC 应用中定义你自己的
McpSyncServerCustomizerBean,会直接导致启动失败 (NoUniqueBeanDefinitionException),除非:
- 给你的 Bean 加
@Primary;或- 把
spring.ai.mcp.server.type设为ASYNC(此时servletMcpSyncServerCustomizer的@ConditionalOnProperty(type=SYNC)不生效,它不会被注册)。注意第 2 条说明:同一个自定义 Bean 在
type=SYNC下崩、在type=ASYNC下正常。这类"改一个配置就从崩到不崩"的现象最容易让人误判。
正确写法:
java
@Bean
@Primary // ← 必须有,否则与 servletMcpSyncServerCustomizer 冲突
public McpSyncServerCustomizer myServerCustomizer() {
return builder -> builder.instructions("本服务提供天气查询能力");
}
或者更稳妥------不用 McpSyncServerCustomizer,直接自己定义 McpSyncServer Bean(@ConditionalOnMissingBean 会让自动配置的那个退场)。
异步侧 Optional<McpAsyncServerCustomizer> asyncServerCustomizer(:238)没有默认 Bean,所以随便定义自己的都不会冲突。
这一类问题的通用排查手段是启动时加 --debug 看 auto-configuration report,或者直接:
java
@Autowired ApplicationContext ctx;
// ctx.getBeansOfType(McpSyncServerCustomizer.class).keySet()
15.6.3 capabilitiesBuilder 的共享
两个自动配置都定义了:
java
@Bean
@ConditionalOnMissingBean
public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() {
return McpSchema.ServerCapabilities.builder();
}
(McpServerAutoConfiguration:98-102、McpServerStatelessAutoConfiguration:68-72)
因为两个配置类的 @Conditional 互斥,不会同时注册。它是个单例 Builder ,mcpSyncServer 与 mcpAsyncServer 都注入它------但这两个方法也是互斥的(type 属性二选一),所以没有交叉污染。
不过要注意:如果你自己 定义了一个 ServerCapabilities.Builder Bean,@ConditionalOnMissingBean 会让自动配置的退场,你的 Builder 会被 capabilitiesBuilder.build()(McpServerAutoConfiguration:209)消费------这是官方留给"我想完全控制 capabilities"的口子。
15.6.4 SYNC 与 ASYNC 的能力对照
| 维度 | type=SYNC |
type=ASYNC |
|---|---|---|
@McpTool 方法返回类型 |
非 响应式(filterReactiveReturnTypeMethod) |
必须 响应式(filterNonReactiveReturnTypeMethod) |
| 回调类型 | SyncMcpToolMethodCallback implements BiFunction<..., CallToolResult> |
AsyncMcpToolMethodCallback implements BiFunction<..., Mono<CallToolResult>> |
| 可注入的上下文 | McpSyncServerExchange / McpSyncRequestContext |
McpAsyncServerExchange / McpAsyncRequestContext |
ToolCallback 转换 |
同步执行 | Mono.fromCallable + boundedElastic(伪异步) |
| outputSchema 生成 | generateFromType(支持泛型) |
generateFromClass(参数化泛型会 CCE) |
默认 McpSyncServerCustomizer |
SERVLET 下有(→ 多候选陷阱) | 无 |
| 错误文案 | "msg\nrootCauseMsg" |
响应式管道内 "Error invoking method: msg";同步抛出时同 SYNC |
| STATELESS 下 | WebMvcStatelessServerTransport + McpStatelessSyncServer |
同传输 + McpStatelessAsyncServer |
15.7 WebMVC 与 WebFlux:两套传输实现的对照与选型
六个传输实现类(三种协议 × 两种 Web 栈),逐对对照。
15.7.1 类级对照表
| 协议 | WebMVC | 行数 | WebFlux | 行数 |
|---|---|---|---|---|
| SSE | WebMvcSseServerTransportProvider |
686 | WebFluxSseServerTransportProvider |
657 |
| Streamable(有状态) | WebMvcStreamableServerTransportProvider |
999 | WebFluxStreamableServerTransportProvider |
748 |
| Stateless | WebMvcStatelessServerTransport |
313 | WebFluxStatelessServerTransport |
285 |
15.7.2 签名差异:阻塞 vs 响应式
| 点 | WebMVC | WebFlux |
|---|---|---|
| 路由字段类型 | RouterFunction<ServerResponse> |
RouterFunction<?>(WebFluxStreamableServerTransportProvider:77、:230) |
| handler 返回类型 | ServerResponse(同步构造) |
Mono<ServerResponse>(:239、:305、:396、:437) |
| SSE handler 返回类型 | ServerResponse(WebMvcSseServerTransportProvider:305) |
Mono<ServerResponse>(WebFluxSseServerTransportProvider:334、:412) |
内部是否 block() |
是,多处 | 否(保持响应式链) |
immediateExecution |
有状态版由 customizer 设 true;无状态版由 StandardServletEnvironment 判定设 true |
都不设(保持默认 false) |
WebMVC 的 Streamable 实现里有三处显式的 block():
java
// 1) POST 收到 JSON-RPC 响应
session.accept(jsonrpcResponse)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block(); // :476-478
// 2) POST 收到 JSON-RPC 通知
session.accept(jsonrpcNotification)...block(); // :482-484
// 3) POST 收到请求 → 用 SSE 流返回
session.responseStream(jsonrpcRequest, sessionTransport)...block(); // :505-507
还有两处:
java
McpSchema.InitializeResult initResult = init.initResult().block(); // :626
session.delete().contextWrite(...).block(); // :579
以及 SSE 版的 session.handle(message).block()(WebMvc :423,注释写着 "Block for WebMVC compatibility")。
这些 block() 意味着什么?
在 Servlet 容器(Tomcat/Jetty/Undertow)里,请求处理线程来自容器的线程池(默认 200)。一次 tools/call 在 handlePost 中会:
- 占用一个容器线程;
block()等待工具方法执行完(工具里的 JDBC/HTTP 调用都在这个线程上);- 用
ServerResponse.sse(...)把结果写回(:489-515)。
所以 WebMVC + SYNC 的并发上限 ≈ Servlet 线程池大小 ,与 spring.ai.mcp.server.type 是 SYNC 还是 ASYNC 无关 ------因为传输层的 block() 在这里。想提高并发只能:
- 调大
server.tomcat.threads.max; - 或改用 WebFlux(传输层不 block);
- 或用 STATELESS + 无状态工具(仍然 block,但至少没有会话占用)。
这是一个容易被误判的点:以为
type=ASYNC就能提高吞吐。第 14 章 14.8.3 说过,ToolCallback路径的 ASYNC 只是把阻塞挪到boundedElastic;本节补充:注解路径的 ASYNC 在 WebMVC 传输下,最终仍会被传输层的block()拉回阻塞。真正能改变线程模型的是 WebFlux。
15.7.3 相同点:一份代码的两个副本
对照两份 Streamable 实现,以下部分是逐字一致的:
-
maxSessions默认100_000L(WebMvc:864/ WebFlux:608) -
sessionIdleTimeout默认null(WebMvc:866/ WebFlux:612) -
disallowDelete默认false(WebMvcMcpServerStreamableHttpProperties:43传入) -
keepAliveInterval/contextExtractor/securityValidator的 Builder 默认值(McpTransportContext.EMPTY/ServerTransportSecurityValidator.NOOP) -
protocolVersions()返回四个版本(WebMvcStreamableServerTransportProvider:203-206):javareturn List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); -
GET只要求Accept: text/event-stream;POST要求同时两种 -
Mcp-Session-Id头的读写、Last-Event-ID重放、DELETE 逻辑 -
isClosing的volatile与503返回
自动配置也一致 :两个 McpServerStreamableHttp*AutoConfiguration 都只传 jsonMapper / mcpEndpoint(WebFlux 侧方法名是 messageEndpoint)/keepAliveInterval / disallowDelete,都不传 maxSessions 与 sessionIdleTimeout 。所以 15.3.3 的"两个参数不可配"对两个栈同样成立。
15.7.4 选型矩阵
| 场景 | 建议 | 理由 |
|---|---|---|
| 工具全部是阻塞 IO(JDBC、RestTemplate、SDK 客户端)+ 中等并发 | WebMVC + SYNC | 线程模型清晰,可用 Servlet 线程池参数调优 |
| 工具全部是阻塞 IO + 高并发(>500 QPS) | WebFlux + SYNC 或 WebMVC + 扩容 | WebFlux 传输层不 block,但工具本身仍会占 boundedElastic;扩容更直接 |
| 工具是响应式的(WebClient、R2DBC) | WebFlux + ASYNC | 端到端非阻塞,唯一能真正发挥 ASYNC 的组合 |
| 无状态短请求、水平扩展、不想做会话亲和 | STATELESS(栈任选) | 见 15.5 |
| 命令行分发(Claude Desktop 等) | STDIO (-webmvc/-webflux 都不需要) |
见 15.2 |
| 混合:既有阻塞工具又想用 WebFlux | WebFlux + SYNC + 阻塞工具内部加超时 | 别指望框架替你隔离阻塞 |
15.8 上线清单:从"能跑"到"能运维"
这一节是把前七节的所有结论收敛成可执行的动作。
15.8.1 协议选型决策树
ini
Q1: 是命令行工具,由客户端 fork/exec 启动吗?
├─ 是 → STDIO(spring.ai.mcp.server.stdio=true,web-application-type=none)
│ 并立刻处理 stdout 污染(15.2.2 ①)
└─ 否 → Q2
Q2: 客户端支持 Mcp-Session-Id 头 + 单端点 /mcp 吗?
├─ 是 → STREAMABLE(显式写 protocol!)
│ → Q3
├─ 否 → SSE(显式写 protocol=SSE,接受 forRemoval 风险)
└─ 不确定 → 先 STREAMABLE,客户端不兼容再降级 SSE
Q3: 需要多实例水平扩展吗?
├─ 是 → Q4
└─ 否 → STREAMABLE 即可,但仍要处理会话泄漏(15.8.2)
Q4: 工具调用之间需要保持状态吗(elicitation / sampling / progress / 多轮上下文)?
├─ 是 → STREAMABLE + 会话亲和(sticky session)
└─ 否 → STATELESS
15.8.2 有状态 Streamable 的三件必做之事
① 自定义 transport Bean,补上两个不可配参数。
java
@Bean
public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransportProvider(
@Qualifier("mcpServerJsonMapper") JsonMapper jsonMapper,
McpServerStreamableHttpProperties props) {
return WebMvcStreamableServerTransportProvider.builder()
.jsonMapper(new JacksonMcpJsonMapper(jsonMapper))
.mcpEndpoint(props.getMcpEndpoint())
.keepAliveInterval(Duration.ofSeconds(30)) // 显式给,别用 null
.disallowDelete(props.isDisallowDelete())
.maxSessions(5_000) // 自己定上限
.sessionIdleTimeout(Duration.ofMinutes(10)) // 必须 > keepAliveInterval
.securityValidator(new MyOriginValidator()) // 15.8.4
.build();
}
注意 builder 里的 Assert(:986-990):sessionIdleTimeout 必须 大于 keepAliveInterval。上面的 10 分钟 > 30 秒,合法。
② 加一个会话数的观测。 因为框架不提供(第 14 章 14.10),只能在自己的 Bean 里做:定期读 sessions.size()。可惜 sessions 是 private 且没有 getter------只能通过反射或包装。务实替代方案:用 Micrometer 的 JVM 指标(堆内存、线程数)配合告警,在内存缓慢上涨时人工介入。
③ 网关层配置。 Streamable 的 SSE 响应会被很多网关缓冲(Nginx 默认 proxy_buffering on),导致流式变成一次性返回。需要:
ini
proxy_buffering off;
proxy_read_timeout 3600s; # SSE 长连接
proxy_send_timeout 3600s;
15.8.3 超时体系:四个层次,一个都不能少
| 层次 | 配置 | 默认值 | 说明 |
|---|---|---|---|
| MCP 请求超时 | spring.ai.mcp.server.request-timeout |
20s |
McpServerProperties:113,传给 serverBuilder.requestTimeout(...)(:213) |
| 工具自身超时 | 工具方法内部 | 无 | 框架不给 。阻塞工具必须自己加(WebClient 的 .timeout()、JDBC queryTimeout、CompletableFuture.orTimeout) |
| Servlet 异步超时 | spring.mvc.async.request-timeout |
取决于容器 | WebMVC 的 SSE 是异步请求,会受它约束 |
| 网关/负载均衡超时 | Nginx / ALB / Ingress | 通常 60s | 必须大于上面三项 |
request-timeout 默认 20 秒对 LLM 相关的工具(RAG 检索、多跳调用)偏短。建议调到 60-120s,同时给工具内部更短的超时(快速失败)。
15.8.4 安全:三个必须自己补的地方
① securityValidator(传输层)。 默认 NOOP(Builder 默认值)。MCP 官方的威胁模型里,DNS rebinding 是本地 STDIO/HTTP server 的经典攻击面:恶意网页让浏览器 DNS 解析到 localhost,然后向你的 MCP 服务端发请求。防护方式是校验 Origin / Host 头:
java
public class MyOriginValidator implements ServerTransportSecurityValidator {
@Override
public void validateHeaders(Map<String, List<String>> headers) throws ServerTransportSecurityException {
var origin = headers.getOrDefault("origin", List.of());
if (!origin.isEmpty() && !ALLOWED.contains(origin.get(0))) {
throw new ServerTransportSecurityException(HttpStatus.FORBIDDEN.value(), "Origin not allowed");
}
}
}
注意:只能通过自定义 transport Bean 传入(自动配置不暴露这个 builder 方法,15.3.7)。
② 鉴权。 MCP 服务端自动配置没有任何鉴权支持 ------没有 API Key、没有 OAuth、没有 mTLS。所有鉴权都要在网关层 或Servlet Filter / WebFilter 里做。注意别把 /mcp 排除在鉴权链之外。
③ 错误消息脱敏。 第 14 章 14.7.2:createSyncErrorResult 会把 e.getMessage() 与根因消息原样发给客户端(进而喂给模型)。上线前必须做一次"异常消息审计"。
15.8.5 集成测试:断言"实际生效的协议"
因为 protocol 的字段值与生效值可能不一致(15.1),测试里建议直接断言 Bean 的存在性,而不是断言配置值:
java
@SpringBootTest
class ProtocolWiringTest {
@Autowired ApplicationContext ctx;
@Test
void streamableTransportIsActive() {
assertThat(ctx.getBeanNamesForType(WebMvcStreamableServerTransportProvider.class)).hasSize(1);
assertThat(ctx.getBeanNamesForType(WebMvcSseServerTransportProvider.class)).isEmpty();
assertThat(ctx.getBeanNamesForType(McpSyncServer.class)).hasSize(1);
}
@Test
void toolNamesAreUnique() {
var specs = ctx.getBeanProvider(
org.springframework.ai.mcp.server.common.autoconfigure.McpServerAutoConfiguration.class);
// 更直接:拿所有 List<SyncToolSpecification> Bean
var names = ctx.getBeansOfType(
new ParameterizedTypeReference<List<McpServerFeatures.SyncToolSpecification>>() {}).values()
.stream().flatMap(List::stream)
.map(s -> s.tool().name()).toList();
assertThat(names).doesNotHaveDuplicates();
}
}
第二个测试非常便宜,却能挡住第 14 章 14.9 讲的全部重名问题(重载、跨来源、Bean 顺序)。
15.8.6 一份生产配置模板
yaml
spring:
main:
banner-mode: off # STDIO 时必备;HTTP 场景也建议关
ai:
mcp:
server:
enabled: true
# ↓ 必须显式写,不要依赖默认
protocol: STREAMABLE
type: SYNC
name: order-mcp-server
version: 2.1.0
instructions: "提供订单查询与退款工具。所有金额单位为分。"
request-timeout: 60s
stdio: false
expose-mcp-client-tools: false
tool-callback-converter: true
capabilities:
tool: true
resource: false # 不用就关,减少 tools/list 噪音
prompt: false
completion: false
change-notification:
tool: true
tool-response-mime-type:
render_order_chart: image/png
server: # ← 注意:streamable-http 是下一级
streamable-http:
mcp-endpoint: /mcp
keep-alive-interval: 30s
disallow-delete: false
mvc:
async:
request-timeout: 120s
server:
tomcat:
threads:
max: 400 # WebMVC 的并发上限就在这
port: 8080
logging:
level:
org.springframework.ai.mcp.server: DEBUG # 能看到 Registered tools / capabilities
一个容易写错的缩进陷阱:上面
spring.ai.mcp.server之后又出现了一个server:(用于streamable-http)。在真实的 YAML 里应该是同一个spring.ai.mcp.server节点下同时有protocol和streamable-http两个子节点------上面为了突出"两个不同的 properties 类共享spring.ai.mcp.server前缀"而刻意分开展示。实际写法:
yamlspring: ai: mcp: server: protocol: STREAMABLE type: SYNC streamable-http: mcp-endpoint: /mcp keep-alive-interval: 30s
15.8.7 上线前的一页纸检查表
ini
[ ] protocol 显式配置(15.1)
[ ] 启动日志里有 "Enable tools capabilities" 与 "Registered tools: N"(:134 / :143)
[ ] tools/list 的工具名无重复(14.9)
[ ] 每个 @McpTool 都有 description(14.4.2)
[ ] 只读工具标了 readOnlyHint=true / destructiveHint=false(14.4.3)
[ ] -parameters 生效:javap 检查 MethodParameters(14.5.6)
[ ] 异常消息审计,无 SQL / 内部 URL / 堆栈外泄(14.7.2)
[ ] 阻塞工具有内部超时(15.8.3)
[ ] request-timeout ≥ 工具最大耗时(15.8.3)
[ ] 有状态 Streamable:自定义 transport Bean + maxSessions + sessionIdleTimeout(15.8.2)
[ ] 多实例:会话亲和 或 改用 STATELESS(15.3.2)
[ ] 网关:proxy_buffering off + 长 read timeout(15.8.2)
[ ] securityValidator 校验 Origin(15.8.4)
[ ] /mcp 在鉴权链内(15.8.4)
[ ] STDIO:stdout 零日志输出(15.2.2)
[ ] 观测:工具方法加 @Observed,或网关层统计(14.10)
[ ] native(如用):补 @RegisterReflectionForBinding(14.10.1)
15.9 能力协商:ServerCapabilities 是怎么被拼出来的
tools/list 之外,客户端 initialize 时拿到的另一半信息是 capabilities。它决定了"客户端该不该展示 resource 面板""该不该监听工具列表变化"。
15.9.1 那个被共享的 Builder
java
@Bean
@ConditionalOnMissingBean
public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() {
return McpSchema.ServerCapabilities.builder();
}
(McpServerAutoConfiguration:98-102;McpServerStatelessAutoConfiguration:68-72 同样一份)
它是单例 ,被 mcpSyncServer / mcpAsyncServer 注入(:109、:230),然后在五个 if 块里被逐步填充,最后:
java
serverBuilder.capabilities(capabilitiesBuilder.build());
(:209、:333)
它是有状态 Builder + 多次调用 ,所以顺序敏感:tools(...) 在 resources(...) 之前还是之后不影响结果,但重复调用会覆盖 ------这正是 resources 那两处重复调用(:151 与 :164)无害的原因(参数完全相同)。
15.9.2 有状态版:五个开关的完整语义
| 代码 | 配置属性 | 生成的能力字段 |
|---|---|---|
capabilitiesBuilder.tools(changeNotificationProperties.isToolChangeNotification())(:136) |
spring.ai.mcp.server.capabilities.tool(默认 true)+ change-notification.tool(默认 true) |
capabilities.tools.listChanged = true |
capabilitiesBuilder.resources(false, changeNotificationProperties.isResourceChangeNotification())(:151、:164) |
capabilities.resource + change-notification.resource |
capabilities.resources.subscribe = false、listChanged = true |
capabilitiesBuilder.prompts(changeNotificationProperties.isPromptChangeNotification())(:179) |
capabilities.prompt + change-notification.prompt |
capabilities.prompts.listChanged = true |
capabilitiesBuilder.completions()(:191、:313) |
capabilities.completion |
capabilities.completions = {}(空对象,没有 listChanged 概念) |
注意三处不对称:
resources的第一参数恒为false------subscribe(资源订阅)能力关闭且不可配。第二版 Spring AI 的自动配置里没有任何属性能打开它。completions()没有参数 ------ServerCapabilities.completions在 MCP 规范里就是个空标记。resources被设置两次 (resources 块与 resourceTemplates 块各一次,:151、:164),且两个块用的是同一个capabilities.isResource()判断(:148、:161)。也就是说无法单独关闭 resourceTemplates。
15.9.3 无状态版:全部硬编码 false
java
capabilitiesBuilder.tools(false); // :94 (有状态版是 isToolChangeNotification())
capabilitiesBuilder.resources(false, false); // :107 (有状态版第二个参数是配置值)
capabilitiesBuilder.prompts(false); // :131
capabilitiesBuilder.completions(); // :143 (无参数,与有状态版一致)
listChanged 全 false 是必然的:无状态服务端没有会话,不知道该通知谁(15.5.2)。
15.9.4 声明与实现的落差:listChanged = true 是个空头承诺
这是本节最值得记住的结论。
默认配置下(change-notification.* 全 true、capabilities.tool=true),服务端会在 initialize 响应里声明:
json
{
"capabilities": {
"tools": { "listChanged": true },
"resources":{ "subscribe": false, "listChanged": true },
"prompts": { "listChanged": true },
"completions": {}
}
}
但核对 Spring AI 的代码,它从来没有主动发过变更通知:
bash
$ grep -rn "notifyClients\|notifyClient(" --include=*.java . | grep -v "/test/"
mcp/transport/mcp-spring-webmvc/.../WebMvcSseServerTransportProvider.java:220 ← 定义
mcp/transport/mcp-spring-webmvc/.../WebMvcSseServerTransportProvider.java:240 ← 定义
mcp/transport/mcp-spring-webmvc/.../WebMvcStreamableServerTransportProvider.java:222 ← 定义
mcp/transport/mcp-spring-webmvc/.../WebMvcStreamableServerTransportProvider.java:247 ← 定义
mcp/transport/mcp-spring-webflux/.../WebFluxSseServerTransportProvider.java:241/279 ← 定义
mcp/transport/mcp-spring-webflux/.../WebFluxStreamableServerTransportProvider.java:163/183 ← 定义
八个命中全部是方法定义(实现 SDK 的接口),没有任何调用点。
结构上也说得通:Spring AI 的自动配置是在启动时 把 List<SyncToolSpecification> 一次性交给 serverBuilder.tools(...)(:142),没有运行时增删工具的 API------工具集合从应用启动到关闭是恒定的。既然不会变,也就不需要通知。
于是出现了一个"承诺了但兑现不了"的能力声明:客户端(比如第 13 章讲过的
McpSyncToolsChangeEventEmmiter)看到listChanged=true可能会注册监听、甚至周期性重新拉tools/list,但服务端永远不会主动推。这不是 bug(规范允许"声明支持但没发生变更"),但如果你在意客户端的无效轮询,可以把
spring.ai.mcp.server.change-notification.*全部设成false------声明诚实一点更省事。
顺带回顾第 13 章 13.6 讲过的客户端热更新:客户端侧的 McpToolsChangedEvent 是由客户端 自己发起的重新拉取驱动的,与服务端是否推送无关。所以即使服务端声明 listChanged=false,Spring AI 客户端照样能发现下游工具变化(它靠的是"收到通知就拉,没收到也可能周期性拉")。两端是解耦的。
15.10 expose-mcp-client-tools:服务端转发下游工具
这是"MCP 网关"场景的核心开关,也是唯一能让服务端工具集不是启动时恒定的路径。
15.10.1 默认行为:防环路
java
private boolean exposeMcpClientTools = false;
(McpServerProperties:106)
默认 false 时,ToolCallbackUtils.aggregateToolCallbacks 会:
-
过滤掉所有
SyncMcpToolCallback/AsyncMcpToolCallback实例(ToolCallbackUtils:48、:74-76); -
过滤掉所有
SyncMcpToolCallbackProvider/AsyncMcpToolCallbackProvider实例(:52-58、:78-81); -
并在过滤掉 provider 时打一条 WARN(
:66-70):vbnetFound MCP Clients. The MCP Client tools will not be exposed by the MCP Server. If you would like to expose the tools, set spring.ai.mcp.server.expose-mcp-client-tools=true.
15.10.2 打开之后的链路
swift
下游 MCP 服务端
↓ MCP 协议
本应用的 MCP 客户端(McpSyncClient / McpAsyncClient,第 13 章)
↓ SyncMcpToolCallbackProvider.getToolCallbacks()(第 13 章 13.4)
List<ToolCallback>(每个都是 SyncMcpToolCallback,带前缀名)
↓ ToolCallbackUtils.aggregateToolCallbacks(includeMcpTools=true,不过滤)
↓ McpToolUtils.toSyncToolSpecification(callback, mimeType)
List<SyncToolSpecification>
↓ ObjectProvider flatMap(与本地 @McpTool 的 spec 合并)
本应用的 MCP 服务端 McpSyncServer
↓ tools/list
上游客户端
这条链路把第 13 章与第 14 章完整地接了起来,也解释了几个之前埋下的伏笔:
- 第 13 章 13.5 的命名消歧在这里才真正有意义 :下游工具名经过
DefaultMcpToolNamePrefixGenerator加了前缀(如brave_web_search),如果不加前缀就转发,很容易与本地工具重名(14.9.3)。 - 第 13 章 13.10 讲的"MCP 工具与 Spring AI 工具的三个不一样"在这里会叠加 :转发后的工具走的是
ToolCallback路径(第 14 章 14.8),丢掉 了下游工具原本的title、ToolAnnotations、outputSchema------也就是说网关会降级下游工具的元数据。 - 第 13 章 13.9 的
isError语义在这里会二次转换 :下游返回isError=true→ 客户端SyncMcpToolCallback转成异常(13.9.4)→ 本应用的McpToolUtils.toSharedSyncToolSpecification的catch (Exception e)又转回isError=true(McpToolUtils:281-286)。两次转换后,消息内容会变成"下游异常的文本" ,原始Content[]结构丢失(13.9 讲过结果是Content[]的 JSON 字符串)。
所以 MCP 网关不是透明的 。如果这是你的核心场景,建议不要走自动配置,而是自己写一层:直接用 MCP SDK 的客户端拿
tools/list的原始McpSchema.Tool,原样构造SyncToolSpecification交给服务端,保住全部元数据。
15.10.3 三个风险
① 环路。 A 转发 B 的工具,B 又转发 A 的工具 → 无限递归。exposeMcpClientTools 默认关闭就是防这个。打开时务必保证拓扑是单向无环的。
② 启动顺序。 MCP 客户端的 ToolCallbackProvider 是懒加载的(第 13 章 13.4:getToolCallbacks() 里才真正 listTools())。而服务端的 syncTools Bean 在启动时会调用它。如果下游服务端此时还没起来,会发生什么?SyncMcpToolCallbackProvider 的实现(第 13 章)会在 getToolCallbacks() 里 listTools() 并可能抛异常------这个异常会发生在服务端 Bean 创建时,导致应用启动失败。
生产建议:MCP 网关场景下,把下游连接的失败隔离起来。要么自己包一层
ToolCallbackProvider做降级(返回空列表 + 告警),要么用spring.ai.mcp.client.*的超时与重试配置(第 13 章)把失败窗口压到最小。
③ 工具数量爆炸。 每个下游服务端的每个工具都会进 tools/list。五个下游 × 30 个工具 = 150 个工具,模型的选择质量会显著下降。第 12 章讲过的 ToolSearchToolCallingAdvisor(工具检索)在这种场景下是必需品------但那是客户端侧的能力,你的上游客户端未必有。
15.11 报文级时序:四种形态一次完整调用长什么样
前几节都是"实现视角"。这一节换个角度:把一次 initialize → tools/list → tools/call 的完整交互按线上报文展开。
说明:端点路径、HTTP 头名、协议版本常量均取自本仓库源码(下文标注了行号),JSON-RPC 报文的字段结构来自 MCP 协议规范。Spring AI 不参与报文构造(那是 SDK 的事),但它决定了端点、头部与协议版本。
15.11.1 Streamable HTTP(有状态)
bash
① POST /mcp ← McpServerStreamableHttpProperties:36 默认 "/mcp"
Accept: application/json, text/event-stream ← 必须两个都有(:434-441)
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18", ← 必须是 :203-206 四个之一
"capabilities":{},
"clientInfo":{"name":"my-agent","version":"1.0.0"}}}
② 200 OK
Content-Type: application/json ← :629
Mcp-Session-Id: 8f3c1a... ← :630,会话 ID 首次下发
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":true},...}, ← 15.9.2
"serverInfo":{"name":"mcp-server","version":"1.0.0"}, ← McpServerProperties:72/:77
"instructions": null }} ← :211
③ POST /mcp ← 之后所有请求都要带头
Mcp-Session-Id: 8f3c1a... ← :463,缺失则 400(:456-461)
Accept: application/json, text/event-stream
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
④ 200 OK(SSE 流,不是 JSON) ← :489-515
Content-Type: text/event-stream
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"get_weather","description":"...",
"inputSchema":{"type":"object","properties":{...},"required":[...]},
"outputSchema":null, ← 默认不生成,第 14 章 14.4.4
"title":"get_weather", ← 三级回退,第 14 章 14.4.2
"annotations":{"readOnlyHint":false,"destructiveHint":true,
"idempotentHint":false,"openWorldHint":true}} ← 默认值,第 14 章 14.4.3
]}}
⑤ POST /mcp
Mcp-Session-Id: 8f3c1a...
{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"get_weather","arguments":{"city":"Beijing"}}}
⑥ 200 OK(SSE 流)
event: message
data: {"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"Beijing: 晴,26℃"}], ← TEXT 模式 String 直通,第 14 章 14.6.1 ⑤
"isError":false}}
── 或失败时 ──
data: {"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"订单查询失败,请稍后重试"}],
"isError":true}} ← 第 14 章 14.7.1 ③
⑦ GET /mcp(可选,建立监听流)
Mcp-Session-Id: 8f3c1a...
Accept: text/event-stream ← 只需这一个(:330-332)
→ 长连接,服务端可推送 sampling / elicitation / logging
⑧ DELETE /mcp ← 唯一主动回收手段
Mcp-Session-Id: 8f3c1a...
→ 200 OK(:583);若 disallowDelete=true → 405(:561-563)
15.11.2 SSE(已废弃)
bash
① GET /sse ← McpServerSseProperties:43 默认 "/sse"
Accept: text/event-stream
② 200 OK(长连接)
Content-Type: text/event-stream
event: endpoint ← ENDPOINT_EVENT_TYPE = "endpoint"(:108)
data: /mcp/message?sessionId=8f3c1a... ← buildEndpointUrl(:365-372)
baseUrl 默认 ""(:37)→ 相对路径
③ POST /mcp/message?sessionId=8f3c1a... ← sessionId 在 query(:399-406)
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
④ 200 OK(无 body) ← :428,结果走 SSE 连接推回
(在 ② 那条长连接上)
event: message ← MESSAGE_EVENT_TYPE = "message"(:103)
data: {"jsonrpc":"2.0","id":1,"result":{...}}
⑤ ~⑥ 同 ③ ④,method 换成 tools/list / tools/call
⑦ 连接断开 → onComplete / onTimeout → sessions.remove(sessionId)(:332-343)
与 Streamable 的三个关键差异(报文层面):
| SSE | Streamable | |
|---|---|---|
initialize 的响应走哪条路 |
SSE 长连接 | 同一个 POST 的响应体 |
| 会话 ID 传递 | SSE endpoint 事件 + URL query |
Mcp-Session-Id 头 |
| 普通请求的响应 | 走长连接(异步) | 走本次 POST 的 SSE 流(同步) |
第 2 行解释了为什么 SSE 模式下必须先建立长连接才能做别的事,而 Streamable 可以"每个 POST 自包含"------这也是 Streamable 更适合 Serverless / 短连接环境的根本原因。
15.11.3 STATELESS
bash
① POST /mcp ← 与有状态共用 endpoint(默认 "/mcp")
Accept: application/json, text/event-stream ← 仍然要求两个(WebMvcStatelessServerTransport:139-143)
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
② 200 OK
Content-Type: application/json ← :163,永远 JSON,从不 SSE
(没有 Mcp-Session-Id 头)
③ POST /mcp ← 不带头,因为根本没有
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{...}}
④ 200 OK
Content-Type: application/json
{"jsonrpc":"2.0","id":2,"result":{"content":[...],"isError":false}}
⑤ GET /mcp → 405 METHOD NOT ALLOWED ← :119-121
initialize 在这里只是个普通请求 (WebMvcStatelessServerTransport:157 没有专门的 initialize 分支),不产生会话。客户端其实可以跳过它直接 tools/call------是否允许取决于 SDK。
15.11.4 STDIO
c
(客户端 spawn 子进程)
→ stdin: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}\n
← stdout: {"jsonrpc":"2.0","id":1,"result":{...}}\n
→ stdin: {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{...}}\n
← stdout: {"jsonrpc":"2.0","id":2,"result":{"content":[...]}}\n
(每行一个 JSON-RPC 消息;stdout 上任何其他内容都会破坏解析)
→ stderr: 2026-10-18 23:59:59.123 INFO ... - 这是给人看的 ← 允许
15.11.5 四种形态的一句话对照
| 形态 | 一次 tools/call 需要几次 HTTP 往返 |
是否需要长连接 | 会话在何处 |
|---|---|---|---|
| Streamable(有状态) | 1(POST)+ 可选长连接 | 否(POST 自包含) | Mcp-Session-Id 头,服务端内存 |
| SSE | 1(POST)+ 必须先建长连接 | 是 | ?sessionId= query,服务端内存 |
| STATELESS | 1(POST) | 否 | 无 |
| STDIO | 0(进程内管道) | 否 | 无(进程即会话) |
15.12 HTTP 层的错误码与 Accept 协商:客户端到底会收到什么
前面几节都在讲"正常流程长什么样"。这一节反过来,讲异常流程------因为在实际排障里,你拿到手的往往不是"工具执行失败",而是一个 HTTP 状态码,加一个可能是空、可能是 JSON-RPC 错误对象、也可能是一句纯文本的响应体。
这一节的结论对跨语言 / 跨实现对接 尤其重要:如果你的客户端不是 Spring AI(Claude Desktop、Cursor、Python SDK、自研 HTTP 客户端),它对错误的容忍度取决于你返回的东西是否"符合它的预期",而 Spring AI 服务端在这件事上有若干处状态码与业务码错配的地方。
15.12.1 HTTP 状态码表(WebMVC + Streamable,全量)
WebMvcStreamableServerTransportProvider 的三个 handler 里一共出现了 9 种出口状态。下面这张表是可直接对照源码的:
| 触发条件 | HTTP 状态 | 响应体 | 源码位置 |
|---|---|---|---|
isClosing == true(正在关闭) |
503 | 纯文本 "Server is shutting down" |
:317、:422、:549 |
安全校验钩子抛出 ServerTransportSecurityException |
e.getStatusCode()(异常自带) |
e.getMessage(),为 null 时是空串 |
:324-327、:429-432、:556-559 |
GET 的 Accept 不含 text/event-stream |
400 | 纯文本 "Invalid Accept header. Expected TEXT_EVENT_STREAM" |
:330-332 |
GET 缺 Mcp-Session-Id 头 |
400 | 纯文本 "Session ID required in mcp-session-id header" |
:336-338 |
GET 会话不存在 |
404 | 空 (notFound().build()) |
:343-345 |
POST 的 Accept 不同时含 text/event-stream 与 application/json |
400 | McpError,业务码 METHOD_NOT_FOUND(-32601) |
:434-441 |
POST 非 initialize 且缺 Mcp-Session-Id |
400 | McpError,业务码 METHOD_NOT_FOUND(-32601) ,消息 "Session ID missing" |
:456-461 |
POST 会话不存在 |
404 | McpError,业务码 INTERNAL_ERROR(-32603) ,消息 "Session not found: <id>" |
:466-471 |
POST body 反序列化失败(IllegalArgumentException / IOException) |
400 | McpError,业务码 INVALID_REQUEST(-32600),消息 "Invalid message format" |
:524-530 |
POST 消息类型既不是 Request / Response / Notification |
500 | McpError,业务码 INVALID_REQUEST(-32600),消息 "Unknown message type" |
:517-522 |
POST 其它任意 Exception |
500 | McpError,业务码 INTERNAL_ERROR(-32603),消息 "Internal server error. Check server logs for details." |
:531-539 |
POST 是 Response 或 Notification |
202 Accepted | 空 | :479-480、:485-486 |
POST 是 Request |
200 | SSE 流(ServerResponse.sse) |
:489-515 |
DELETE 且 disallowDelete == true |
405 | 空 | :561-563 |
DELETE 缺 Mcp-Session-Id |
400 | 纯文本 "Session ID required in mcp-session-id header" |
:567-569 |
DELETE 会话不存在 |
404 | 空 | :574-576 |
DELETE 成功 |
200 | 空 | :583 |
initialize 时 sessionFactory == null |
500 | INTERNAL_ERROR,消息 "SessionFactory not configured" |
:601-606 |
initialize 且请求带了 Mcp-Session-Id |
400 | INTERNAL_ERROR(-32603) ,消息 "Session already initialized" |
:607-612 |
initialize 且 sessions.size() >= maxSessions |
503 | INTERNAL_ERROR(-32603) ,消息 "Max number of sessions reached" |
:613-618 |
initialize 成功 |
200 | application/json 的 JSONRPCResponse + Mcp-Session-Id 响应头 |
:628-631 |
这张表里值得单独拎出来的有六处。
(1)initialize 成功返回的是 application/json,不是 SSE。
:628-631 硬编码了 .contentType(MediaType.APPLICATION_JSON)。也就是说:一次 MCP 会话里,只有第一个请求(initialize)是普通 JSON 响应,之后所有 tools/call 都是 SSE 流 。这是 MCP Streamable HTTP 规范允许的行为(服务端可以二选一),但意味着你的客户端必须同时具备"解析 JSON"和"解析 SSE"两条路径 ------只实现一条的客户端,会在 tools/list 那一步而不是 initialize 那一步失败,从而把问题误判为"工具列表有问题"。
(2)会话不存在返回 404 + -32603 INTERNAL_ERROR。
MCP 的 JSON-RPC 错误码里,"会话不存在"这件事没有专门的错误码 (本仓库看不到 SDK 源码,不做断言 SDK 是否另有定义)。Spring AI 用的是 -32603(服务端内部错误)。语义上这不算错------服务端确实"无法处理这个请求"------但对排障很不友好:客户端拿到 -32603 的第一反应是"服务端崩了",而真实原因只是"会话过期/被 DELETE 了/服务端重启了"。
排障口诀:客户端看到
Session not found这五个字,第一步不是查服务端日志,而是查"我的会话是不是该重建了"。 有状态 Streamable 下,服务端重启、空闲驱逐(虽然默认关闭)、DELETE调用、反向代理把长连接掐断,都会导致这个错误。
(3)"未知消息类型"给的是 500,业务码却是 -32600 INVALID_REQUEST。
:517-522 这个分支的语义是"body 解析成功了,但解析出来的对象三种类型都不是"------按 JSON-RPC 语义这确实该是 -32600(客户端发的请求不合法),HTTP 状态也该是 400。但源码写的是 INTERNAL_SERVER_ERROR。
这是本章最明确的一处"状态码与业务码错配"。 实际后果:客户端如果以 HTTP 状态为准,会认为"服务端出错,可以重试";如果以业务码为准,会认为"我发的报文有问题,别重试"。两种结论完全相反。
(4)Accept 校验失败用的是 -32601 METHOD_NOT_FOUND。
:434-441:Accept 头不对,返回的业务码是 -32601(方法不存在)。这是明显的语义错配------请求方法是存在的,问题出在内容协商。
更麻烦的是默认值问题 :curl、fetch、axios 的默认 Accept 都是 */*,而 contains(MediaType.TEXT_EVENT_STREAM) 是精确相等 判断,*/* 不等于 text/event-stream。
json
$ curl -X POST http://localhost:8080/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
→ 400 Bad Request
{"jsonrpc":"2.0","error":{"code":-32601,"message":"Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON"}}
这是"我用 curl 测试 MCP 服务端,第一步就 400"的头号原因。 正确写法:
rust
$ curl -X POST http://localhost:8080/mcp \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'
(5)maxSessions 打满返回 503 + -32603。
:613-618。注意这里的判断发生在 handleInitRequest 里、在 sf.startSession(...) 之前 (:613 判断,:619 才创建),所以打满时不会创建半成品会话。503 语义(服务不可用、稍后重试)是对的,但配合 15.3.3 的结论------maxSessions 默认 100,000 且没有配置属性------这个分支在生产中几乎不可能被触发,除非你手动构造 provider。
(6)isClosing 的 503 是三个 handler 各写一遍。
:317、:422、:549 三处完全相同的代码。功能上没问题,但它揭示了一件事:"优雅关闭"这个横切关注点没有被抽出来 。对比之下 securityValidator 的调用也被复制了三遍(:321-322、:426-427、:553-554)。这意味着:如果你要在这两个点做统一增强(比如关闭时返回 Retry-After、安全校验失败时打审计日志),没有扩展点,只能改源码或套一层 Servlet Filter。
15.12.2 Accept 协商:GET 与 POST 的要求不一样
这是最容易被忽略的一条:
java
// GET:只要求 SSE
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) { // :330
return ServerResponse.badRequest().body("Invalid Accept header. Expected TEXT_EVENT_STREAM");
}
// POST:两个都要,且是 AND
if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)
|| !acceptHeaders.contains(MediaType.APPLICATION_JSON)) { // :435-436
return ServerResponse.badRequest().body(...);
}
为什么 POST 要同时要求两个?因为按规范,POST /mcp 的响应既可以是 JSON 也可以是 SSE (服务端按需选择)。Spring AI 的实际行为是:initialize 返回 JSON,其它 Request 返回 SSE(15.12.1 第 1 点)。所以它要求客户端声明"两种我都能吃"。
注意 WebMVC 写的是 De Morgan 形式 !A || !B,WebFlux 写的是 !(A && B)(WebFluxStreamableServerTransportProvider:322-323)------语义等价,写法不同。这种"同一逻辑两种写法"在第 15.7.3 讲过,是这两个模块互为副本的又一个证据。
关于 contains 的严格性,有一点必须说清楚:
List.contains用的是MediaType.equals。Spring 的MimeType.equals会比较参数(parameters) ,因此Accept: text/event-stream;q=0.9解析出来的MediaType带有q=0.9参数,不等于MediaType.TEXT_EVENT_STREAM。本仓库不包含
spring-web源码,这一点不在本仓库内可验证 ;但结论方向是明确的------给 MCP 服务端发Accept头时不要带q参数、不要带charset,就写最朴素的application/json, text/event-stream。 这是零成本的风险规避。
另外,Accept 头有大小写与空格的宽容度(text/event-stream vs Text/Event-Stream),MediaType 解析是忽略大小写的,这一点不用担心。
15.12.3 WebMVC 与 WebFlux:错误响应的逐条差异
两个栈的 Streamable 实现在错误出口上有实质差异,不只是写法不同:
| 场景 | WebMVC | WebFlux |
|---|---|---|
GET 的 Accept 不含 SSE |
400 + 纯文本 body | 400 + 空 body (:257-258) |
GET 缺 Mcp-Session-Id |
400 + "Session ID required in mcp-session-id header" |
400 + 空 body ,源码带注释 // TODO: say we need a session id(:261-263) |
GET 会话不存在 |
404 空 body | 404 空 body(:270-271) |
POST 的 Accept 缺失 |
400 + McpError(METHOD_NOT_FOUND) |
400 + 空 body (:321-325) |
POST 缺 session id |
400 + McpError(METHOD_NOT_FOUND) |
400 + McpError(METHOD_NOT_FOUND)(:335-338) |
POST 会话不存在 |
404 + McpError(INTERNAL_ERROR) |
404 + McpError(INTERNAL_ERROR)(:344-348) |
| 反序列化失败 | 400 + McpError(INVALID_REQUEST) |
400 + McpError(INVALID_REQUEST)(:386-389) |
| 未知消息类型 | 500 + McpError(INVALID_REQUEST) |
400 + McpError(INVALID_REQUEST)(:376-379) |
| 非 IO 的运行时异常 | 500 + McpError(INTERNAL_ERROR)(有 catch (Exception) 兜底,:531-539) |
无兜底------异常以 error signal 冒泡,交给 Spring WebFlux 的默认异常处理 |
| 空 body | 由 request.body(String.class) 决定(空串 → 反序列化失败 → 400) |
switchIfEmpty(ServerResponse.badRequest().build())(:392) |
| SSE 写出方式 | ServerResponse.sse(sseBuilder -> {...}, Duration.ZERO)(:489) |
ServerResponse.ok().contentType(TEXT_EVENT_STREAM).body(Flux<ServerSentEvent>, ...)(:360-373) |
三处最要紧的差异:
(1)WebFlux 在多个 400 分支上是"无消息"的。 源码里那句 // TODO: say we need a session id(:262)是作者自己留的坦白。同一套协议、同一个错误,MVC 栈告诉你原因,WebFlux 栈只给你一个 400。 如果你的团队在 WebFlux 栈上排障,不要指望从响应体里读到任何信息------打开 DEBUG 日志是唯一出路(而 MCP 服务端本身又没有任何自有观测埋点,见第 14 章 14.10)。
(2)"未知消息类型"的状态码在两个栈上不一样:MVC 是 500,WebFlux 是 400。 WebFlux 那个才是对的。
(3)WebFlux 的 handlePost 没有 catch (Exception) 兜底。 WebMVC 的 try 块以 catch (IllegalArgumentException | IOException e) + catch (Exception e) 收尾(:524-539),任何异常都被翻译成 JSON-RPC 错误对象。WebFlux 只对 IllegalArgumentException | IOException 做了翻译(:382-390),其它异常直接冒泡成 Reactor error signal。
后果:WebFlux 栈下,某些服务端内部错误不会产生 MCP 错误对象,而是产生一个"框架默认 500 页面/JSON"。 客户端需要同时处理"JSON-RPC 错误"和"HTTP 错误"两类报文,而 WebMVC 栈下绝大多数情况只有前者。
15.12.4 STATELESS 的错误码:更简单,但 GET 是 405
WebMvcStatelessServerTransport 只有一个真正的 handler(handlePost,:123-215),handleGet 直接返回 405:
java
private ServerResponse handleGet(ServerRequest request) {
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build(); // :119-121
}
注意:这个 405 不带任何说明 ,而且它发生在 isClosing 检查与安全校验之前 (handleGet 里没有这两段),也就是说 STATELESS 下 GET /mcp 永远 405,无论服务端处于什么状态。
handlePost 的分支比有状态版少一半:
| 触发条件 | HTTP 状态 | 响应体 | 源码位置 |
|---|---|---|---|
isClosing |
503 | "Server is shutting down" |
:124-126 |
| 安全校验失败 | e.getStatusCode() |
e.getMessage() |
:128-135 |
Accept 不同时含两者 |
400 | 空 | :139-143 |
mcpHandler == null |
500 | INTERNAL_ERROR,"MCP handler not configured" |
:145-151 |
| Request 处理抛异常 | 500 | INTERNAL_ERROR,"Failed to handle request. Check server logs for details." |
:165-173 |
| Notification 成功 | 202 | 空 | :180 |
| Notification 抛异常 | 500 | INTERNAL_ERROR,"Failed to handle notification. Check server logs for details." |
:182-190 |
| 既非 Request 也非 Notification | 400 | INVALID_REQUEST,"The server accepts either requests or notifications" |
:192-197 |
| 反序列化失败 | 400 | INVALID_REQUEST,"Invalid message format" |
:199-205 |
| 其它异常 | 500 | INTERNAL_ERROR,"Internal server error..." |
:206-214 |
| Request 成功 | 200 | application/json 的 JSONRPCResponse |
:162-163 |
对比有状态版,STATELESS 的错误码表干净得多 :没有 404(因为没有会话可找不到)、没有 405 之外的 405、没有 -32601 的错配(唯一的 -32601 出现在有状态版缺失 session id 的场景,STATELESS 不需要 session id)。
而最关键的差别是最后一行:STATELESS 下所有请求都返回普通 JSON,永远不返回 SSE。 这让它成为"客户端只想做一次 HTTP 请求拿一个 JSON"场景(Serverless 函数、CLI 工具、批处理脚本)里最容易对接的形态------前提是你接受 15.5 那一整节列出的降级。
15.12.5 安全校验钩子:默认是 NOOP,但它是唯一的"官方"扩展点
三个 handler 的第一件事都是:
java
try {
var headers = HeaderUtils.collectHeaders(request);
this.securityValidator.validateHeaders(headers);
}
catch (ServerTransportSecurityException e) {
var message = e.getMessage() != null ? e.getMessage() : "";
return ServerResponse.status(e.getStatusCode()).body(message);
}
HeaderUtils.collectHeaders(HeaderUtils:35-40)做的事情只有一件:把请求头收集成 Map<String, List<String>>,key 全部 toLowerCase(Locale.ROOT) 。这个小写化很重要------它意味着你的校验器拿到的 key 一定是小写的,写 headers.get("Origin") 是错的,必须 headers.get("origin")。
而 securityValidator 的默认值:
bash
$ grep -rn "ServerTransportSecurityValidator.NOOP" mcp/ --include=*.java
.../webmvc/.../WebMvcSseServerTransportProvider.java:579
.../webmvc/.../WebMvcStatelessServerTransport.java:239
.../webmvc/.../WebMvcStreamableServerTransportProvider.java:862
.../webflux/.../WebFluxSseServerTransportProvider.java:551
.../webflux/.../WebFluxStatelessServerTransport.java:211
.../webflux/.../WebFluxStreamableServerTransportProvider.java:610
六个 Builder 的默认值全是 NOOP。 也就是说:Spring AI 的 MCP 服务端默认不做任何 Origin 校验,DNS rebinding 防护需要你自己接。 这一点在 15.8.4 已经列为"必须自己补的地方",这里补上接入点 :实现 ServerTransportSecurityValidator(来自 MCP SDK,本仓库无源码),通过对应 transport 的 Builder.securityValidator(...) 注入(例如 WebMvcStreamableServerTransportProvider:937-939)。
但这里有个装配上的死结 :这些 transport provider 是由自动配置类构造的(McpServerStreamableHttpWebMvcAutoConfiguration 等),而自动配置没有为 securityValidator 提供任何配置属性或 Bean 注入口。所以:
想接安全校验钩子,必须放弃自动配置的 transport provider,自己定义
WebMvcStreamableServerTransportProviderBean。 这等于把 15.3 整节的装配链(endpoint、contextExtractor、jsonMapper、maxSessions......)手动重写一遍。这是 Spring AI 2.0.1 在"安全可扩展"上的一处真实空缺。
15.13 陷阱总表
| # | 陷阱 | 现象 | 位置 / 规避 |
|---|---|---|---|
| 1 | protocol 不写 → 走已废弃的 SSE |
应用报 deprecated 警告,端点在 /sse 而不是 /mcp |
McpServerAutoConfiguration:349-356;显式写 protocol |
| 2 | protocol 字段值(STREAMABLE)与实际生效值(SSE)相反 |
代码里 getProtocol() 读到 STREAMABLE,实际跑 SSE |
McpServerProperties:100;不要以字段值为准 |
| 3 | stdio=true + protocol=STATELESS → 什么都不装配 |
应用正常启动,日志里没有任何 MCP 行 | 矩阵最后一行(15.1.3);启动时搜 Registered tools: |
| 4 | stdio=true + protocol=STREAMABLE |
protocol 被静默忽略,得到 STDIO |
McpServerStdioDisabledCondition:30-48 |
| 5 | STDIO 兜底 Bean 无条件注册 | 任何"没有 web 传输 Bean"的场景都会得到 STDIO | McpServerAutoConfiguration:91-96 只有 @ConditionalOnMissingBean |
| 6 | STDIO 下往 stdout 打日志 | 协议帧被污染,客户端解析失败 | 15.2.2;日志必须走 stderr 或文件 |
| 7 | STDIO 忘了关 banner / console 日志 | 同上,且只在"某些环境"复现 | spring.main.banner-mode=off、logging.pattern.console= |
| 8 | maxSessions 默认 100,000 且无配置属性 |
内存会话表理论上可无限增长 | WebMvcStreamableServerTransportProvider;只能手动构造 provider |
| 9 | sessionIdleTimeout 默认 null,无配置属性 |
会话永不回收,且清理调度器根本不启动 | :196-199;同上 |
| 10 | 会话表是纯内存 ConcurrentHashMap |
多实例部署下会话不共享 → 客户端必须粘滞 | 15.3.2 |
| 11 | 服务端重启后客户端报 Session not found |
404 + -32603,容易被误判为"服务端崩了" |
:466-471;客户端要有重建会话逻辑 |
| 12 | SSE 用 query param、Streamable 用 header | 混用两种客户端时 session id 传错位置 | SSE ?sessionId=;Streamable Mcp-Session-Id |
| 13 | SSE 会话在连接断开时自动移除,Streamable 不会 | Streamable 下不 DELETE 就一直留着 |
15.4.4 |
| 14 | disallowDelete=true 时 DELETE 返回 405 |
客户端无法主动释放会话 | :561-563 |
| 15 | STATELESS 下 GET /mcp 永远 405,且无说明 |
拿 curl 探测端点会困惑 | WebMvcStatelessServerTransport:119-121 |
| 16 | STATELESS 下 change-notification 全部硬编码 false | 配了 spring.ai.mcp.server.change-notification.* 也没用 |
15.5.2 |
| 17 | STATELESS 下没有 McpSyncServerCustomizer 与 roots 入口 |
自定义能力无处注入 | 15.5.3 |
| 18 | STATELESS + immediateExecution 的环境判断 |
同一份配置在 SERVLET / 非 SERVLET 下行为不同 | 15.5.4 |
| 19 | 自定义 McpSyncServerCustomizer 导致启动失败 |
NoUniqueBeanDefinitionException |
15.6.2;加 @Primary 或改 type=ASYNC |
| 20 | Optional<T> 注入不解决多候选冲突 |
误以为"Optional 就安全" | 15.6.2 |
| 21 | type=SYNC 崩、type=ASYNC 不崩 |
同一份代码改一个配置就从崩到不崩 | McpServerAutoConfiguration:223-225 的 @ConditionalOnProperty |
| 22 | spring.ai.mcp.server.keep-alive-interval 与 streamable-http.keep-alive-interval 同名不同归属 |
配错位置导致心跳不生效 | McpServerSseProperties:33 vs McpServerStreamableHttpProperties:32 |
| 23 | McpServerSseProperties 与 McpServerProperties 共享 spring.ai.mcp.server 前缀 |
靠字段名区分,极易看错 | 15.1.5 |
| 24 | curl 默认 Accept: */* → 400 |
第一步调试就失败 | 15.12.1;必须显式写 Accept: application/json, text/event-stream |
| 25 | Accept 带 q 参数导致 contains 失配 |
明明写了却仍 400 | 15.12.2;不带参数 |
| 26 | Accept 校验失败返回 -32601 METHOD_NOT_FOUND |
语义错配,误导排障方向 | :434-441 |
| 27 | "未知消息类型"在 MVC 是 500、在 WebFlux 是 400 | 同一错误两个栈状态不同 | :517-522 vs WebFlux...:376-379 |
| 28 | WebFlux 的多个 400 分支空响应体 | 排障时读不到任何原因(源码自带 // TODO) |
WebFlux...:257-258、:261-263、:321-325 |
| 29 | WebFlux handlePost 无 catch (Exception) 兜底 |
内部错误不产生 JSON-RPC 错误对象 | 15.12.3 |
| 30 | 六个 Builder 的 securityValidator 默认 NOOP |
默认零 Origin 校验,DNS rebinding 需自接 | 15.12.5 |
| 31 | 想接安全钩子必须放弃自动配置的 transport provider | 没有配置属性/Bean 注入口 | 15.12.5 |
| 32 | listChanged = true 是空头承诺 |
声明支持变更通知,但全仓库无 notifyClients 调用点 |
15.9.4 |
| 33 | expose-mcp-client-tools=true 的风险 |
工具名二次改写、环路、下游故障传导 | 15.10.3 |
| 34 | initialize 返回 JSON、其它请求返回 SSE |
只实现单一解析路径的客户端会在 tools/list 挂掉 |
:628-631 vs :489-515 |
| 35 | isClosing 与安全校验在三个 handler 里各复制一遍 |
无统一扩展点,只能改源码或加 Filter | :317/422/549、:321/426/553 |
15.14 本章小结
-
协议选择不是"填个枚举值",而是选择一整套装配。
protocol的三个值对应三组不同的自动配置(McpServerAutoConfiguration有状态 / SSE 传输 / STREAMABLE 传输 /McpServerStatelessAutoConfiguration无状态),它们在 capabilities、customizer 入口、变化通知、端点数量上都不等价。 -
默认值是错的方向。
protocol字段默认STREAMABLE,但不写这个属性时实际生效的是已废弃的 SSE 。这是 Spring AI 2.0.1 服务端最值得写成团队规范的一条:永远显式写spring.ai.mcp.server.protocol。 -
存在静默失败的组合。
stdio=true+protocol=STATELESS会让你既拿不到 server Bean 也拿不到 transport Bean,而应用照常启动。诊断手段是搜索启动日志里的Registered tools:。 -
Streamable HTTP 的"状态"是纯内存、默认永不回收、且不可配置的。
maxSessions=100_000与sessionIdleTimeout=null都没有对应的配置属性,会话表是ConcurrentHashMap。这直接决定了它不适合多实例无粘滞部署。 -
SSE 与 Streamable 的会话标识机制根本不同 :query param vs 请求头;连接断开自动移除 vs 必须显式
DELETE。 -
STATELESS 是一整套降级,不是一个"轻量模式"。 能力全 false、无 customizer、无 roots、无变化通知、只有 POST、只返回 JSON。它换来的是"一个 HTTP 请求一个 JSON"的极致简单与天然的水平扩展性。
-
两个 Web 栈的传输实现是两套副本,且错误响应有实质差异:WebMVC 有 body 有兜底,WebFlux 多处空 body 且无兜底;"未知消息类型"一个 500 一个 400。选型时要把"排障体验"算进去。
-
传输层的安全默认是零。 六个 Builder 的
securityValidator全是NOOP,而自动配置又没有注入口------接安全校验的代价是放弃自动配置。这是 2.0.1 在安全可扩展性上的真实空缺。 -
能力协商里有一处"声明与实现的落差" :
listChanged = true被写进 capabilities,但全仓库找不到notifyClients的调用点------因为 Spring AI 启动时一次性注册工具集,没有运行时增删的 API。
15.15 思考题
-
McpServerProperties.protocol初始化为STREAMABLE,但NonStatelessServerCondition里 SSE 分支是matchIfMissing=true、STREAMABLE 分支是matchIfMissing=false。如果不改代码、只改配置,能不能让"不写protocol"时生效的是 STREAMABLE? 如果不能,最小侵入的规避手段是什么? -
你的应用是
stdio=true+protocol=STATELESS,启动后日志里完全没有 MCP 相关输出,也没有报错。请写出三种定位手段(不能依赖"看日志里有没有 MCP"这一条),并说明每种手段各自能确认什么。 -
有状态 Streamable 部署在两个实例后面,负载均衡用轮询。客户端第一次
initialize打到实例 A,第二次tools/call打到实例 B,会发生什么? 请给出 HTTP 状态码、业务码和响应体内容,并给出至少两种工程解法。 -
你要给 MCP 服务端加 Origin 白名单校验。请评估三条路径的可行性 :(a)通过配置属性;(b)定义
ServerTransportSecurityValidatorBean;(c)自己定义WebMvcStreamableServerTransportProviderBean。分别说明代价。 -
WebFlux 栈下,
POST /mcp的Accept头不对时返回 400 且响应体为空 ,源码里还有一句// TODO: say we need a session id。在不改 Spring AI 源码的前提下,你能用什么手段让客户端拿到可读的错误信息? 请给出至少两种,并比较侵入性。 -
listChanged = true被写进了 capabilities,但全仓库没有notifyClients的调用点。请论证:这个声明在什么场景下是"无害的多余",在什么场景下会变成"客户端行为异常的根因"? -
type=SYNC时定义一个自己的McpSyncServerCustomizer会启动失败,改成type=ASYNC就正常。请解释这背后的条件链,并说明为什么"改一个配置就从崩到不崩"这类现象在自动配置体系里特别危险。 -
STATELESS 下所有请求都返回
application/json,有状态 Streamable 下initialize返回 JSON、其它返回 SSE。如果你要写一个"只支持 JSON、不支持 SSE"的极简客户端,最小可用实现需要覆盖哪些报文形态? 哪些 MCP 能力是你必然要放弃的?
15.16 本章文件索引
自动配置(服务端公共)
| 文件 | 行数 | 本章引用 |
|---|---|---|
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java |
403 | 15.1、15.2、15.6、15.9 |
.../common/autoconfigure/McpServerStatelessAutoConfiguration.java |
274 | 15.1、15.5、15.6、15.9 |
.../common/autoconfigure/McpServerStdioDisabledCondition.java |
48 | 15.1.2 |
.../common/autoconfigure/McpServerJsonMapperAutoConfiguration.java |
71 | 15.2 |
.../common/autoconfigure/ToolCallbackConverterAutoConfiguration.java |
135 | 15.10 |
.../common/autoconfigure/StatelessToolCallbackConverterAutoConfiguration.java |
129 | 15.5、15.10 |
.../common/autoconfigure/properties/McpServerProperties.java |
272 | 15.1.1、15.6.1 |
.../common/autoconfigure/properties/McpServerSseProperties.java |
90 | 15.1.5、15.6.1 |
.../common/autoconfigure/properties/McpServerStreamableHttpProperties.java |
71 | 15.3、15.6.1 |
.../common/autoconfigure/properties/McpServerChangeNotificationProperties.java |
80 | 15.9 |
自动配置(WebMVC / WebFlux 传输)
| 文件 | 行数 |
|---|---|
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/autoconfigure/McpServerSseWebMvcAutoConfiguration.java |
100 |
.../webmvc/autoconfigure/McpServerStreamableHttpWebMvcAutoConfiguration.java |
74 |
.../webmvc/autoconfigure/McpServerStatelessWebMvcAutoConfiguration.java |
69 |
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webflux/src/main/java/org/springframework/ai/mcp/server/webflux/autoconfigure/McpServerSseWebFluxAutoConfiguration.java |
95 |
.../webflux/autoconfigure/McpServerStreamableHttpWebFluxAutoConfiguration.java |
73 |
.../webflux/autoconfigure/McpServerStatelessWebFluxAutoConfiguration.java |
68 |
传输实现
| 文件 | 行数 | 本章引用 |
|---|---|---|
mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/WebMvcStreamableServerTransportProvider.java |
999 | 15.3、15.7、15.12 |
mcp/transport/mcp-spring-webmvc/.../WebMvcStatelessServerTransport.java |
313 | 15.5、15.12.4 |
mcp/transport/mcp-spring-webmvc/.../WebMvcSseServerTransportProvider.java |
686 | 15.4 |
mcp/transport/mcp-spring-webmvc/.../HeaderUtils.java |
45 | 15.12.5 |
mcp/transport/mcp-spring-webflux/.../WebFluxStreamableServerTransportProvider.java |
748 | 15.7、15.12.3 |
mcp/transport/mcp-spring-webflux/.../WebFluxStatelessServerTransport.java |
285 | 15.5 |
mcp/transport/mcp-spring-webflux/.../WebFluxSseServerTransportProvider.java |
657 | 15.4 |
mcp/transport/mcp-spring-webflux/.../HeaderUtils.java |
49 | 15.12.5 |
15.17 复核命令
bash
# 1. protocol 字段默认值与实际判定
sed -n '95,105p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/properties/McpServerProperties.java
sed -n '343,401p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java
# 2. stdio 禁用条件
cat auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerStdioDisabledCondition.java
# 3. SSE 的废弃标记
grep -rn "forRemoval" auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webmvc/ auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/properties/McpServerSseProperties.java
# 4. STDIO 兜底 Bean(只有 ConditionalOnMissingBean)
sed -n '85,100p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java
# 5. 会话表与两个不可配置的默认值
grep -n "maxSessions\|sessionIdleTimeout\|sessions =\|sessionLastAccessTimes" mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/WebMvcStreamableServerTransportProvider.java
# 6. Streamable 的三个端点
grep -n "GET\|POST\|DELETE\|route(" mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/WebMvcStreamableServerTransportProvider.java | head -20
# 7. Last-Event-ID 重放
grep -n "LAST_EVENT_ID\|replay" mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/WebMvcStreamableServerTransportProvider.java
# 8. SSE 用 query param 传 sessionId
grep -n "sessionId\|endpoint" mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/WebMvcSseServerTransportProvider.java | head -20
# 9. STATELESS 只有一个 POST、GET 405
sed -n '115,145p' mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/WebMvcStatelessServerTransport.java
# 10. STATELESS 的能力硬编码
grep -n "false" auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerStatelessAutoConfiguration.java | head -20
# 11. capabilities 的组装
sed -n '200,215p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java
# 12. listChanged 的空头承诺:找 notifyClients 的调用点
grep -rn "notifyClients\|notifyClient(" --include=*.java . | grep -v "/test/"
# 13. Optional<McpSyncServerCustomizer> 与默认 customizer
grep -n "Optional<McpSyncServerCustomizer>\|servletMcpSyncServerCustomizer" auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java
# 14. WebMVC vs WebFlux 的 POST Accept 校验写法
grep -n "TEXT_EVENT_STREAM\|APPLICATION_JSON" mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/WebMvcStreamableServerTransportProvider.java
grep -n "TEXT_EVENT_STREAM\|APPLICATION_JSON" mcp/transport/mcp-spring-webflux/src/main/java/org/springframework/ai/mcp/server/webflux/transport/WebFluxStreamableServerTransportProvider.java
# 15. WebFlux 的空 body 400 与那句 TODO
grep -n "TODO" mcp/transport/mcp-spring-webflux/src/main/java/org/springframework/ai/mcp/server/webflux/transport/WebFluxStreamableServerTransportProvider.java
# 16. 六个 NOOP 安全校验默认值
grep -rn "ServerTransportSecurityValidator.NOOP" --include=*.java mcp/
# 17. HeaderUtils 的 key 小写化
cat mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport/HeaderUtils.java
# 18. expose-mcp-client-tools 的防环路与转发
grep -rn "exposeMcpClientTools\|ToolCallbackUtils" --include=*.java auto-configurations/mcp/ | grep -v "/test/"
15.18 下一章预告
第 13、14、15 三章把 MCP 这条线(客户端 → 服务端注解实现 → 协议与传输)讲完了。到这里,Spring AI 里"模型怎么调"和"工具怎么来"两大块都已落地。
第 16 章:向量存储(上)------VectorStore 接口体系、BatchingStrategy 与 SearchRequest。
这一章会回到 RAG 的地基。第 7、8 章讲的是"检索增强怎么编排",但没有回答"文档到底存在哪、相似度到底怎么算"。第 16 章要处理的正是这件事:
VectorStore extends DocumentWriter, VectorStoreRetriever(VectorStore:40)------这个双继承 才是理解整个向量存储体系的钥匙:写入侧来自DocumentWriter,检索侧被抽成了独立的VectorStoreRetriever函数式接口(只为"只读检索"的场景留的口子)。- 接口本身只有 144 行,方法收敛为三组契约:写入(
add/accept)、删除(delete(List<String>)/delete(Filter.Expression)/delete(String))、检索(similaritySearch,继承自VectorStoreRetriever)。 BatchingStrategy(spring-ai-model/.../embedding/BatchingStrategy.java)在整个仓库里只有一个实现 ------TokenCountBatchingStrategy。它只有一个方法List<List<Document>> batch(List<Document>),契约里唯一被强调的约束是"必须保持顺序"。SearchRequest(SearchRequest.java,295 行)只有四个字段 :query(默认"")、topK(默认DEFAULT_TOP_K = 4)、similarityThreshold(默认SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0)、filterExpression(默认 null,且是@Nullable)。注意topK=4这个默认值------它是 RAG 效果调优里最常被忽略的一个隐藏参数。- 以及那个被反复问到的问题:
similarityThreshold到底是在 Java 侧过滤,还是在数据库侧下推? 答案是"看实现"------而这个"看实现"正是第 16、17 章要用十几个VectorStore实现逐一对账的地方。