ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringAiAlibaba学习笔记:MCP实战

SpringAiAlibaba学习笔记:MCP实战

如果你已经在 Spring AI 里用@Tool写过工具,这篇讲的是下一步:怎么把这些工具从应用进程里搬出去,变成一个任何 AI 客户端都能远程调用的 MCP Server,再反过来让 AI 应用以纯 Client 的身份把它接回来。

文中的所有结论都来自一次真实搭建,包括踩过的坑和最后的验证数据。示例代码基于 Spring AI 1.x + MCP Java SDK 0.17.0。

一、MCP 到底解决了什么问题

一句话:MCP 是 AI 世界的 USB 接口。它把"焊死在应用里的工具"变成"即插即用的网络服务"。

以前用@Tool写工具,方法跟主进程一起编译部署。换一个 AI 应用,工具就得重写一遍;想给 Claude Desktop 或 Cursor 用,更是无从谈起。

现在把工具包成独立进程(MCP Server),任何 MCP 客户端都能远程调用。模型那一侧完全无感,它看到的还是同一份工具说明书。

协议层只需要记住三件事:

  • 三大原语:Tools(模型可调用的函数)、Resources(模型可读取的数据)、Prompts(预置提示词模板)。日常用得最多的是 Tools。

  • 通信本质:JSON-RPC 2.0,一次典型交互就是initialize→tools/list→tools/call。

  • 两种传输:SSE(2024-11-05 协议)和 Streamable HTTP(2025-03-26 协议)。新项目直接上后者。

两种传输的区别值得单独说,因为这是最容易踩坑的地方:

SSE(旧)

Streamable HTTP(新)

通道

两条 TCP:GET /sse 收,POST /mcp/message 发

单通道,所有请求 POST /mcp

响应

从 SSE 流里回来

直接在 POST 的响应 body 里

会话

sessionId 挂在 URL 参数上,断线即作废

Mcp-Session-Id 放响应头

扩展性

服务端要维护 session 表

可无状态扩展

测试

要开两个终端

一条 curl 就够

我们最初用 SSE,撞上了 0.17.0 Java SDK 的 Reactor 管道竞态:响应数据已经完整到达,SDK 处理时却报Failed to send message。切到protocol=STREAMABLE之后问题彻底消失——单通道没有 SSE 流转发管道,那个坑就不存在了。

二、搭建 MCP Server

Step 1:加依赖

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> <!-- 版本由 spring-ai-bom 管理 --> </dependency>

Step 2:写工具类

这一步没有任何新东西,和普通的@Tool写法完全一样:

@Component public class TimeTools { @Tool(description = "获取当前的完整日期时间,格式 yyyy-MM-dd HH:mm:ss") public String now() { ... } @Tool(description = "查询A股股票的实时行情...") public String quote(@ToolParam(description = "股票代码,如 sz300890") String code) { ... } }

关键认知:工具类零改动。MCP 化的是"注册和调用方式",不是工具本身。

Step 3:注册成 MCP 工具

这是整个 Server 侧唯一的核心配置:

@Bean public ToolCallbackProvider studyTools(TimeTools timeTools, StockTools stockTools) { return MethodToolCallbackProvider.builder() .toolObjects(timeTools, stockTools) .build(); }

MethodToolCallbackProvider扫描@Tool方法,生成 inputSchema,暴露成 MCP 协议端点。

Step 4:配置 application.properties

spring.ai.mcp.server.name=study-mcp-server spring.ai.mcp.server.version=1.0.0 spring.ai.mcp.server.instructions=提供时间查询/日期推算/A股行情工具 spring.ai.mcp.server.tool-change-notification=true # 传输模式:SSE(默认) 或 STREAMABLE(推荐) spring.ai.mcp.server.protocol=STREAMABLE

两个注意点:protocol=STREAMABLE会停用/sse端点,两者互斥;instructions写中文如果出现乱码,是 properties 文件按 Latin-1 读入导致的,改用 yml 或在 IDEA 里设成 UTF-8 即可。

一个容易困惑的细节:Streamable 模式下响应的 Content-Type 仍然是text/event-stream,body 用 SSE 格式包装。这是为流式进度通知预留的通道,客户端 SDK 会自动处理,不用管。

三、MCP Client 的三种写法

三种都实测过,按从底层到上层的顺序说。

这里只是用java编写调用mcp服务的代码,便于理解mcp服务的调用过程

写法一:裸协议手写,约 60 行

SSE 读线程 + BlockingQueue 中转 + 手拼 JSON-RPC 字符串:

POST 发出请求(只发不收) → SSE 线程收到响应 → 按 jsonrpc id 匹配 → offer 进队列 → 主线程 poll 取走

这段代码的价值不在于用,而在于看穿 SDK 封装下真实发生了什么。所有 MCP 客户端内部干的都是这件事。

写法二:官方 SDK,推荐

HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder("http://localhost:8848").build(); McpSyncClient client = McpClient.sync(transport) .requestTimeout(Duration.ofSeconds(10)) .build(); client.initialize(); // 握手 ListToolsResult tools = client.listTools(); // 工具清单 CallToolResult r = client.callTool( new CallToolRequest("quote", Map.of("code", "sz300890"))); // 调用 String text = ((TextContent) r.content().get(0)).text(); // 取文本 client.close();

SDK 0.17.0 的几个 API 备忘:builder(baseUrl)带参构建;取文本要强转 TextContent,没有asText()这种语法糖;CallToolRequest直接 new,参数是 name 加 arguments Map。

写法三:Spring 声明式,生产级

spring: ai: mcp: client: enabled: true toolcallback: enabled: true # 远程工具自动包装成 ToolCallback streamable-http: # 注意传输模式对应配置节 connections: study-server: url: http://localhost:8848/mcp

注入List<ToolCallback>挂给 ChatClient,远程工具用起来和本地@Tool一模一样。

四、Tool 和 MCP Tool 的真正区别

这是这次学习最值得说清楚的一点。

模型视角:完全无感

模型收到的都是工具说明书(name + description + inputSchema),它只负责决定调什么。模型根本不知道工具代码在哪执行。

真正的区别在调用路径

模型响应(两者完全相同) {"name":"now","arguments":{}} │ Spring AI 解析工具调用意图 │ ┌─────────┴─────────┐ ▼ ▼ 本地 ToolCallback 远程 ToolCallback(MCP) │ │ Method.invoke() HTTP POST /mcp 反射,进程内,纳秒级 (响应回来后,server端才反射) 毫秒级,但工具调用对延迟不敏感

精确表述:MCP 不是替换了反射,是在反射外面包了一层 HTTP 壳。Server 端的TimeTools.now()还是被Method.invoke()反射调用的那个方法,一点没变。变的只是"请求怎么到达这个方法"。

工程维度的对比

维度

本地 @Tool

MCP 工具

部署

跟主应用同进程、同发布周期

独立进程,独立升级

消费者

只有主应用

任何 MCP 客户端

通信

方法调用

HTTP JSON-RPC

修改工具

主应用重新编译部署

server 重启即可,客户端无感

类比

自己家厨房做饭

中央厨房外卖

用熟悉的架构类比:本地@Tool相当于进程内直接new ServiceImpl().method(),MCP 工具相当于 Feign/RPC 调远端服务。这和微服务拆分是同一个演进逻辑——为了解耦部署,付出一次网络开销。

五、架构拆分与 AI 闭环

5.1 从同进程拆成两个项目

最初的形态是工具和 AI 应用在同一个进程里,后来拆成了这样:

┌──────────────────────────────┐ ┌──────────────────────────────┐ │ spring-ai-alibaba-study:8848 │ │ stock-mcp-server:8090 │ │ (AI应用 = 纯 client) │ HTTP │ (纯工具服务 = server) │ │ │─────▶│ │ │ · ChatClient + DashScope │ POST │ · TimeTools (4个) │ │ · ReactAgent 框架 │ /mcp │ · StockTools.quote │ │ · MCP Client (手写配置) │ │ · 独立进程/可独立部署 │ └──────────────────────────────┘ └──────────────────────────────┘

5.2 验证过的完整链路

"查一下翔丰华的实时行情" ↓ agent ReactAgent 收到提问 模型看到5个远程工具说明书 → 决定调 quote(code=sz300890) ↓ Spring AI 识别为MCP工具 → 转成 JSON-RPC HTTP POST http://localhost:8090/mcp 调用mcp服务 ↓ mcp服务 收到 tools/call MethodToolCallbackProvider 反射执行 StockTools.quote() → 调腾讯行情API ↓ 结果回流 模型二次加工 → "现价26.59元 +2.03%"

实测结果:翔丰华 26.59 元,涨 2.03%;问"今天几号?30 天后是几号",返回 2026-11-04 和 2026-12-04。

5.3 三个坑

坑一:注入List<ToolCallback>拿到空列表,静默降级。

自动配置注册的 Bean 是SyncMcpToolCallbackProvider(Provider 模式),不是散装的 ToolCallback。而 Spring 对不存在的 Bean 做集合注入时返回空列表而不报错。后果很严重:模型没有工具可用,于是自信地编数据,实测编出过 28.45 元和"2024 年 6 月 28 日"。教训是 AI 应用里工具挂载失败是静默的,必须打印工具清单自检。

坑二:ReactAgent.Builder 的方法名。

挂ToolCallback[]用.tools(...),挂 Provider 用.toolCallbackProviders(...)。两者等价,后者更优——本地MethodToolCallbackProvider和远程SyncMcpToolCallbackProvider是同一个接口,统一抽象能真正落地。

坑三:启动时 starter 连接远端失败会阻断应用启动。

initialize 握手在启动期完成,好处是连接不通能早暴露,代价是必须保证 server 先起。

5.4 手写 McpClientProviderConfig

SyncMcpToolCallbackProvider spring-start会把这个bean装配好,构建Agent的时候可以直接挂载,但是我们可能需要同时连接多个MCP服务,那就把McpToolCallbackAutoConfiguration干的事自己抄一遍,分三步:

// 第1步 管道:一个传输 = 一条通往远端的通道 HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder("http://localhost:8090") .endpoint("/mcp").build(); // 第2步 会话:客户端 + initialize 握手 McpSyncClient client = McpClient.sync(transport) // 这里是定义当前的调用者,因为mcp服务可能有很多调用者,给自己一个标识,让mcp记录你的调用 .clientInfo(new McpSchema.Implementation("my-app", "1.0.0")) .requestTimeout(Duration.ofSeconds(30)) .build(); client.initialize(); // 协商协议版本、交换能力清单 // 第3步 汇总:N个服务的工具聚成一个 Provider new SyncMcpToolCallbackProvider(client1, client2, ...);

多服务接入可以定义一个service列表,循环创建client,使用client集合创建provider

三个值得注意的点:手动@Bean优先级更高,starter 的@ConditionalOnMissingBean会让位;McpSyncClient是 AutoCloseable,配置类实现 DisposableBean 统一归还连接;Provider 还实现了ApplicationListener<McpToolsChangedEvent>,配合 server 侧的tool-change-notification=true,远端工具增删时 client 能动态感知。

5.5 两种接入方式怎么选

声明式(properties 三行)

手写配置类

写法

spring.ai.mcp.client.streamable-http.connections.xx.url=...

McpClientProviderConfig

适合

快速接入、标准场景

多服务管理、定制超时/鉴权/重连

原理

starter 自动配置代你执行手写版的三步曲

自己控制每一步

Bean

SyncMcpToolCallbackProvider

SyncMcpToolCallbackProvider

六、MCP Client 的断开重连

背景:mcpService跟client有连接,那么当mcp-service上的工具更新了,client如何实时感知?

mcpservice-client 的断开重连:SyncMcpToolCallbackProvider 提供了invalidateCache标志位,当标志位=true的时候,下次请求Agent获取工具时Provider会去mcp-service重新加载工具列表;

核心认知:client 端 invalidateCache 只是标脏,重连发生在两跳之后。

invalidateCache() → 纯内存操作:invalidateCache = true(O(1),零网络) ↓ 代码识别标志位 → getToolCallbacks() 里的 if (invalidateCache) 分支 ↓ (惰性:只在"下次有人调用"时走进来) 拉取工具 → client.listTools() 发 POST ↓ 发现连接已断 → transport 层 re-initialize → 新 session → 拉取成功

三层各司其职:

  • 标志位管"要不要刷新"(事件回调、定时器都只做这一件事)
  • getToolCallbacks/listTools管"怎么刷新"(加锁防并发重复拉)
  • transport 的 re-initialize 管"刷新路上连接断了怎么办"(对上层透明)

SDK 自愈的精确边界(字节码验证,修正"SDK 不会重连"的粗说法):

通道

断后行为

机制

POST 业务通道(tools/call、listTools)

✅ 自动恢复

HttpClientStreamableHttpTransport.sendMessage内部:session 失效(404)→ reconnect(lambda$reconnect系列方法)→ 重新 initialize → 新 session id → 原请求继续

常开 GET 流(通知通道)

❌ 死了不复活

无任何"下次请求"来触发它的 reconnect——它为通知而生,没有主动使用者

两个推论:

  1. 惰性设计的隐含特性:如果没人调用,定时器跑 100 次也不会重连——重连被推迟到"有人要用"那一刻(省资源:没人调用就不值得维护连接)。
  2. 通知通道永远救不活的原因:它是唯一没有"被动触发者"的组件。想让它复活需主动心跳(client.listTools()兼做心跳,比ping()好——不只验证连通,顺手把工具清单也刷了,一石二鸟)。

server 侧重启的结构性失效(协议层,无解):

  • 通知 = 对当前 TCP 连接的推送("电话振铃"模式),没有"留言信箱"模式
  • server 重启 = 旧连接全灭 + 新进程 session 表为空 →notifyToolsListChanged()无人可发
  • MCP 通知是 fire-and-forget:无 offset、无 backlog、无 ack、无离线补发
  • 协议自己留的活口:tools/list带nextCursor分页——"工具清单会变,client 应重新拉",拉取永远可靠

推拉结合的架构模式(和 Nacos/Apollo 同构):

第一层(拉取,保命绳):client 主动 listTools —— 独立 POST,任何场景都可靠 第二层(通知,加速器):list_changed 推送 —— 只在"server 活着 + 连接活着"时有效 第三层(兜底,自动拉):@Scheduled invalidateCache —— 把第一层变成自动的

业界同款:Nacos 长轮询推送 + 客户端全量拉取兜底;MQ push 消费 + offset 重放;Redis PSYNC + 全量 RDB。

附:版本兼容矩阵

classpath 实况: mcp-core 0.14.0 ← spring-ai-alibaba-agent-framework 传递依赖(老API) mcp-core 0.17.0 ← server starter 传递依赖 mcp-spring-webmvc 0.17.0 解决:pom 显式声明 io.modelcontextprotocol.sdk:mcp:0.17.0 统一版本 坑:0.17.0 client + SSE 传输 → Reactor 竞态;Streamable 传输 → 正常

多版本共存这件事本身不报错,但会让行为变得难以预测。遇到"明明代码一样、表现却不同"的情况,先mvn dependency:tree看一眼 mcp-core 到底加载了哪个版本。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进