如果你已经在 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 三行) | 手写配置类 | |
|---|---|---|
写法 |
| 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) | ✅ 自动恢复 |
|
常开 GET 流(通知通道) | ❌ 死了不复活 | 无任何"下次请求"来触发它的 reconnect——它为通知而生,没有主动使用者 |
两个推论:
- 惰性设计的隐含特性:如果没人调用,定时器跑 100 次也不会重连——重连被推迟到"有人要用"那一刻(省资源:没人调用就不值得维护连接)。
- 通知通道永远救不活的原因:它是唯一没有"被动触发者"的组件。想让它复活需主动心跳(
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 到底加载了哪个版本。