news 2026/10/19 9:17:23

Spring AI 集成 MCP 实战:最小实例搭建大模型工具调用链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 集成 MCP 实战:最小实例搭建大模型工具调用链路

第一次接触 MCP 这个词,我差点把它跟前段时间同样很火的另一个缩写弄混。直到我在一个 Spring Boot 项目里真正跑通一个 MCP 小实例,才意识到这东西跟"配置管理协议"完全没关系,它是给大模型装"手和眼睛"的一套标准接口。如果你和我一样,主技术栈是 Java / Spring,最近又被 Spring AI 和 MCP 这两个词包围,那这篇应该能帮你省下不少摸索时间。

这篇文章我会从一个最小可跑的实例切入,讲清楚 MCP 在 Spring AI 里到底是什么角色,然后逐步展开 Server、Client、Tool 这些概念,最后把我踩过的坑和排查思路一起说出来。内容不假设你已经懂 MCP,只要求你会 Spring Boot 的基本用法。读完你不仅能跑通一个实例,还能自己判断什么时候该上 MCP、什么时候其实用不上。

1. 为什么是 Spring AI + MCP,而不是直接拼 Prompt

先说痛点。很多团队第一次做 AI 功能,思路通常是"把问题背景写进 Prompt,让大模型回答"。这套路应付纯文本问答没问题,但一旦让模型去查数据库、读文件、调内部服务,就会发现它像个只有嘴巴没有手的专家,说得头头是道,但什么都干不了。早期解决方案是 Function Calling,也就是让模型输出一段结构化 JSON,你再写代码去执行。它能用,但有个明显问题:每个工具都要自己定义协议、维护参数结构,项目一多,代码里全是格式各异的"工具封装层",复用和统一管理都很痛苦。

MCP 就是冲着这个痛点来的。它把"模型需要调用外部能力"这件事做成了统一协议:工具提供方按照约定暴露能力,模型调用方按照同一套约定消费能力。用 USB-C 接口来类比会很好懂——以前每个设备都有自己的充电口,现在统一成一个口,线的两端只要都符合规范,插上就能用。MCP 里的 Server 就是"设备端的 USB-C 口",Client 就是"充电器端的 USB-C 口",两边的握手、传输、数据格式都由协议规定好了。

那 Spring AI 在这中间是什么位置?它是模型调用方的集成框架,帮你省掉跟模型服务商的协议对接,又帮你把 MCP Client 嵌入到 Spring 的上下文里。于是你可以用熟悉的@Service、@Tool、@Bean这类 Spring 风格写代码,而不是去手动处理 JSON-RPC 或 SSED 传输。我实际用下来的感觉是:Spring AI 不是让你少写代码,是让你少写那些跟核心业务无关的协议胶水代码。配置好之后,MCP 工具跟一个本地 Spring Bean 差不多,声明一下就能被模型感知到。

概览一下核心概念,方便后面实例里对号入座:

  • MCP Server:对外提供能力的进程,可以暴露工具(Tool)、数据资源(Resource)、提示词模板(Prompt)。
  • MCP Client:连接 Server 的一端,通常嵌在 AI 应用里,负责把模型的请求转发给 Server。
  • Host:承载 Client 的宿主环境,在 Spring AI 里就是你的 Spring Boot 应用。
  • Transport:两端通信的通道,Spring AI 目前常用两种,stdio是本地进程间,WEBMVC/WEBFLUX是走 HTTP + SSE。

这些术语在你跑通实例之后会非常清晰,现在只要求有个印象。

2. 动手前的最小认知:一次 MCP 调用的完整链路

这一节我会把一次 "模型调用工具" 的完整路径拆开。不看明白这段,后面配参数时会总觉得像在设密码。

假设用户对应用说:"帮我查一下某个订单的物流状态"。在没有 MCP 的常规设计里,你要先决定到底调哪个 API,然后把 API 参数格式写进 Prompt 模板,再祈祷模型输出正确的 JSON,最后还得处理各种"模型自作主张改字段名"的情况。整个过程是割裂的:参数格式在 Prompt 里,执行逻辑在 Java 代码里,两边靠"约定"勉强对齐。

而有了 MCP 之后,链路变成了下面这样:

  1. 应用启动时,Spring AI 的 MCP Client 连接上配置好的 Server,获取一份工具清单(工具名、描述、参数结构)。
  2. 用户提问进来,Spring AI 会把用户问题连同工具清单一起发给大模型。
  3. 大模型"看"完工具描述后,决定要不要调用。如果需要调用,会返回一个结构化调用意图(类似"我要调用 getOrderStatus,参数是 orderId=xxx")。
  4. Spring AI 收到这个意图,不会自己瞎猜,而是交给 MCP Client,由 Client 按协议发给 MCP Server。
  5. Server 执行真正的业务逻辑(比如查数据库),把结果返回给 Client。
  6. Spring AI 把工具结果再塞给大模型,模型基于这个结果生成最终回答。

这里最妙的是第 3 步:模型看到的是"工具描述",不是"工具实现"。所以你可以在 Server 里随便改实现逻辑,只要工具名和参数结构不变,模型侧完全无感知。这跟接口 API 的"契约优先"思想很像,只不过契约的格式由 MCP 协议统一钉死了。

Spring AI 帮你做的主要是第 4 步和第 6 步:它把 MCP Client 和模型间的数据转换、异常处理都封装好了。你在代码里甚至感觉不到网络传输,工具方法被调用时,就像在调用一个本地 Service 方法。

还有一个关键点:MCP 支持同步和异步两种模式,在WEBMVC传输下,同步模式的工作方式是走 HTTP 长连接 + SSE 响应,异步模式则更复杂一些。小实例阶段建议用同步模式,逻辑最直观,问题也最好排查。别一上来就追求异步、流式、并行这些进阶能力,先把这条主链路跑通,后面的扩展都是在这个骨架上加肉。

3. 正式动手:跑通一个 MCP 小实例,从 Server 到 Client

我不会只给你贴代码,而是带着你把两个端分别建起来。你可以建两个项目玩"跨进程"调用,也可以在同一个应用里体验"一站式"闭环。我建议从两个项目开始,这样传输过程是显式的,概念不容易混淆。

3.1 创建 MCP Server:一个提供当前时间的小工具

先建一个空 Spring Boot 项目,Java 17+,Spring Boot 3.3+,做一个独立的 MCP 时间服务。给它起个名字叫mcp-time-server,里面只有一个工具类。

Maven 依赖如下,注意版本对应关系要跟你的 Spring Boot 主版本一致,否则启动时会遇到各种NoClassDefFoundError:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> </dependency>

接入的是 Spring AI 的 BOM,版本管理不用自己操心,Spring Boot 管理版本会自动对齐。核心代码:

@Component public class TimeTool { @Tool(description = "获取服务器当前日期时间,返回格式为 yyyy-MM-dd HH:mm:ss") public String getCurrentTime() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } }

这个@Tool注解是 Spring AI 提供的,它会自动扫描被 Spring 容器管理的 Bean 方法,把方法名、描述、参数类型注册为 MCP 工具。你不需要手写任何 JSON Schema,Spring AI 会通过反射拿到方法签名,自动生成工具描述。

配置文件application.properties里只需要指定 Server 的名字和传输方式:

spring.main.web-application-type=none spring.ai.mcp.server.name=time-server

这里有两个细节要注意。web-application-type=none表示我不需要启动 Web 容器,这个 Server 走stdio传输,在本地以子进程方式被拉起。spring.ai.mcp.server.name是给服务取的逻辑名,后面 Client 订阅时靠它做区分。

启动这个项目,你会在控制台看到类似 "MCP server started" 的日志,并且进程不会退出,因为它正在从标准输入读 JSON-RPC 消息。这就是一个最简 MCP Server,什么都不暴露时,它只是一个等待命令的空壳,一旦你加了@Tool方法,工具就自动出现在能力清单里。

3.2 创建 Spring AI 客户端应用:连接 Server 并完成问答

再建一个普通的 Spring Boot Web 项目,名字叫mcp-time-client,这就是我们触发 AI 调用、承载 MCP Client 的应用。依赖方面要加三样东西:Spring AI 的模型 Starter、MCP Client Starter,以及 Web 相关依赖。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-xxx</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>

上面的spring-ai-starter-model-xxx是一个占位,实际根据你用的模型服务商替换成对应 artifactId。配置层面,把模型服务的地址、Key 填到application.properties,同时声明 MCP Client 要连接的服务:

spring.ai.model.chat=xxx spring.ai.model.chat.api-key=${your-api-key} spring.ai.model.chat.base-url=${your-base-url} spring.ai.mcp.client.transport=STDIO spring.ai.mcp.client.stdio.servers[0].command=java spring.ai.mcp.client.stdio.servers[0].args=-jar,../mcp-time-server/target/mcp-time-server.jar spring.ai.mcp.client.stdio.servers[0].name=time-server

这段配置的含义是:启动这个 Spring Boot 应用时,自动帮我用java -jar把刚才那个 Server 作为子进程拉起来,name 是time-server。Spring AI 的自动配置会完成连接、握手、工具清单拉取,你不用写任何连接代码。

接着在应用里注入一个ChatClient,这是 Spring AI 提供的流式链式调用入口:

@Service public class TimeAssistant { private final ChatClient chatClient; public TimeAssistant(ChatClient.Builder builder) { this.chatClient = builder.build(); } @PostMapping("/time/ask") public String ask(String question) { return chatClient.prompt(question).call().content(); } }

我直接把它做成了 REST 接口,方便用浏览器或 curl 测试。启动应用后,访问:

curl "http://localhost:8080/time/ask?question=现在几点?"

如果一切正常,你会看到大模型返回一个基于真实服务器时间的答案,比如 "现在是 2025-06-01 14:30:22"。如果它回答"我无法获取当前时间",说明工具链路没生效,问题多半出在配置或模型能力上,我后面专门讲排查。

3.3 在同一个应用里体验功能闭环

如果你不想建两个项目,也可以在同一个 Spring Boot 应用里同时引入 server 和 client 的依赖,用WEBMVC传输让它们走 HTTP 自连。这种方式做 Demo 最省事,适合在本地快速验证 Spring AI + MCP 的全部能力。

依赖换成:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>

配置如下,关键区别在传输方式改为WEBMVC,并且给 Client 填上连接地址:

spring.ai.mcp.server.transport=WEBMVC spring.ai.mcp.server.name=time-server spring.ai.mcp.client.transport=WEBMVC spring.ai.mcp.client.type=SYNC spring.ai.mcp.client.webmvc.url=http://localhost:8080/sse

注意这个http://localhost:8080/sse是 MCP Server 自动暴露的 SSE 端点,不用自己建 Controller。应用启动时,Client 会订阅这个端点,拉取工具清单。两种写法我都试过,结论是:两个项目的方式对理解架构更有帮助,单项目的方式对快速验证更友好。新手建议先跑通单项目,再去拆开对比。

4. 实测中踩过的坑:连不上、工具不生效、参数解析失败

跑通一个 Demo 往往只要半小时,但"换一个项目就失败"才是真实常态。我把实际测试里遇到过的高频问题整理一下,每个都附上排查思路,比直接给结论更能帮你定位。

4.1 工具列表是空的,模型完全不知道有工具存在

现象是:应用能启动,但无论怎么问,模型都不调用工具,只给一段礼貌的"我无法获取时间"。排查链路要分三步走。

第一步,确认 Server 是否真的暴露了工具。在stdio模式下,启动 Server 后手动往标准输入里发一条工具列表请求,或者看启动日志里有没有ToolDefinitions相关输出。Spring AI 在注册工具时会打印类似 "Registered tool: getCurrentTime" 的日志,看不到这条,说明@Tool方法本身就没被扫到。

第二步,确认 Client 是否成功拉取到工具列表。在日志里搜索MCP相关关键词,正常会出现 client 连接成功、获取到工具数量之类的记录。如果 client 日志里没有连接成功的记录,检查spring.ai.mcp.client.stdio.servers数组配置是不是遗漏了某个字段。

第三步,也是最容易被忽略的:部分模型服务商的兼容接口能力较弱,对工具(function calling)的支持不完整。我碰到过一次,同一个配置换成某服务商的兼容地址后,工具完全失效,但聊天能力正常。当时排查了半天,最后把问题锁定到模型接口的 tool 支持上。这类问题要看模型服务商的文档,确认它的接口有没有实现 function calling 能力,而不是继续调 Spring AI 配置。

4.2 传输方式不匹配导致连接超时

这个问题在混用STDIO和WEBMVC时特别容易踩。比如 Server 端配置了spring.main.web-application-type=none,也就是以子进程方式运行,Client 端却配成了WEBMVC,指望去连它的 HTTP 端口,那必然连接失败。反过来也成立:Server 走 Web 方式启动,Client 却按子进程拉起,也会连不上。

我的排查建议是:先用一种传输方式跑通,再考虑切换。STDIO是最简单的,因为不涉及端口和容器。但要注意一点,STDIO模式要求 Client 能以命令行方式拉起 Server,所以本地测试时,jar 路径必须正确。我经常看到路径写错,导致启动即失败,应用起来后 client 日志里一片红。路径错误这类问题直接用绝对路径最容易避免,别偷懒写相对路径。

4.3 工具调用成功,但参数解析报错,模型答非所问

有一种情况比较隐蔽:工具确实被调用了,但传给工具的参数是 null,或者类型转换失败。比如我写了一个工具方法,参数是个自定义对象,结果模型只传了部分字段,导致 Java 侧反序列化时报错。

这类问题本质上是"模型理解的参数结构"和"代码期望的参数结构"不一致。MCP 协议会把方法签名转换成 JSON Schema,这个 Schema 描述得越清晰,模型就越不容易出错。所以写@Tool方法的参数时,要遵循两个原则:

  • 参数类型尽量使用基本类型、String、以及结构简单的对象,不要搞多层嵌套的复杂泛型,比如Map<String, List<InnerObject>>这种,模型真的容易晕。
  • 参数描述写清楚,说人话。比如订单ID,可以在订单列表中查看,比光秃秃一个id更有助于模型理解。

我后来把几个工具方法的参数都改成平铺的 String / Integer,再配合详细 description,工具调用成功率从大约 70% 提升到 95% 以上。这个经验在内部工具偏多的项目里尤其有价值。

4.4 同一个 Bean 里多个工具方法,只注册了一个

Spring AI 的@Tool注解是按方法级别扫描的,同一个类里的多个方法都会被注册,但前提是类本身被 Spring 管理。如果你在一个没有被@Component标注的类里写了@Tool,那什么都不会发生。我见过有同事把@Tool加在@RestController的私有方法上,结果启动后工具列表不完整。

建议把 MCP 工具集中放到独立的 Service 类里,一个类承载一组相关性强的工具,类上标注@Component,方法上标注@Tool。这样既被容器管理,也方便统一排查。

5. 小实例之外:MCP 从玩具走向实用工具的几个方向

跑通时间服务这个实例之后,你大概已经感受到 MCP 的价值了。但要让它真正出现在生产系统里,还需要掌握几个关键扩展点。我觉得最值得关注的,是把 MCP 接入你已有的数据和服务,而不是去折腾协议本身。

5.1 给模型接上数据库查询能力

很多人第一个想到的 MCP 实用场景就是"用自然语言查数据库"。实现思路是:MCP Server 里提供查询工具,参数是 SQL 语句,返回结果集。但千万别直接暴露一个"执行任意 SQL"的工具让模型随意使用,风险太大。

更稳妥的做法是定义业务语义明确的工具,比如queryUserOrders(userId)、queryProductStock(productId),让 Server 内部帮你拼 SQL、加权限校验、做结果脱敏。这样模型只是在有限的几个入口里选,而不是在 SQL 层面乱来。我在实际项目里给一个客服系统做过类似封装:模型根据用户描述调用"查订单""查物流"等几个固定工具,参数结构简单,执行结果限制在最近几天,安全性好很多。

5.2 从 MCP Server 拉取可感知的上下文数据

除了工具调用,MCP 还有 Resource 这个概念,相当于给模型准备"可读取的上下文"。比如你有一个内部知识库文档,定期更新,在传统方案里你得每次拼进 Prompt,Token 费用高,Prompt 还会越来越臃肿。用 MCP Resource,可以让模型按需读取特定文档片段,只有在回答相关问题时才把内容拉进来。

不过实践中我感觉到 Spring AI 对 Resource 的封装成熟度不如 Tool,文档和示例也少一些。如果是小项目,可以直接用 Tool 返回查询结果,效果类似,只是缺少协议层面的"资源发现"能力。等你的工具数量超过二十个,再认真研究 Resource 和 Prompt 模板体系不迟。

5.3 工具编排与权限边界:这才是生产化的关键

生产环境里,MCP 最大的难题不是"连接",而是"边界"。你让一个模型能调用十几种工具,就得想清楚谁能用、能调哪些、参数要不要校验、结果要不要审计。我有几个经验可以分享:

  • 做一层工具代理,在 MCP Server 入口统一做权限校验,而不是散落在每个工具里。比如检查调用方 AppId、用户角色,再放行工具执行。
  • 对工具返回的数据做最小化裁剪,只给模型完成任务所必需的字段。模型不是数据库客户端,它不需要看到全部字段。
  • 保留完整的调用日志,包括谁在什么时间调了哪个工具、传了什么参数、返回了什么。模型输出不可控,但你至少要能审计它做过什么。

如果你用的是内部网络,MCP Server 和 Client 之间的传输建议走内网隔离,不要把服务暴露在公网。STDIO模式在这个意义上是最安全的,因为根本不开网络端口。

5.4 关于多 Server 聚合的一点思考

一个 Client 连接多个 MCP Server 完全可行,Spring AI 的配置支持一个 Server 数组。但我会提醒你别一上来就搞太多。多个 Server 会让工具清单变得很长,模型在长清单里的选择准确率会下降,而且排查问题时要跨多个进程看日志,工作量倍增。我的做法是:先用一个聚合 Server 把相关工具聚在一个进程里,观察调用效果,等工具数量和种类确实复杂到需要拆分时,再考虑多 Server 架构。过度设计在 AI 集成这里带来的复杂度,往往比收益更明显。

6. 一些值得记住的实践原则

这一节算是我把上面的经验收敛成几条原则,方便你写代码时对照。

工具做少,语义做清晰。宁可一个工具的名字长一点、描述啰嗦一点,也别用doThing这种模糊命名。模型靠描述来决定调用,描述写得好,成功率就高。

参数做平,类型做简单。平铺的 String、Integer、Boolean 组合最容易让模型理解。一旦参数复杂到需要一个对象,请考虑拆分工具,或者预先把数据准备好再让工具去筛。

先跑通主链路,再优化性能。同步模式、小实例、单个 Server,先确认端到端能工作,再逐步引入缓存、异步、流式。很多初学者上来就配一堆高级参数,出问题时根本分不清是哪一环。

日志一切。AI 链路不像普通接口,你没法直接断点调试模型的内部决策。你只有靠日志还原"模型决定调什么工具、Client 是否转发、Server 返回了什么"这条链路。所以在工具方法和 Server 入口处打上入参、出参日志,排查效率至少提高一倍。

我自己的习惯是,在每个@Tool方法入口加一行日志,输出方法名和参数 JSON;在 Client 侧配一个拦截器,输出模型返回的 ToolCall 信息。遇到模型耍小聪明的时候,这两行日志就能迅速定位是模型的问题还是代码的问题。

7. 从实例到实践:一个小结式的经验分享

如果你是从零开始接触 Spring AI 和 MCP,希望这篇能帮你少走弯路。踩过这轮坑之后,我最真实的体会是:MCP 解决的问题不是"让模型学会工具调用",而是"让工具调用成为一个团队可以统一维护的系统工程"。协议本身的封装,Spring AI 已经做得很好了,真正需要你花心思的,永远是业务边界、参数质量和日志审计。

最后分享一个我后来一直沿用的做法:每个新 AI 功能都先用最小 MCP 实例验证"模型能不能调通工具",再填充真实业务逻辑。这个验证阶段通常不超过半天,但它能帮你在架构决策上提前止损。比如有一次我们打算让模型直接操作一系列内部服务,结果最小实例发现其中两个服务返回的数据结构不适合直接喂给模型,于是转而先做数据清洗层。这个判断如果没有实例验证,就要等业务上线后才发现,代价完全不同。

技术迭代很快,今天写的这些具体配置可能过几个月又会变,但"先跑通、再分层、日志说话、边界先行"的思路不会过时。希望这篇从实例开始的内容,能成为你自己项目里那个最有用的起点。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 9:17:19

OpenCV FLANN KNN搜索实战:索引原理、参数调优与工业避坑指南

1. 这不是“GenericIndex”的官方功能&#xff0c;而是OpenCV中一个被长期误读的命名陷阱你搜到“OpenCV使用 GenericIndex 进行 KNN 搜索”&#xff0c;第一反应可能是&#xff1a;OpenCV是不是新出了个叫 GenericIndex 的高性能索引模块&#xff1f;是不是像 FAISS、Annoy 或…

作者头像 李华
网站建设 2026/10/19 9:16:36

二维前缀和与原地修改:力扣3070子矩阵计数题解

我最早看到力扣 3070 这道题的时候&#xff0c;第一反应是&#xff1a;又要枚举左上角、右下角&#xff0c;四个循环跑一遍了。等我把题面读了三遍&#xff0c;才发现题目压根不是那个意思——它要统计的是“包含grid[0][0]的子矩阵”里&#xff0c;元素和小于等于k的个数。这个…

作者头像 李华
网站建设 2026/10/19 9:16:32

Linux网络编程:send()与SIGPIPE信号导致进程崩溃的排查与修复

场景其实特别常见&#xff1a;排查线上服务时&#xff0c;发现进程突然消失&#xff0c;翻日志却什么都没打印&#xff0c;最后一看是Linux网络编程里那个“老熟人”——socket的send()函数返回了错误&#xff0c;进程直接终止退出。很多人第一反应是“send返回-1我处理了啊”&…

作者头像 李华
网站建设 2026/10/19 9:15:39

open-code-review:用自动化流程让Code Review不再流于形式

1. 整体思路与核心矛盾&#xff1a;为什么多数 Code Review 都死在形式上先说说我为什么要折腾 open-code-review 这套东西。过去几年我带过不少研发小组&#xff0c;也见过太多团队上一秒刚宣布“从今天开始我们严格执行 Code Review”&#xff0c;下一秒 MR 就变成“LGTM&…

作者头像 李华
网站建设 2026/10/19 9:14:40

研发流程中的识别、评估与行动:实践一套可复用的工作流

1. 项目概述1.1 "rea"到底是什么&#xff1f;一个能够贯穿研发流程的代号有个老同事问我最近在忙什么&#xff0c;我跟他提了一嘴"我正在整理一个内部代号叫 rea 的工程实践"。他第一反应是&#xff1a;你说的是 React 生态里的某个东西&#xff1f;还是re…

作者头像 李华