MCP Java SDK 2.1.0 在 10 月 16 日发布,最实用的新功能是按请求过滤 tools/list,同一个 MCP 服务可以让不同身份的调用方看到不同的工具。过滤只管“列出来”,隐藏的工具按名字照样能调用。另外,默认开启的会话清理会留下一个非守护线程,靠 main 方法自然返回来结束进程的应用,关闭 Spring 上下文之后 JVM 不会退出;用 SIGTERM 停掉的常规 Web 服务不受影响。
这一版还给 Streamable HTTP 服务端默认打开了保活和空闲会话清理,服务端不再接受 2024-11-05 协议。下面先过一遍改动,再看在 Spring Boot 里跑出来的结果。
2.1.0 改了什么
以下内容对照了 v2.1.0 发布说明 和 v2.1.0 tag 的源码。
工具列表过滤
对应 #1108。服务端 builder 上新增 addToolFilter,同步版接收 McpSyncListFilter<McpSchema.Tool>,异步版接收 McpAsyncListFilter,无状态 builder 上也有。过滤器拿到的是当前请求的 McpTransportContext 和一个工具,返回要不要列出它。
官方文档 docs/server.md 里把规则写得很细。每次 tools/list 都会重新计算,不缓存。注册多个过滤器时按“与”组合,后加的过滤器只会让结果更少,不会放宽。过滤器抛异常时,整个列表请求失败,客户端收到 -32603,不会悄悄少几个工具。notifications/tools/list_changed 不经过过滤。STDIO 没有请求元数据,过滤器拿到的是 McpTransportContext.EMPTY。
还有一条和 Spring 用户关系很大:同步过滤器默认跑在共享的调度线程上,不在请求线程上。SecurityContextHolder、MDC 这类线程绑定的状态在过滤器里拿不到。文档的做法是在传输层的 contextExtractor 里先把身份解析出来放进上下文,过滤器只读上下文。
文档也用警告框写明了:过滤器只控制展示,隐藏的工具被按名字调用时仍然会执行,权限必须在工具的调用处理器里自己检查。
HTTP 客户端传输重写
对应 #1079 和 #1165。基于 JDK HttpClient 的传输内部从 Subscriber 改成了 Publisher。#1079 的提交说明写明这次没有公开 API 变化,改的是可靠性:关闭的传输不再拖着 HttpClient 不放,closeGracefully() 在 DELETE 失败时也会释放连接,服务端返回读不了的 JSON 时立刻报出真实原因,不再等到超时。服务端引起的错误现在是 McpTransportException,消息里带上服务端返回的响应体。
#1165 改了请求定制器的注册方式。HttpClientStreamableHttpTransport 和 HttpClientSseClientTransport 的 builder 上新增 addHttpRequestCustomizer 和 addAsyncHttpRequestCustomizer,多次调用会叠加。原来的 httpRequestCustomizer 和 asyncHttpRequestCustomizer 标成了 @Deprecated,还能用,但调用它们会先清空之前加过的定制器。
Streamable HTTP 服务端的会话管理
对应 #1130。HttpServletStreamableServerTransportProvider 的 keepAliveInterval 原来默认是 null,不发保活;2.1.0 改成默认 30 分钟。新增 sessionSweepInterval,同样默认 30 分钟。按源码注释,一个会话既没有打开的流、又在一整个间隔里没收到请求,就算空闲,会被清掉。客户端断开后不发 DELETE 的会话,以前只能等服务重启才释放。设成 null 可以关掉清理。
移除 2024-11-05 协议
对应 #1126,改动只在 Streamable HTTP 上。HttpClientStreamableHttpTransport 默认支持的版本列表去掉了 2024-11-05,服务端的 protocolVersions() 只返回 2025-03-26、2025-06-18、2025-11-25 三个。2024-11-05 本来就是 HTTP+SSE 时代的协议版本,和 Streamable HTTP 对不上。v2.1.0 的 docs/client.md 仍写着 HttpClientStreamableHttpTransport 默认通告 2024-11-05。
其他
ServerHttpHeaderValidator 取代了 ServerTransportSecurityValidator,后者和 builder 上的 securityValidator(...) 都标成了 @Deprecated(#982);POST 请求的 Content-Type 不是 application/json 时直接拒绝(#1164);ClientCapabilities 和 ServerCapabilities 加了 SEP-2133 的 extensions 属性(#1064);STDIO 的 sendMessage 改成线程安全(#935);用 LAST_EVENT_ID 恢复流时的泄漏修了;listXyz() 返回的 Mono 可以重复订阅了。
在 Spring Boot 里跑一遍
项目是 Spring Boot 4.0.5、JDK 21,引 spring-boot-starter-web 和 SDK 的 mcp 模块。mcp 会带上 mcp-core 和 Jackson 3 的 JSON 模块,正好和 Boot 4 用的 Jackson 3 一致。
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>2.1.0</version>
</dependency>
这次没有用任何 Spring 的 MCP starter,直接用 SDK。之前那篇讲 LangChain4j Community MCP Server starter 的文章走的是自动配置,这里换成手动装配,SDK 的每个开关都能直接看到。整个例子没有调用任何模型 API。
HttpServletStreamableServerTransportProvider 本身就是一个 HttpServlet,用 ServletRegistrationBean 挂到 /mcp 上,要打开异步支持。contextExtractor 在请求线程上执行,这里把请求头 X-Role 取出来放进上下文:
@Bean
HttpServletStreamableServerTransportProvider mcpTransport() {
return HttpServletStreamableServerTransportProvider.builder()
.mcpEndpoint("/mcp")
.contextExtractor(request -> McpTransportContext.create(
Map.of("role", String.valueOf(request.getHeader("X-Role")))))
.build();
}
@Bean
ServletRegistrationBean<HttpServletStreamableServerTransportProvider> mcpServlet(
HttpServletStreamableServerTransportProvider transport) {
ServletRegistrationBean<HttpServletStreamableServerTransportProvider> bean =
new ServletRegistrationBean<>(transport, "/mcp", "/mcp/*");
bean.setAsyncSupported(true);
return bean;
}
服务端注册三个工具。order_status 谁都能看;admin-ping 和 admin-flush_cache 只给管理员看。区别在于 admin-ping 只靠过滤器隐藏,处理器里不做检查;admin-flush_cache 的处理器会再校验一次角色。
@Bean(destroyMethod = "close")
McpSyncServer mcpServer(HttpServletStreamableServerTransportProvider transport) {
return McpServer.sync(transport)
.serverInfo("order-tools", "0.0.1")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(orderStatus(), adminPing(), adminFlushCache())
.addToolFilter((ctx, tool) -> !tool.name().startsWith("admin-") || isAdmin(ctx))
.build();
}
static boolean isAdmin(McpTransportContext ctx) {
return "admin".equals(ctx.get("role"));
}
static SyncToolSpecification adminFlushCache() {
return SyncToolSpecification.builder()
.tool(Tool.builder("admin-flush_cache", EMPTY_SCHEMA).description("清空缓存").build())
.callHandler((exchange, req) -> isAdmin(exchange.transportContext())
? text("cache flushed")
: CallToolResult.builder().content(List.of(new TextContent("forbidden"))).isError(true).build())
.build();
}
客户端用 HttpClientStreamableHttpTransport,通过新的 addHttpRequestCustomizer 给每个请求加上 X-Role 头。分别建一个 guest 客户端和一个 admin 客户端:
static McpSyncClient client(String base, String role) {
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(base)
.endpoint("/mcp")
.addHttpRequestCustomizer((builder, method, uri, body, context) -> builder.header("X-Role", role))
.build();
return McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10)).build();
}
应用启动后,在 ApplicationRunner 里让两个客户端各自 initialize、listTools,再调用几个工具。最后用 JDK HttpClient 直接发两个原始 POST:一个 Content-Type 是 text/plain,一个是用 2024-11-05 版本发起的 initialize。main 方法里跑完就调用 ctx.close() 关闭 Spring 上下文,再打印还活着的非守护线程:
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(McpDemoApplication.class, args);
ctx.close();
Thread.getAllStackTraces().keySet().stream()
.filter(t -> t.isAlive() && !t.isDaemon() && t != Thread.currentThread())
.map(Thread::getName).sorted()
.forEach(name -> System.out.println("[shutdown] non-daemon thread still alive: " + name));
System.out.println("[shutdown] main returns");
}
运行结果
mvn -B package -DskipTests
timeout 40 java -jar target/mcp-sdk-21-demo-0.0.1.jar
端口配的是 8089,日志级别调到了 WARN,下面是程序输出。其中 415 那一行很长,响应体后半段是一整段 stackTrace 数组,这里只保留到 stackTrace 开头:
[server] listening on http://localhost:8089/mcp
[guest] protocol 2025-11-25
[guest] tools/list -> [order_status]
[admin] tools/list -> [order_status, admin-ping, admin-flush_cache]
[guest] tools/call order_status -> isError=false order A-1001 shipped
[guest] tools/call admin-ping -> isError=false pong
[guest] tools/call admin-flush_cache -> isError=true forbidden
[admin] tools/call admin-flush_cache -> isError=false cache flushed
[raw] POST text/plain -> 415 {"jsonRpcError":{"code":-32600,"message":"Unsupported Media Type: Content-Type must be application/json"},"cause":null,"localizedMessage":"Unsupported Media Type: Content-Type must be application/json","message":"Unsupported Media Type: Content-Type must be application/json","stackTrace":[...
2026-10-17T08:10:37.124+08:00 WARN 1124431 --- [nio-8089-exec-9] i.m.server.McpAsyncServer : Client requested unsupported protocol version: 2024-11-05, so the server will suggest the 2025-11-25 version instead
[raw] POST application/json -> 200 {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"tools":{"listChanged":true}},"serverInfo":{"name":"order-tools","version":"0.0.1"}}}
[shutdown] non-daemon thread still alive: streamable-http-server-transport-session-sweeper-1
[shutdown] main returns
过滤生效了。guest 只看到 order_status,admin 看到全部三个。
但 guest 按名字调用 admin-ping,拿到了 pong。这个工具在 guest 的列表里根本不存在,照样执行了,和文档的警告一致。admin-flush_cache 因为处理器里自己查了角色,guest 调用时返回 isError=true forbidden,admin 调用才成功。
text/plain 的 POST 被拒,状态码 415,JSON-RPC 错误码 -32600。响应体把整个 McpError 异常对象序列化了出去,包括 cause、localizedMessage 和完整的 stackTrace,类名、行号、Tomcat 过滤器链都在里面。看源码,responseError 方法直接 jsonMapper.writeValueAsString(mcpError),这个写法在 2.1.0 之前就有,这次新加的 415 分支也走了它。仓库里有一个 2025 年 9 月提的 issue 讨论过这类错误响应的格式(#577),目前仍是打开状态。服务暴露在公网的话,这些 4xx 响应体会把服务端栈信息带出去。
用 2024-11-05 发起的 initialize 没有被拒,返回 200。服务端打了一条 WARN,回给客户端的 protocolVersion 是 2025-11-25。按 MCP 的版本协商规则,客户端收到自己不支持的版本时应当断开连接。也就是说,只认 2024-11-05 的老客户端会在客户端这一侧失败,服务端只留一条 WARN。
关闭上下文后 JVM 不退出
上面输出的最后两行是 ctx.close() 之后打印的。main 方法已经返回,但还剩一个非守护线程 streamable-http-server-transport-session-sweeper-1,进程不退出,40 秒后被 timeout 杀掉,退出码 124。
注意这里 close 是调用过的。ctx.close() 会销毁 Servlet,destroy() 里调 closeGracefully().block(),McpSyncServer 的 close 也会转到传输层。问题出在源码里:startSessionSweeper 用 Schedulers.newSingle("streamable-http-server-transport-session-sweeper") 新建了一个 Reactor 调度器,Schedulers.newSingle(String) 建的是非守护线程。closeGracefully() 最后只对 Flux.interval 的订阅调了 dispose(),这个调度器本身没有释放,线程就留了下来。
把 sessionSweepInterval 设成 null 再跑一次:
HttpServletStreamableServerTransportProvider.builder()
.sessionSweepInterval(null)
.mcpEndpoint("/mcp")
// ...
.build();
timeout 40 java -jar target/mcp-sdk-21-demo-0.0.1.jar --demo.sweep=false
前面的输出和默认配置一样,结尾是:
[raw] POST application/json -> 200 {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"tools":{"listChanged":true}},"serverInfo":{"name":"order-tools","version":"0.0.1"}}}
[shutdown] main returns
这次没有残留线程,进程正常退出,退出码 0。
触发条件要说清楚。JVM 只有在所有非守护线程结束后才会自己退出,这个残留线程卡住的就是这条路径:main 里关闭上下文后自然返回,比如命令行工具、批处理任务、在 main 里起停服务端的集成测试。常规的 Web 服务一般是收到 SIGTERM 后由关闭钩子关掉 Spring 上下文,进程随后退出,不受这个线程影响。我对默认配置的进程发了一次 SIGTERM,进程立即退出,退出码 143。用 System.exit(SpringApplication.exit(ctx)) 这类方式显式退出的应用也不受影响。只调 SpringApplication.exit(ctx) 不够,它只关上下文、返回退出码,不会结束 JVM。
截至发稿,上游仓库的 issue 里还没有人报这个问题。相近的是还没合并的 #869,它处理的是 STDIO 传输的线程没有设成守护线程。
一起交流
分享你的思考,让讨论更进一步。