1. 从stdio到Streamable-HTTPMCP Server传输方式这次改了什么MCP 这三字最近在技术圈刷屏的频率基本和年初的 AI 智能体热度绑定在一起。我这次要用 Spring Boot 给团队搭一个内部 MCP Server把现有 Java 服务暴露给 AI 代理直接调用。动手前我先把协议层面理顺了结论是直接用 Streamable-HTTP别再碰老式的 HTTPSSE 写法。一句话解释 MCP Server 是什么它就是一个遵循 MCP JSON-RPC 协议的 HTTP 服务把自己的能力注册成一个个工具Tool然后让大模型在对话过程中决定什么时候去调用这些工具。模型不关心你的工具是 Java 写的还是 Python 写的它只看你暴露出来的工具描述、参数 Schema 和返回结果。所以 MCP 更像是一份AI 界的接口契约。传输方式的演进值得多说两句。最原始的 MCP 是 stdio 模式也就是把进程的 stdin/stdout 当作通信管道适合本地 CLI 工具和 IDE 插件。服务要部署到远程就得走 HTTP。老方案里大家常见的是 HTTPSSE客户端先请求一个/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进程级管道实现简单适合本地本地开发、编辑器插件HTTPSSE客户端常驻连接状态由服务端维护但容易被网关断连早期远程 MCPStreamable-HTTP短连接 可选流式响应一次请求一次响应支持流式返回远程服务、生产环境2. 工程初始化依赖选型与配置先看版本再动手Spring Boot 构建 MCP Server 现在最顺手的方式不是自己实现 MCP 协议编解码而是直接用 Spring AI 提供的 MCP Server Starter。我这边的项目基线是 Spring Boot 3.4.x Java 17MCP 相关依赖用的是 Spring AI 的 webmvc 实现。选 webmvc 而不是 webflux主要因为团队现有代码是 Spring MVC 风格工具方法里还会调现有的数据库和内部 HTTP 服务阻塞式的线程模型反而更好控制。pom.xml 里核心就这几个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webmvc/artifactId version1.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_HTTPtransport这个配置项直接决定了服务端暴露的端点行为。设成STREAMABLE_HTTP后Spring Boot 会自动注册一个 Controller默认路径是/mcp。启动成功后日志里会看到类似这样的信息MCP server started at endpoint: /mcp transportSTREAMABLE_HTTP如果你的日志没显示这个多半是 starter 没被扫描到或者 application.yml 里的配置前缀不对。这里提醒一下不同版本之间配置项名有变动比如某些旧版本写作spring.ai.mcp.server.transportHTTP后来为了区分才改成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.port18080避免和其他本地应用冲突。第二处是应用的 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字段名清晰最好带上注释。我不建议直接返回MapString, 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 头不一致导致 406MCP 客户端的请求一般会带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 应用直接 fetchCORS 会变成一个必须处理的问题。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 流和事件机制上扩展也不迟。