Spring AI 2.0.1 深度解析-第 15 章 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理

Spring AI 2.0.1 深度解析-第 15 章 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理

第 14 章解决了"MCP 服务端的工具从哪来"------注解怎么被扫、SyncToolSpecification 怎么被造出来、返回值怎么变成 CallToolResult。但还有一个更前置的问题没回答:

客户端的请求是怎么进来的,进来之后服务端要不要记住它?

这看起来是个配置问题(spring.ai.mcp.server.protocol 填个值就行),实际上它是 Spring AI 2.0.1 里条件判定最绕 的一块:字段默认值和实际生效值相反,两种配置组合会"静默什么都不装配",而会话相关的两个关键参数根本没有配置属性。


15.0 本章的五句总纲

  1. protocol 的字段默认值与实际生效值相反。 McpServerProperties.protocol 初始化为 STREAMABLE(McpServerProperties:100),但 NonStatelessServerCondition 里 SSE 分支是 matchIfMissing = true、STREAMABLE 分支是 matchIfMissing = false(McpServerAutoConfiguration:349-356)。于是不写这个属性时生效的是 SSE ------而 McpServerSseWebMvcAutoConfiguration 带着 @Deprecated(forRemoval = true)(:76)。

  2. 有状态与无状态是两套完全不同的装配,不是同一个开关的两个值。 McpServerAutoConfiguration 与 McpServerStatelessAutoConfiguration 各自定义 capabilitiesBuilder、各自的 spec 来源、各自的 server Bean。STATELESS 下变化通知被硬编码为 false ,连 McpSyncServerCustomizer 与 roots 回调都没有入口。

  3. Streamable HTTP 的会话是纯内存的,且默认永不回收。 sessions 是一个 ConcurrentHashMap(WebMvcStreamableServerTransportProvider:112);maxSessions 默认 100_000、没有配置属性 ;sessionIdleTimeout 默认 null、也没有配置属性 ,且为 null 时连清理调度器都不启动(:196-199)。

  4. SSE 与 Streamable 的会话标识机制完全不同。 SSE 用 query param (?sessionId=,通过 endpoint 事件告知客户端,WebMvcSseServerTransportProvider:110、:347、:365-372);Streamable 用 Mcp-Session-Id 请求头 (WebMvcStreamableServerTransportProvider:463、:571)。SSE 的会话在连接断开时自动移除(:332-343),Streamable 的必须显式 DELETE。

  5. 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 生效(有状态)。
  • 但具体用哪个传输 ,看 EnabledSseServerCondition vs EnabledStreamableServerCondition:同样在未配置时,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)

三处细节:

  1. 会话 ID 在 query param 里 (request.param(SESSION_ID)),不是请求头。这意味着 sessionId 会出现在访问日志、浏览器历史、代理日志 里。从安全角度看,把会话凭证放在 URL 里不如放在请求头里------这也是 Streamable HTTP 改用 Mcp-Session-Id 头的原因之一。
  2. session.handle(message).block() ------ 后面那行注释(:424-426)是源码里写着的 "Block for WebMVC compatibility"(被 formatter 拆成了四行)。WebMVC 是 Servlet 阻塞模型,这里必须 block;WebFlux 版本不需要。
  3. 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 说过会被过滤掉);
  • McpSyncServerCustomizer Bean 在 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 应用中定义你自己的 McpSyncServerCustomizer Bean,会直接导致启动失败 (NoUniqueBeanDefinitionException),除非:

  1. 给你的 Bean 加 @Primary;或
  2. 把 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 中会:

  1. 占用一个容器线程;
  2. block() 等待工具方法执行完(工具里的 JDBC/HTTP 调用都在这个线程上);
  3. 用 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(WebMvc McpServerStreamableHttpProperties:43 传入)

  • keepAliveInterval / contextExtractor / securityValidator 的 Builder 默认值(McpTransportContext.EMPTY / ServerTransportSecurityValidator.NOOP)

  • protocolVersions() 返回四个版本(WebMvcStreamableServerTransportProvider:203-206):

    java 复制代码
    return 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 前缀"而刻意分开展示。实际写法:

yaml 复制代码
spring:
  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 概念)

注意三处不对称:

  1. resources 的第一参数恒为 false ------subscribe(资源订阅)能力关闭且不可配。第二版 Spring AI 的自动配置里没有任何属性能打开它。
  2. completions() 没有参数 ------ServerCapabilities.completions 在 MCP 规范里就是个空标记。
  3. 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):

    vbnet 复制代码
    Found 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 章完整地接了起来,也解释了几个之前埋下的伏笔:

  1. 第 13 章 13.5 的命名消歧在这里才真正有意义 :下游工具名经过 DefaultMcpToolNamePrefixGenerator 加了前缀(如 brave_web_search),如果不加前缀就转发,很容易与本地工具重名(14.9.3)。
  2. 第 13 章 13.10 讲的"MCP 工具与 Spring AI 工具的三个不一样"在这里会叠加 :转发后的工具走的是 ToolCallback 路径(第 14 章 14.8),丢掉 了下游工具原本的 title、ToolAnnotations、outputSchema------也就是说网关会降级下游工具的元数据。
  3. 第 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,自己定义 WebMvcStreamableServerTransportProvider Bean。 这等于把 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 本章小结

  1. 协议选择不是"填个枚举值",而是选择一整套装配。 protocol 的三个值对应三组不同的自动配置(McpServerAutoConfiguration 有状态 / SSE 传输 / STREAMABLE 传输 / McpServerStatelessAutoConfiguration 无状态),它们在 capabilities、customizer 入口、变化通知、端点数量上都不等价。

  2. 默认值是错的方向。 protocol 字段默认 STREAMABLE,但不写这个属性时实际生效的是已废弃的 SSE 。这是 Spring AI 2.0.1 服务端最值得写成团队规范的一条:永远显式写 spring.ai.mcp.server.protocol。

  3. 存在静默失败的组合。 stdio=true + protocol=STATELESS 会让你既拿不到 server Bean 也拿不到 transport Bean,而应用照常启动。诊断手段是搜索启动日志里的 Registered tools:。

  4. Streamable HTTP 的"状态"是纯内存、默认永不回收、且不可配置的。 maxSessions=100_000 与 sessionIdleTimeout=null 都没有对应的配置属性,会话表是 ConcurrentHashMap。这直接决定了它不适合多实例无粘滞部署。

  5. SSE 与 Streamable 的会话标识机制根本不同 :query param vs 请求头;连接断开自动移除 vs 必须显式 DELETE。

  6. STATELESS 是一整套降级,不是一个"轻量模式"。 能力全 false、无 customizer、无 roots、无变化通知、只有 POST、只返回 JSON。它换来的是"一个 HTTP 请求一个 JSON"的极致简单与天然的水平扩展性。

  7. 两个 Web 栈的传输实现是两套副本,且错误响应有实质差异:WebMVC 有 body 有兜底,WebFlux 多处空 body 且无兜底;"未知消息类型"一个 500 一个 400。选型时要把"排障体验"算进去。

  8. 传输层的安全默认是零。 六个 Builder 的 securityValidator 全是 NOOP,而自动配置又没有注入口------接安全校验的代价是放弃自动配置。这是 2.0.1 在安全可扩展性上的真实空缺。

  9. 能力协商里有一处"声明与实现的落差" :listChanged = true 被写进 capabilities,但全仓库找不到 notifyClients 的调用点------因为 Spring AI 启动时一次性注册工具集,没有运行时增删的 API。


15.15 思考题

  1. McpServerProperties.protocol 初始化为 STREAMABLE,但 NonStatelessServerCondition 里 SSE 分支是 matchIfMissing=true、STREAMABLE 分支是 matchIfMissing=false。如果不改代码、只改配置,能不能让"不写 protocol"时生效的是 STREAMABLE? 如果不能,最小侵入的规避手段是什么?

  2. 你的应用是 stdio=true + protocol=STATELESS,启动后日志里完全没有 MCP 相关输出,也没有报错。请写出三种定位手段(不能依赖"看日志里有没有 MCP"这一条),并说明每种手段各自能确认什么。

  3. 有状态 Streamable 部署在两个实例后面,负载均衡用轮询。客户端第一次 initialize 打到实例 A,第二次 tools/call 打到实例 B,会发生什么? 请给出 HTTP 状态码、业务码和响应体内容,并给出至少两种工程解法。

  4. 你要给 MCP 服务端加 Origin 白名单校验。请评估三条路径的可行性 :(a)通过配置属性;(b)定义 ServerTransportSecurityValidator Bean;(c)自己定义 WebMvcStreamableServerTransportProvider Bean。分别说明代价。

  5. WebFlux 栈下,POST /mcp 的 Accept 头不对时返回 400 且响应体为空 ,源码里还有一句 // TODO: say we need a session id。在不改 Spring AI 源码的前提下,你能用什么手段让客户端拿到可读的错误信息? 请给出至少两种,并比较侵入性。

  6. listChanged = true 被写进了 capabilities,但全仓库没有 notifyClients 的调用点。请论证:这个声明在什么场景下是"无害的多余",在什么场景下会变成"客户端行为异常的根因"?

  7. type=SYNC 时定义一个自己的 McpSyncServerCustomizer 会启动失败,改成 type=ASYNC 就正常。请解释这背后的条件链,并说明为什么"改一个配置就从崩到不崩"这类现象在自动配置体系里特别危险。

  8. 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 实现逐一对账的地方。
相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):`VectorStore` 接口体系、`BatchingStrategy`
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 23 章 模型供应商实现:从 `OpenAiSetup` 到 Bedrock
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 06 章 内置 Advisor 全景:13 个实现逐个剖到源码级
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 03 章 消息体系与 Prompt:不可变模型、`ChatOptions` 与模板渲染
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 24 章 生产实践与 2.x 迁移:陷阱清单、AOT 与调优
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 11 章 工具调用(下):ToolCallingManager、ToolCallingAdvisor与调用预算
java·人工智能·开源