我第一次在Spring AI里把MCP工具链跑通的时候,第一反应是:之前手写Function Calling工具描述和JsonSchema的日子,终于可以消停一会儿了。MCP(Model Context Protocol)这个名字听起来很“协议”,但落到Spring AI项目里,它其实就是在解决一件很具体的事:让大模型应用能统一地发现外部工具、调用外部工具、接收外部工具返回的数据。Spring AI从1.0时代就开始把MCP作为核心能力来推,提供了MCP Client和MCP Server两条完整路径。这篇文章我不会复读官方文档,而是把我自己在Spring Boot项目里配置MCP Server、挂载MCP工具、让Agent真正调用工具的完整操作过程写下来,包括环境配置、代码示例、验证方式,以及我踩过的几个坑。适合已经搭过Spring AI但还没搞明白MCP怎么落地的开发者。
1. 先搞清楚MCP在Spring AI里到底解决什么问题
1.1 从Function Calling到MCP:工具调用方式的自然演进
在做AI应用之前,大多数后端系统对外暴露能力的方式是REST接口,模型要调用系统能力,需要有人提前写好工具描述,告诉模型“这个工具叫什么、参数是什么、返回什么”。这就是Function Calling,Spring AI里对应的就是@Tool注解和ToolCallback。
但问题在于,每个系统都自己定义工具描述方式,A系统的工具规范和B系统的工具规范可能完全不一样。今天你要接文件系统,写一套;明天要接数据库,再写一套;后天要接浏览器自动化,又得重新适配。这种重复劳动本质上是“工具协议”没有统一。
MCP就是干这个的:它把“工具发现”“工具调用”“结果返回”这三个动作标准化了。一个MCP Server可以暴露文件读写、SQL查询、HTTP请求、浏览器操作等任意能力,而MCP Client只需要按协议去请求,就能拿到工具列表并执行调用。Spring AI选择把MCP纳入生态,等于帮你把外部工具接入过程中的协议层问题一并解决掉。
1.2 Spring AI在MCP生态里的位置:既是Client又是Server
很多人第一次接触MCP时,容易把这个概念想窄了,以为MCP只是“AI去调用别人工具”的通道。实际上在Spring AI里,MCP的操作是双向的:
- MCP Client:Spring Boot应用作为连接方,去连接本地的stdio进程或远程MCP Server,把外部工具注册给AI模型使用。
- MCP Server:Spring Boot应用作为提供方,把自己业务类里的方法用
@Tool暴露成标准MCP工具,供其他AI客户端或Agent调用。
也就是说,同一个Spring Boot项目,既可以去“消费”外部MCP工具,也可以“生产”自己的MCP工具。理解这一点很关键,因为企业里往往两条都会用到:既有需要调用第三方能力的场景,也有需要把自己内部服务能力安全暴露给AI平台的场景。
Spring AI官方提供的起步依赖也对应这两条线:spring-ai-starter-mcp-client和spring-ai-starter-mcp-server。接下来的章节会把这两条线的操作都过一遍。
1.3 什么时候值得上MCP,什么时候还是写Function Calling更省事
MCP虽好,但也不是银弹。如果项目里只有两三个固定工具,而且没有跨系统复用需求,直接写@Tool方法可能更轻快。但如果你遇到下面这些情况,就值得认真考虑MCP了:
- 工具列表在持续扩展,今天文件系统、明天数据库、后天内部OA。
- 有多个AI应用都需要复用同一套工具能力,希望用统一标准对外暴露。
- 想直接消费开源社区已有的MCP Server生态,而不是从零开发。
- Agent场景里需要动态发现工具,而不是把工具硬编码在提示词里。
我个人的判断标准是:工具数量超过5个,或者有三方系统接入者,就走MCP;否则先别过度设计。
2. 先跑通一个标准MCP Client:stdio通道连接文件系统Server
2.1 先落好依赖:BOM和Starter
在Spring AI里操作MCP,第一步永远是先把依赖引入对。我建议直接用spring-ai-bom统一管理版本,避免自己手写MCP相关依赖的版本号。
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>然后引入MCP Client的Starter:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>这里特别提醒一句:spring-ai-starter-mcp-client内部已经帮我们把MCP协议相关的SDK、自动配置、ToolCallback转换都装配好了。除非你确实要自己实现底层协议,否则不要在业务模块里再手动引入io.modelcontextprotocol.sdk的各个包,版本冲突的概率会成倍增加。
如果你用的是Gradle,同样建议在dependencyManagement里引入BOM:
implementation platform("org.springframework.ai:spring-ai-bom:1.0.0") implementation "org.springframework.ai:spring-ai-starter-mcp-client"2.2 配置一个stdio类型的MCP Server
Spring AI的MCP Client通过配置就能连接MCP Server,不需要写连接管理代码。最简单的场景就是连接一个由本地命令启动的MCP Server,比如官方文件系统Server,它是通过npx启动的Node.js进程。
在application.yml里这样配置:
spring: ai: mcp: client: stdio: servers: - name: filesystem command: npx args: - "-y" - "@modelcontextprotocol/server-filesystem" - "/tmp/data" env: NODE_ENV: "production"这段配置的含义是:启动一个名为filesystem的MCP Server,用npx运行官方的server-filesystem包,并允许它访问/tmp/data目录。name字段会作为工具名前缀出现在模型工具列表里,command和args对应真正的进程启动命令,env则用于注入环境变量。
配置完成后,启动Spring Boot应用,Spring AI会自动拉起这个进程,通过标准输入输出与它通信。第一次启动时npx需要拉包,耗时可能会长一些,所以不要急着断言“连不上”。
2.3 验证MCP工具是不是真的注册成功了
配置对了不等于“工具进模型上下文了”,需要验证。最直接的办法是注入已经注册好的ToolCallback列表,把工具名打出来:
@Component public class ToolListLogger implements ApplicationRunner { private final List<ToolCallback> toolCallbacks; public ToolListLogger(List<ToolCallback> toolCallbacks) { this.toolCallbacks = toolCallbacks; } @Override public void run(ApplicationArguments args) { toolCallbacks.forEach(tool -> System.out.println("MCP Tool: " + tool.getToolDefinition().name())); } }如果配置正确,你会看到类似filesystem_read_file、filesystem_write_file、filesystem_directory_tree这样的工具名出现。它们就是MCP Server通过协议暴露给模型的能力清单。
再进一步,可以写一个最简单的模型调用请求,让Agent自己去调用工具:
ChatClient chatClient = ChatClient.builder(chatModel).build(); String response = chatClient.prompt() .user("帮我列出 /tmp/data 目录下的文件名称") .call() .content();当模型认为需要查看文件系统时,它会自动选择名为filesystem_directory_tree或filesystem_read_file的工具,并把工具参数按协议发回给本地进程,拿到结果后再组织自然语言回答。这里就能直观感受到MCP的价值:模型不需要知道文件系统API长什么样,只需要知道有工具可用。
2.4 在Windows上跑stdio进程的一个常见坑
上面的配置在macOS和Linux上通常没问题,但在Windows上很容易启动失败。原因是Spring AI通过ProcessBuilder启动进程,而Windows下npx往往需要npx.cmd才能被正确解析。
一种解决方式是把command改成npx.cmd,同时在args里保持原来的参数。如果目录路径里有空格,注意用working-directory属性显式指定工作目录:
spring: ai: mcp: client: stdio: servers: - name: filesystem command: npx.cmd args: - "-y" - "@modelcontextprotocol/server-filesystem" - "C:/Users/me/data" working-directory: "C:/projects/ai-agent"这类问题不会在Linux上复现,容易让人怀疑是自己配置写错了。先查命令是否在PATH里,再看args的拆法是否符合yaml数组的预期,最后确认工作目录。基本上按这个顺序排查,stdio进程都能起来。
3. 把一个Spring Boot应用改造成MCP Server:把业务方法变成标准工具
3.1 为什么企业里往往需要自建MCP Server
MCP Client只是“消费”外部工具,但更多时候,企业内部希望把自己独有的业务能力也暴露成MCP工具。比如你有一个订单中心、一个库存中心,想让公司里的AI助手统一查询,总不能每次都去外部装一个第三方Server。更合理的做法是直接用Spring Boot加@Tool注解,把业务方法变成标准MCP工具,暴露成一个HTTP端点,供内部MCP Client和AI平台调用。
这样做的好处是:工具归属权在自己手里,鉴权、审计、参数校验都放在业务边界内,不会被外部MCP Server的黑盒实现绑架。
3.2 引入服务端Starter并配置端点
自建MCP Server的操作比想象中简单。先在项目里引入服务端Starter:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> </dependency>然后在application.yml里启用:
spring: ai: mcp: server: enabled: true name: internal-warehouse version: "1.0.0"配合spring-boot-starter-web,Spring AI会为MCP Server暴露一个HTTP端点(默认路径一般是/mcp)。这个端点就是标准的MCP协议入口,任何MCP Client都可以通过它发现工具并调用,前提是知道地址和鉴权信息。
3.3 用@Tool注解暴露业务工具的正确姿势
MCP Server暴露工具,底层依赖的还是Spring AI的@Tool注解,只是换了一个暴露给协议层的通道。先写一个普通服务类:
@Service public class WarehouseTools { @Tool(description = "根据SKU查询当前库存余量,返回JSON字符串,包含sku和stock字段") public String queryStock(String sku) { int stock = warehouseService.getStock(sku); return "{\"sku\":\"" + sku + "\",\"stock\":" + stock + "}"; } @Tool(description = "创建入库单,入参为sku和quantity,返回入库单号") public String createInboundOrder(String sku, int quantity) { String orderNo = inboundService.createOrder(sku, quantity); return "{\"orderNo\":\"" + orderNo + "\"}"; } }然后需要把这些方法注册成ToolCallbackProvider:
@Configuration public class McpToolConfig { @Bean public ToolCallbackProvider warehouseToolProvider(WarehouseTools warehouseTools) { return MethodToolCallbackProvider.builder() .toolObjects(warehouseTools) .build(); } }这里有个特别重要的细节:@Tool方法的入参和返回值一定要设计得足够“钝”。模型只会看你的方法名和描述来生成Json参数,如果你定义的是一个复杂嵌套对象,模型生成参数的失败率会显著提高。优先使用String、int、boolean,或者简单的Map与JsonNode,返回结果也最好是一段结构化字符串或JsonNode,而不是一个需要模型自行解释的领域对象。
3.4 工具方法内部的安全控制
MCP Server一旦暴露,收到的入参就都来自模型,而模型生成参数的本质是“猜”,所以工具方法内部必须当成不可信输入处理。我在生产环境里至少做三件事:
- 白名单校验:sku、orderId如果不符合业务规则,直接返回错误语义,不要抛异常。
- 操作日志:记录调用来源、用户上下文、入参和出参。线上排查Agent发疯时,全是靠日志定位。
- 权限透传:如果工具方法依赖当前用户身份,可以用
ToolContext参数接收上下文信息,再从上下文里取用户标识。
@Tool(description = "根据SKU查询当前库存余量") public String queryStock(String sku, ToolContext context) { String userId = context.getContext().get("userId"); if (!authService.hasWarehousePermission(userId)) { return "{\"error\":\"no permission\"}"; } return stockService.query(sku); }ToolContext是Spring AI内置的上下文对象,它不会进入模型生成参数的过程,但能随工具调用一起传给方法,是透传请求头和用户信息的常用通道。
3.5 用标准MCP客户端自测Server
自建Server完成后,最简单的自测方式就是拿本机命令行工具或另一个Spring AI MCP Client项目连接它。如果不想新起项目,也可以临时写一个带spring-ai-starter-mcp-client的测试配置,把上面的/mcp端点的完整地址填到SSE或HTTP类型的server配置里,启动后看ToolCallback列表里是否有queryStock和createInboundOrder。
需要特别注意的是,MCP协议传输方式也在迭代。老版本常用HTTP+SSE,新版本则更多是Streamable HTTP。Spring AI对不同传输方式的配置项不完全一样,连接自建Server时一定要先确认Server端暴露的是哪种传输,再去Client端选对应的配置入口,否则会出现“地址明明可访问,但工具发现不了”的怪问题。
4. 让Agent真正调用MCP工具:ChatClient和ToolCallingManager的组合
4.1 自动装配倒是省事,但别让它黑盒化
当我们只配置了MCP Client而不写任何代码时,Spring AI会自动把MCP Server暴露的工具转换为ToolCallback,注册到ApplicationContext。这个机制很好用,但也会带来一个困惑:工具什么时候进入模型?为什么同一个项目里有的模型能用工具、有的不能用?
原因在于,ChatClient在构建时如果需要ToolCallback,是从上下文里收集的。如果你显式指定了defaultTools,那么外部自动注册的MCP工具就不一定会被带上。所以当你发现“MCP Server连上了,但模型不调用工具”,先检查自己有没有在ChatClient.builder()里手写defaultTools,很可能你建造了一个“没有收录上下文默认工具”的Client。
4.2 显式把MCP工具挂到ChatClient上
我更推荐用显式的方式把MCP工具交给Agent,组合清晰,出问题也好排查:
@Configuration public class AgentConfig { @Bean public ChatClient chatClient(ChatModel chatModel, List<ToolCallback> mcpToolCallbacks) { return ChatClient.builder(chatModel) .defaultTools(mcpToolCallbacks.toArray(new ToolCallback[0])) .build(); } }这段代码的核心就是把Spring容器里已经存在的MCP工具全部注入到ChatClient里。其中的mcpToolCallbacks来源就是MCP Client starter自动配置生成的ToolCallback对象。这样配置后,任何用这个ChatClient发起的提示词,都会把工具列表带给模型,模型在需要的时候自动生成工具调用指令。
我用一个具体例子验证过这段链路:MCP Server是自建的库存工具,用户发出“SKU-001库存还有多少”,ChatClient会把工具定义发给模型,模型选择queryStock并传入参数{"sku":"SKU-001"},框架执行工具后,再把结果发回模型,最终模型以自然语言回答。整个过程不需要手动编写一行“工具选择”逻辑。
4.3 更底层的ToolCallingManager:自己控制调用循环
如果业务需求不只是“让模型回答一个问题”,而是要精确控制工具执行顺序、审计每一次工具结果、甚至对工具结果做统一拦截,那就别只用ChatClient,可以用ToolCallingManager。
Spring AI里的工具调用天然是一个循环:模型返回工具调用请求,框架解析并执行,执行结果再作为消息回传,模型再决定是继续调用还是输出最终结果。ToolCallingManager正是这个循环的核心编排角色。你需要做的,是把MCP工具转换成ToolCallback列表,然后交给它:
ToolCallingManager toolCallingManager = ToolCallingManager.builder().build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(mcpToolCallbacks.toArray(new ToolCallback[0])) .defaultToolCallingManager(toolCallingManager) .build();当工具调用量特别大或出现“模型反复调用同一个工具”的情况时,用ToolCallingManager可以更容易地加入重试、熔断和超时控制。它也让日志更清晰:你能看到几轮对话中工具被调用了几次,结果是什么,而不是两眼一抹黑。
4.4 给工具调用留好观测入口
工具调用一旦多起来,最危险的不是调用失败,而是“模型开始乱选工具”你却毫无察觉。我的做法是在ToolCallback外面包一层审计类,统一记录工具名、入参、耗时、结果摘要,并输出一个traceId串到对话日志里。
这样当用户反馈AI回答不对时,可以直接追溯到是哪个工具返回了异常数据,还是模型没选对工具。如果最后发现是模型选错工具,优先改工具的description,把触发条件写得更明确,而不是一味调温度参数。
5. 实战例子:餐饮SaaS后台的AI助手通过MCP查库存和创建入库单
5.1 场景设定:从一个常见业务需求切入
我最近在做餐饮SaaS后台的AI集成项目,里面的一个核心场景就是:运营人员在对话框里直接问库存、查订单,甚至让AI帮忙创建入库单。这个场景非常典型,因为既有“读操作”又有“写操作”,又需要严格的权限控制。
技术栈是Spring Boot + Spring AI Alibaba,模型层走的是标准OpenAI兼容接口。MCP部分我选择了自建Server,把订单和库存相关的@Tool方法暴露出来。这样比直接写Function Calling的好处是:工具定义未来可以同时提供给公司内部多个AI渠道使用,而不是只绑死在某一个ChatClient上。
5.2 工具粒度和写操作设计
工具粒度是整个链路能否成功的关键。我在这个项目里只暴露了三个工具:
@Tool(description = "查询指定SKU当前可用库存数量,参数传SKU编码,返回JSON") public String queryStock(String sku) { ... } @Tool(description = "查询订单基本信息,参数传订单号,返回JSON,包含订单状态和金额") public String queryOrder(String orderNo) { ... } @Tool(description = "创建入库单,参数为sku、quantity。注意:此操作会实际写入库存,调用前必须确认用户意图明确。返回JSON,包含入库单号") public String createInboundOrder(String sku, int quantity) { ... }读操作和写操作分开定义,不要揉成一个“万能方法”。写操作的描述里我特意加上了“调用前必须确认用户意图明确”这句话,提示模型不要因为用户随口一句就触发写入。当然,描述归描述,可靠的人为确认还是要靠交互流程。
5.3 在Prompt里给Agent立规矩
工具描述只是第一道防线,真正不可靠的是模型对用户意图的推断。我在系统提示词里固定了一段规则:
- 涉及库存查询,必须使用queryStock。
- 涉及创建入库单或出库单,必须先查询当前库存,再向用户展示待确认信息,得到明确确认后才调用写工具。
- 工具返回error或no permission时,如实转述结果,不要自行编造。
实际测试效果:用户发“SKU-002库存还剩多少,顺便帮我补10件”时,模型先调用queryStock拿当前库存,然后回复“当前SKU-002库存为X件,是否确认创建10件入库单?”,只有用户回复“确认”后,模型才调用createInboundOrder。
这个链路本身就演示了MCP在Spring AI里最有价值的部分:Agent已经能“看情况做事”,而不只是简单问答。
5.4 多租户下的权限必须挂在工具内部
餐饮SaaS是典型的多租户系统,A商户的库存和B商户的库存必须完全隔离。MCP工具拿到模型生成的参数时,通常只会有sku、quantity,不会自动带上租户ID。
因此我在ToolContext里注入当前登录用户的租户信息,工具方法内部再做一次租户数据源路由和越权校验。模型就算生成了某个sku,也只能查到本租户的数据。这一点无论MCP做得再方便,业务层都不能省。
5.5 写操作失败后的补偿
另一个容易被忽略的问题是:工具执行成功但后续Agent对话中断了。比如createInboundOrder已经写库成功,但模型流式输出断掉,用户以为没成功又下了一条重复单。我的方案是让入库单工具支持幂等键,也就是把“用户操作会话ID+操作编号”作为一个参数传给工具,如果工具发现同一幂等键已处理过,直接返回原单号,不再重复创建。
这个细节在真实业务里比任何花哨的Agent编排都保命。
6. 接MCP时最容易翻车的几个地方
6.1 Spring AI版本和Spring Boot版本对不上
MCP相关包跟着Spring AI版本走,而Spring AI版本又和Spring Boot版本有对应关系。最典型的错误是:项目里Spring Boot用的是3.3.x,但手动引用了最新版Spring AI,结果启动时出现NoSuchMethodError或McpSchema相关类冲突。
我的建议是永远使用spring-ai-bom统一管理,并且只在升级Spring Boot版本后整体升级Spring AI BOM,不要单独升级某一个starter。如果项目里有依赖平台强制的旧Boot版本,就查一下官方支持的版本映射表,选一个兼容的Spring AI版本。
6.2 stdio进程起不来或连上了但工具为空
stdio类型的MCP依赖本地命令,最容易出问题。常见原因我总结了几个:
- command对应的命令不在PATH里,比如Windows下的
npx需要写成npx.cmd。 - args在yaml里被写成了字符串而不是数组,导致参数被当成一个整体传给进程。
- env变量缺少必要配置,Node.js或Python进程启动后直接崩溃。
- 工作目录不存在,进程没有权限读写指定路径。
遇到这类问题,直接打开MCP Client的内部日志,把进程启动的异常堆栈打出来。Spring AI有对应的debug开关,通常在spring.ai.mcp.client.stdio.servers.*.env里给进程加DEBUG环境变量,或调高Spring框架的日志级别,能看到进程的stdout和stderr。
6.3 远程MCP端点连接不上:传输协议版本不匹配
远程MCP连接时,最常见的坑是Server端和Client端对同一个URL的传输方式理解不一致。老版Server暴露的是HTTP+SSE,新版则更偏向Streamable HTTP。Spring AI在配置远程端点时,不同传输方式对应不同的配置入口。如果你按SSE方式配置,但Server端已经切到Streamable HTTP,很可能会发现“可以握手,但工具列表为空”。
最简单的方法:自建MCP Server时,在启动日志里明确打印出端点暴露的路径和传输方式,然后写一个最简单的Client连上去验证,不要直接在业务Agent里调试。
6.4 工具数量太多,把大模型上下文撑爆
每个MCP工具定义都会占用模型上下文的token。当一个MCP Client接了好几个Server,每个Server又暴露几十个工具时,你会发现即使没有调用任何工具,模型请求的token消耗也明显上涨。
Spring AI的MCP Client配置通常支持按工具名筛选,只暴露你当前Agent真正需要的工具。我在生产环境里的做法是:按业务域拆分Agent,每个Agent只连接1到2个MCP Server,并过滤掉无关工具。宁可多起几个服务,也不要让一个Agent背上几十个工具的上下文负担。
6.5 不要把所有@Tool方法都直接暴露到公网
MCP Server本质是远程调用入口,和REST接口一样需要做网关隔离、鉴权、限流。如果直接把带@Tool的Spring Boot应用暴露到公网,又没有加安全层,会变成“任何人都能通过MCP协议调用你的内部工具”。所以Server端一定要在/mcp端点外层加Spring Security,或者至少加一层自定义过滤器校验调用方身份。
有个容易被忽略的点:MCP工具方法内部的异常信息也会返回给模型,模型可能把“SQL异常详情”“文件路径”这类内部信息直接告诉用户。所以在工具方法里,不要返回底层异常堆栈,要把异常转化为对外安全的语义化错误信息。
我在实际项目中,通常会给MCP工具再加一层统一出入口:输入统一转成Map<String, Object>过滤,输出统一包一层结果对象,既方便模型解析,也方便后续统计和审计。另一个小技巧是,把MCP Server的启动交给Spring的SmartLifecycle管理,避免AI首轮调用时因为进程还没拉起来而超时。MCP这条链路看着简单,但真到生产环境,细节都是在这些不起眼的地方积累出来的。希望这篇操作记录能帮你少走几步弯路。