☰
Spring Boot MCP Server 实践:Streamable-HTTP 与工具开发
2026/9/30 3:47:21 网站建设 项目流程

1. 从stdio到Streamable-HTTP:MCP Server传输方式这次改了什么

MCP 这三字最近在技术圈刷屏的频率,基本和年初的 AI 智能体热度绑定在一起。我这次要用 Spring Boot 给团队搭一个内部 MCP Server,把现有 Java 服务暴露给 AI 代理直接调用。动手前我先把协议层面理顺了,结论是:直接用 Streamable-HTTP,别再碰老式的 HTTP+SSE 写法。

一句话解释 MCP Server 是什么:它就是一个遵循 MCP JSON-RPC 协议的 HTTP 服务,把自己的能力注册成一个个"工具"(Tool),然后让大模型在对话过程中决定什么时候去调用这些工具。模型不关心你的工具是 Java 写的还是 Python 写的,它只看你暴露出来的工具描述、参数 Schema 和返回结果。所以 MCP 更像是一份"AI 界的接口契约"。

传输方式的演进值得多说两句。最原始的 MCP 是 stdio 模式,也就是把进程的 stdin/stdout 当作通信管道,适合本地 CLI 工具和 IDE 插件。服务要部署到远程,就得走 HTTP。老方案里大家常见的是 HTTP+SSE:客户端先请求一个/sse端点建立长连接,之后通过另一个 POST 端点发送 JSON-RPC 消息。这套方式能用,但问题也很明显——客户端和服务端之间需要维持一个常驻连接,网关超时、容器重启、连接被掐断都会造成体验很差。

Streamable-HTTP 把这事简化了。它不要求客户端长期挂着一个 SSE 连接,而是允许一次 POST 请求完成"请求-响应"闭环,响应可以是普通 JSON,也可以是 SSE 流。若服务端确实需要主动向客户端推送消息,再通过 GET 流或者 SSE 响应里的 event 来实现。会话状态通过请求头Mcp-Session-Id传递,服务端内存里存 session,客户端每次带着这个头就能保持上下文。

对于 Spring Boot 项目来说,这个改动是实实在在的舒服:不需要单独维护一个常驻连接的 Controller,不需要为每个 SSE 连接设计心跳线程,部署时也不需要在 Nginx 层做特殊的长连接超时配置。我这次选型的核心判断就是:新项目直接用 Streamable-HTTP,符合 MCP 规范的最新推荐,也省掉了一堆运维层面的麻烦。

传输方式连接模型特点适合场景
stdio进程级管道实现简单,适合本地本地开发、编辑器插件
HTTP+SSE客户端常驻连接状态由服务端维护,但容易被网关断连早期远程 MCP
Streamable-HTTP短连接 + 可选流式响应一次请求一次响应,支持流式返回远程服务、生产环境

2. 工程初始化:依赖选型与配置,先看版本再动手

Spring Boot 构建 MCP Server 现在最顺手的方式,不是自己实现 MCP 协议编解码,而是直接用 Spring AI 提供的 MCP Server Starter。我这边的项目基线是 Spring Boot 3.4.x + Java 17,MCP 相关依赖用的是 Spring AI 的 webmvc 实现。选 webmvc 而不是 webflux,主要因为团队现有代码是 Spring MVC 风格,工具方法里还会调现有的数据库和内部 HTTP 服务,阻塞式的线程模型反而更好控制。

pom.xml 里核心就这几个依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> <version>1.0.0</version> </dependency>

如果你的依赖下载时找不到 Spring AI 的包,先检查一下是否把 Spring 的里程碑仓库加进去了。Spring AI 在 1.0.0 发布之前,很多版本在 Maven Central 之外发布,需要补充仓库配置。这个问题我当时排查了近半个小时,后来才发现是仓库没配全。配置完仓库后,还要注意各模块版本尽量统一,不要 MCP Server 用一个版本、Core 用另一个版本,不然启动时很容易出现方法签名对不上的问题。

application.yml 里的关键配置如下:

spring: application: name: mcp-server-demo ai: mcp: server: name: internal-tools-mcp version: 1.0.0 transport: STREAMABLE_HTTP

transport这个配置项直接决定了服务端暴露的端点行为。设成STREAMABLE_HTTP后,Spring Boot 会自动注册一个 Controller,默认路径是/mcp。启动成功后日志里会看到类似这样的信息:

MCP server started at endpoint: /mcp transport=STREAMABLE_HTTP

如果你的日志没显示这个,多半是 starter 没被扫描到,或者 application.yml 里的配置前缀不对。这里提醒一下:不同版本之间配置项名有变动,比如某些旧版本写作spring.ai.mcp.server.transport=HTTP,后来为了区分才改成STREAMABLE_HTTP。遇到启动日志异常,先去看对应版本的自动配置源码,别凭记忆猜。

主启动类不需要额外改动,就是一个标准的 Spring Boot 应用:

@SpringBootApplication public class McpServerApplication { public static void main(String[] args) { SpringApplication.run(McpServerApplication.class, args); } }

我的建议是先只加依赖、不写任何工具方法,启动一次确认/mcp端点已经暴露出来。这一步能帮你把"依赖问题"和"工具实现问题"隔离,后续排错就简单多了。

2.1 为什么选择 webmvc starter 而不是自己写协议层

网上也有不少人直接用 MCP Java SDK 手写McpServer,再配合 Spring MVC 暴露端点。这样做的好处是可控性强,坏处是你要自己处理 JSON-RPC 消息解析、session 管理、SSE 响应封装这些琐碎事。Streamable-HTTP 的协议细节里有不少约定,比如 initialize 请求之后必须通知 initialized,比如Mcp-Session-Id的生成策略,自己写很容易漏掉某个细节而导致某些客户端兼容不了。

我用 starter 之后,工具注册、协议端点的映射、Session 管理都交给了框架,自己只专注写"业务能力方法"。从我的实际经验看,Spring AI 的 MCP Server Starter 在协议层已经做到了开箱即用,和 Claude Desktop、curl、自己写的测试客户端都能正常握手。

2.2 配置项里值得提前调的两处

第一处是端口和上下文路径。MCP Server 会作为独立服务运行,我习惯在项目里固定端口,比如server.port=18080,避免和其他本地应用冲突。

第二处是应用的 Jackson 配置。MCP 工具调用过程中,参数和返回值都要经过 Jackson 序列化。如果项目里有特殊的日期格式、自定义序列化器,尽量在spring.jackson下面统一配置好。我遇到过一次工具返回LocalDateTime时格式不一致导致客户端解析失败,后来在配置里统一了yyyy-MM-dd HH:mm:ss才稳定下来。

3. 工具开发:让 Java 方法变成 AI 可以直接调用的能力

MCP Server 最有价值的部分就是工具方法。一个工具的本质就是一个 Java 方法,加上描述后暴露给大模型。Spring AI 的写法很直白,在 Bean 上写@Tool注解即可。

我写的第一版工具是一个内部订单号查询:

@Component public class OrderTools { private final OrderQueryService orderQueryService; public OrderTools(OrderQueryService orderQueryService) { this.orderQueryService = orderQueryService; } @Tool(description = "根据订单号查询订单状态,返回订单状态、创建时间、金额等信息") public OrderInfo queryOrder( @ToolParam(description = "订单号,例如 ORD202501010001") String orderNo) { return orderQueryService.queryByOrderNo(orderNo); } }

这段代码里有两个关键点。

第一,@Tool的 description 一定要写清楚。大模型看到工具列表时,没有任何代码层面的语义感知,它完全靠 description 来决定"什么时候调这个工具、传什么参数"。你描述越具体,模型就越少瞎猜。参数上的@ToolParam同样重要,尤其是字段的取值范围、格式示例,能显著降低参数传错率。

第二,返回对象OrderInfo会被序列化成 JSON 返回给模型。这个对象应该是一个普通的 POJO 或者 record,字段名清晰,最好带上注释。我不建议直接返回Map<String, Object>,虽然能跑,但大模型对自由格式的 Map 理解能力明显弱于结构明确的类。

工具注册的方式,是把工具方法所在的 Bean 通过ToolCallbackProvider暴露出去:

@Configuration public class McpToolRegistryConfig { @Bean public ToolCallbackProvider registerOrderTools(OrderTools orderTools) { return MethodToolCallbackProvider.builder() .toolObjects(orderTools) .build(); } }

Spring 会自动扫描这个 Provider 中的所有@Tool方法,注册到 MCP 协议的tools/list响应里。

3.1 支持复杂入参和嵌套对象

单参数工具是最简单的,但实际业务里,很多工具需要多个参数。你可以把多个参数压缩在一个 record 或 POJO 中,Spring AI 会自动生成嵌套的 JSON Schema。比如:

public record DateRange( @ToolParam(description = "开始时间,格式 yyyy-MM-dd") String start, @ToolParam(description = "结束时间,格式 yyyy-MM-dd") String end) { }

然后用这个 record 作为方法入参:

@Tool(description = "按时间范围统计订单数") public long countOrders(DateRange range) { return orderQueryService.countByDateRange(range.start(), range.end()); }

大模型收到工具定义后,会看到DateRange对应的 JSON object 结构,自动生成合适的参数。这里我想提醒一个容易踩的坑:如果有嵌套对象的字段是可选的,一定要在描述里说明"不传时使用默认值",否则模型可能因为把握不准而反复追问,或填一个迷惑值进去。

3.2 工具方法里的校验逻辑不能省

MCP 工具直接暴露给大模型时,入参并不像 HTTP 接口那样有严格的参数校验中间件。语言模型即使有工具描述,也会生成各种边界值。所以我在工具方法内部做了完整校验:

if (orderNo == null || !orderNo.startsWith("ORD")) { throw new IllegalArgumentException("订单号必须以ORD开头"); }

异常抛出后,框架会把错误信息返回给大模型,模型通常会根据错误信息修正参数后重试。这实际上构成了一种"模型自主纠错"的链路。我建议每个工具方法都做类似的显式校验,不要把校验完全交给数据库层。

3.3 不写"万能工具"

新手容易犯的毛病是写一个大而全的"执行 SQL 的工具"或者"调用任意 HTTP 接口的工具"。MCP 工具是给 AI 看的,工具粒度越细、职责越单一,模型就越容易准确选择。一个万能工具会把所有逻辑都塞进一个 description 里,模型基本上只能蒙。我把内部接口拆成了订单查询、用户信息查询、库存校验三个独立工具,实际测试的准确率高了不少。

4. 联调验证:从 curl 到 Claude Desktop 的一整条链路

MCP Server 写完后,不能直接扔给上层应用就算完事。我习惯在接入正式客户端之前,先用 curl 把协议链路完整跑一遍。Streamable-HTTP 的核心是 JSON-RPC over HTTP,这里的报文结构值得记一下。

第一步是初始化握手:

curl -i http://localhost:18080/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "curl-test", "version": "0.1.0" } } }'

正常返回的响应头里会带着Mcp-Session-Id。这个值的作用相当于 HTTP 场景里的 session cookie,之后的请求都需要带它。返回体的serverInfo字段会显示你在配置里设置的name和version,说明服务端已经正确识别。

第二步发送 initialized 通知。这一步很容易漏,漏了之后部分实现会拒绝后续的 tools 请求:

curl http://localhost:18080/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: <上一步返回的sessionId>" \ -d '{ "jsonrpc": "2.0", "method": "notifications/initialized" }'

第三步列出工具清单:

curl http://localhost:18080/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: <sessionId>" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }'

这时你应该能在响应里看到自己写的工具名和 JSON Schema。最后是调用工具:

curl http://localhost:18080/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Mcp-Session-Id: <sessionId>" \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "queryOrder", "arguments": { "orderNo": "ORD202501010001" } } }'

响应里会带content数组,里面包含工具返回的 JSON 文本。到这一步,协议链路已经通了。

4.1 日志怎么定位协议问题

我强烈建议联调时把 MCP 相关日志级别调到 DEBUG。如果你用默认配置,日志里只会显示 HTTP 请求线和状态码,看不到 JSON-RPC 消息内容,出了问题基本靠猜。

在application.yml里加:

logging: level: org.springframework.ai: DEBUG io.modelcontextprotocol: DEBUG

调完日志再跑一轮 curl,你能看到框架内部的报文收发过程、session 的创建和命中情况。我之前遇到过一个工具调用总是失败的问题,排查半天,最后日志显示是参数类型映射时 Boolean 和 String 不一致,一眼就定位到了。

4.2 接入 Claude Desktop 或其他 MCP 客户端

现在主流的 AI 客户端基本都支持 MCP,接入方式通常是配置一个 MCP 服务地址。在 Claude 里添加 MCP 服务时,填上:

http://localhost:18080/mcp

然后让它"查询订单 ORD202501010001",观察它是否主动选择调用queryOrder工具。如果描述写得好,模型一般会自己决定调用时机,整个交互过程是模型自主决策的,你作为服务端只是提供能力。

有一点要注意:桌面客户端可能会保持自己的 session 生命周期。如果你重启了 MCP Server,客户端还在复用旧的 sessionId,就会收到 session 不存在的错误。这时重新连接或重启客户端就行,服务端没有做 session 持久化。

4.3 用 Spring WebClient 写一个最小客户端自测

团队里如果不方便引入完整 MCP 客户端 SDK,可以写一个几十行的自测代码,模拟 MCP 客户端发起请求。我这边用 RestClient 写了个冒烟测试,启动测试类之前先起服务端,然后顺序调用 initialize、initialized、tools/list、tools/call。这个方法最适合放进 CI,每次改动工具后自动验证协议层没回退。

@Test void smokeTestMcpStreamableHttp() { String initialize = restClient.post() .uri("/mcp") .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") .body(...) .retrieve() .toEntity(String.class); String sessionId = UUID.fromString(...).toString(); // 后续请求带上 Mcp-Session-Id 继续调用 }

5. 踩坑记录:认证、CORS、超时与会话状态

真正从本地 Demo 走到可用状态,踩的坑比写业务代码多得多。这里把几个高频问题集中列出来。

5.1 Accept 头不一致导致 406

MCP 客户端的请求一般会带Accept: application/json, text/event-stream,但如果某些客户端只带了application/json,而服务端检测到 Streamable-HTTP 能力后倾向于返回text/event-stream,就可能出现 406 或内容类型不匹配。

解决方法是让服务端更宽容一点。在过滤器或 Controller 层把响应 Content-Type 的协商逻辑放宽,或者在 starter 的配置里不强制要求 event-stream。我的处理是保证自己的客户端请求头两边都带上,同时服务端适配了两种响应格式。

5.2 SessionId 丢失的问题

我在 curl 测试时经常发生这种情况:第一次 initialize 成功拿到 sessionId,下一步请求时随手复制漏了一截,服务端返回找不到 session 的错误。实际上这意味着客户端需要自行保存并恢复Mcp-Session-Id,而不是每次重新初始化。

如果你在做远程部署,并且服务端有多个实例,这里要小心:session 状态默认在单机内存里,负载均衡到不同实例会导致 session 丢失。要支持多实例,势必要引入共享 session 存储或直接把服务设计成无状态。Streamable-HTTP 的优势是你每次工具调用都是完整请求,不一定非要依赖本地 session;把 session 迁移到 Redis 也是可以的,但配置复杂度会上升。

5.3 没有正确发送 initialized 通知

MCP 规范里,初始化握手结束、客户端确认能力之后,要发送notifications/initialized通知。这个通知是 JSON-RPC 通知,不带 id,也不是请求。如果漏发或顺序不对,服务端可能一直保持 initialized 状态,后续tools/list的响应会异常或直接被拒绝。

我用 curl 手动模拟时,第一次就漏了这一步,有点懵,后来对照规范加了这条通知才顺利往下走。如果你对接的是成熟客户端,一般框架已经处理好了,但服务端日志里看到 initialized 前就有 tools 请求时,多留个心眼。

5.4 CORS 和反向代理配置

MCP Server 如果被浏览器端的工具调用,或者被某些 Web 应用直接 fetch,CORS 会变成一个必须处理的问题。Spring Boot 项目里加一个全局 CORS 配置即可,注意要把Mcp-Session-Id加到allowedHeaders里。

@Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:3000"); config.addAllowedHeader("*"); config.addExposedHeader("Mcp-Session-Id"); config.addAllowedMethod("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/mcp", config); return new CorsFilter(source); }

反向代理场景下要注意路径转发。我一开始在 Nginx 里把/mcp映射到后端/api/mcp,结果内部产生的协议请求仍然请求/mcp,造成路径不一致。这属于很基础的坑,但确实容易在快速搭建时忽略。

5.5 长耗时工具的响应等待问题

大模型调用工具时,客户端普遍有自己的超时设置,有些甚至只有几十秒。我的第一个工具内部调了个报表服务,平均要跑 30 秒以上,直接导致了客户端侧超时。这里给两个思路:

一是优化工具本身,比如查询类接口走缓存,让响应在几秒内返回。二是把长任务拆成"提交任务"和"查询结果"两个工具。模型第一次调用submitReport拿到任务 ID,之后周期性调用getReportResult。这样单次工具调用都不会太久,客户端的超时问题自然解决。

6. 安全加固与扩展:从本地对接到能上线的状态

MCP Server 一旦开放到网络里,就不能只想着功能跑通。我这边分了几步加固。

第一,给服务加认证。最简单的方式是在 HTTP 层拦截未认证请求,比如让 MCP 端点校验Authorization头里的 Bearer Token。Spring AI 的某些版本自带 MCP 授权配置,可以在配置里直接开启:

spring: ai: mcp: server: authorization: enabled: true type: BEARER token: your-secret-token

如果你的 starter 版本不支持这个配置,手动写一个拦截器也不难,核心逻辑就是校验每个/mcp请求的 Authorization 头,除了 GET 探活路径之外都拦。MCP 客户端侧也需要支持配置 Token,目前主流客户端都能配置请求头,问题不大。

第二,工具权限最小化。MCP Server 暴露的工具越多,攻击面和误用风险就越大。我这边会把所有工具归组,线上环境只暴露查询类工具,写操作工具单独用一个不允许外网访问的实例承载。考虑走动态工具注册的方案:不同客户端看到不同的工具列表,这个在协议层面是可以做到的。

第三,和 Playwright、Burp Suite、Figma 这类生态中的 MCP Client 互操作。现在很多开发工具都内置了 MCP 客户端,比如 Playwright MCP、Burp Suite MCP、Chrome DevTools MCP。它们的共同点是都遵循同一份 MCP 协议,也就是说,你的随机工具方法一旦被注册成 MCP Server,就可以被这些生态里的客户端发现和调用。我们内部已经试过让一个支持 MCP 的浏览器自动化客户端调用我们的订单查询工具,协议兼容性完全没问题。

6.1 流式输出与实时进度

Streamable-HTTP 名里带着"Streamable",自然支持服务端在响应过程中逐步返回信息。MCP 规范里有进度通知机制,服务端可以在工具执行期间发送notifications/progress,客户端就能展示进度条。Spring AI 的工具回调里也提供了进度上下文,如果你的工具执行时间较长,可以在方法中定期上报进度。

这块我目前只做了基础接入,效果是用户能看到"正在查询报表,已处理 40%"这类信息,对使用体验提升明显。要注意的是进度通知是半双工的,服务端推送这些事件时,客户端必须支持对应事件解析,否则可能忽略掉。接入第三方客户端前,先确认它的 MCP 实现是否处理进度事件。

6.2 关于无状态化的进一步思考

Streamable-HTTP 的会话管理虽然比老方案简单,但生产环境的多实例部署依然要面对 session 复制的问题。我个人的选择是尽量把服务设计成无状态:工具方法本身不依赖 session 中的数据,所有必要信息都由入参传递。这样即使 session 丢了,客户端重新 initialize 一次也能继续干活。

某些场景确实需要 session 保存上下文,比如多轮对话中工具之间的数据传递。这时建议把 session 的存储层改成 Redis,但要注意Mcp-Session-Id的键名和过期时间需要统一。MCP 规范没有强制 session 存储方式,所以这块完全由服务端自己定,我这边暂时用内存存储,等到流量起来再做迁移。

6.3 还有个容易被忽视的小细节:健康检查

线上部署时,负载均衡器会定期探活。如果探活地址直接打到/mcp,可能会被当成一次异常 initialize 请求。我的做法是单独暴露一个/actuator/health端点给基础设施探活,MCP 端点只处理协议流量。至于客户端进程重启后旧 session 失效,客户端侧重新建立连接就好,不需要服务端做额外处理。

最后分享一个我个人的体会:MCP Server 的开发重心不在协议细节,而在工具设计质量。协议交给 starter 和框架去处理,大部分情况下都足够可靠。把更多精力花在"工具怎么拆、描述怎么写、返回结构怎么定"上,实际使用效果反而会好很多。Streamable-HTTP 这种传输方式最省心的地方是它不占连接、部署简单,后续需要更复杂的推送能力时,再顺着规范往 GET 流和事件机制上扩展也不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询