
1. 从问题出发MCP 客户端注解到底要解决什么这两年做 AI Agent 相关的 Java 项目基本绕不开一个话题——MCPModel Context Protocol。Spring AI 从很早就开始集成 MCP 支持到 2.0.x 版本已经把 MCP Server 和 MCP Client 两条链路都打通了。我接手过几个项目有的是要把现有的 RuoYi 管理后台接进 AI 能力有的是要把 Dify 的工作流迁到 Java 生态里最后都会落到同一个难题上怎么用最少的代码让项目里已有的 Java 方法变成 AI 模型能直接调用的工具同时又要能对接外部的 MCP 服务器。MCP 客户端注解就是解决这个难题的关键手段。Hot search terms 里那个Spring AI 干货笔记之 MCP 客户端注解的标题看起来只是一个技术笔记的标题但它背后其实牵扯着一整套工具调用的设计思路。这篇文章我会从实际踩坑的角度出发把 Spring AI 里 MCP 相关的注解机制、客户端配置方式、工具注册流程、以及调试过程中遇到的各种问题逐一拆开讲清楚。要理解 MCP 客户端注解先得建立一个基本认知MCP 是一个软件协议它跟 HTTP、WebSocket 这类协议是同一个层面的概念。它定义的是 AI 模型和外部工具之间如何发现、如何调用、如何返回结果的规则。类比一下MCP 服务器就好比一个技能市场MCP 客户端就好比采购方而 Spring AI 里的那些注解就是让你快速在这个市场里摆摊和采购的傻瓜化工具。如果你只靠手写 MCP 协议里的 JSON-RPC 消息那会非常痛苦注解的意义就是把这一层复杂性封装掉了。对于正在做 Spring AI Agent 开发、或者想从 Dify 这类低代码平台迁到 Java 原生实现的团队来说这篇文章可以作为一份入门到进阶的参考。我不会只贴代码还会把我实际运行过程中遇到的异常、排查思路、以及哪些配置特别容易踩坑都写进去。2. Spring AI 里的 MCP 注解家族全解析2.1 Tool 注解把方法变成 AI 可调用的工具MCP 客户端注解里出现频率最高的就是Tool。这个注解标记在 Java 方法上Spring AI 会自动把方法转换成 MCP 工具定义ToolDefinition注册到模型对话上下文中。AI 模型在生成回答时如果需要调用这个方法就会根据方法名、参数约束、描述信息组织一次函数调用然后由 Spring AI 的调度器执行真实的方法逻辑将结果回传给模型。我先把最常用的写法贴出来注意看参数的配置方式Service public class WeatherToolService { Tool(name getWeatherByCity, description 根据城市名称查询实时天气信息) public String getWeatherByCity(ToolParam(description 城市名称例如北京、上海) String city) { // 调用实际的天气服务 return weatherApi.query(city); } }这里有几个关键点需要理解name参数是工具的唯一标识。AI 模型通过名字来决定是否调用这个工具名字要起得表意明确不要用method1这种。description参数极其重要。大模型不是靠读代码理解工具的它是靠 description 来理解工具的用途和适用场景。我见过很多项目把 description 写得很敷衍结果模型在需要调用工具的时候懵掉了输出了一堆我现在无法查询天气的废话。description 要写清楚工具能干什么、在什么场景下用、有什么限制。ToolParam注解用来说明方法参数的语义。这个参数描述同样影响模型对参数的理解尤其是字段含义不直观的情况下写得越细越好。在实际业务中还有一种常见场景一个工具方法需要访问当前用户上下文、数据库 Session 等。这时候我建议把工具方法放在一个独立的 Service 里通过 Spring 的依赖注入把需要的组件引进来不要用静态方法。2.2 MCP 客户端编程方式不使用注解的另一种解法这里需要澄清一个常见的概念混淆。在 Spring AI 中MCP 客户端并不完全依赖注解来实现Tool注解主要服务于向外暴露工具的场景。如果你的应用需要消费外部 MCP 服务器提供的工具比如连接一个已有的文件系统 MCP 服务器、数据库 MCP 服务器那你要做的是创建一个 MCP 客户端实例然后从客户端获取工具回调。Spring AI 提供了两种 MCP 客户端接入方式Stdio 方式通过标准输入输出流与外部的本地进程通信适合连接同机的命令行工具、脚本。HTTP 方式包含 SSE通过 HTTP 或 SSE 连接到远程 MCP 服务器适合连接部署在远端的功能服务。用代码创建一个 MCP 同步客户端的方式如下Configuration public class McpClientConfig { Bean public McpSyncClient mcpSyncClient() { var transport new HttpUrlTransport.builder() .url(http://localhost:8080/mcp) .build(); return McpClient.sync(transport).build(); } }拿到McpSyncClient之后你可以调用listTools()方法获取远程服务器提供的工具列表再转换成 Spring AI 的ToolCallback注册给模型。2.3 McpClient 注解自动注入客户端的高级玩法在 Spring AI 的较新版本中MCP 客户端集成框架spring-ai-autoconfigure-mcp-server / mcp-client提供了McpClient注解用于自动注入已配置的 MCP 客户端实例。这个注解的应用场景是你已经在配置文件中声明了多个 MCP 服务器连接希望在某个 Service 或 Controller 里直接注入对应的客户端 Bean 来调用工具。配置文件的声明方式类似spring: ai: mcp: client: connections: - name: file-server url: http://localhost:8081/mcp - name: db-server url: http://localhost:8082/mcp然后在业务代码中使用Service public class ThorService { private final McpSyncClient fileClient; public ThorService(McpClient(file-server) McpSyncClient fileClient) { this.fileClient fileClient; } }这种注解注入方式大大简化了客户端的管理。不用再手动创建每个连接也不用担心 Bean 的重复定义。有一点值得提醒McpClient注解在多个 Spring AI 小版本里的包路径有过调整不同版本之间编译可能不兼容。所以在引入依赖的时候务必锁定版本号最好全局用 BOMBill of Materials统一管理。2.4 注解参数选型对照表用表格整理一下核心参数选项方便项目中查阅。注解核心参数作用备注Toolname工具唯一名称建议英文驼峰Tooldescription工具用途描述写详细极大影响模型调用准确率ToolreturnDirect是否直接返回结果而非传给模型适合中间态结果慎用ToolParamdescription方法参数说明辅助模型生成正确的参数值ToolParamrequired参数是否必须默认跟随方法签名推断McpClientvalue指定连接配置的 name对应配置文件里的 connections3. 实操过程从零到一搭建一个可用的 MCP 客户端工程3.1 依赖引入与版本选择Spring AI 的 MCP 支持目前主要在spring-ai-mcp-server和spring-ai-mcp-client两个模块里。我以 Spring Boot 3.4.x Spring AI 2.0.1 为例Maven 依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-mcp-client/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-mcp-server/artifactId /dependency版本选择上有一个经验不要直接用某个模块自动引入的传递依赖版本建议显式声明 Spring AI BOM避免不同子模块之间版本不一致导致运行时NoSuchMethodError。我曾在项目里遇到McpSyncClient接口的listTools()方法抛 NoSuchMethodError最后排查发现是 mcp-server 和 mcp-client 两个模块一个升到了 2.0.1、另一个还是 1.0.0接口签名不兼容非常头疼。3.2 服务端暴露一个工具MCP Server 配置在同一个工程里既当服务端又当客户端是常见的调试姿势。先做一个简单的 MCP Server 端工具方便验证客户端能否发现并调用它。Component public class OrderQueryTool { Tool(name getOrderStatus, description 根据订单号查询订单的当前状态返回状态码和状态描述) public String getOrderStatus(ToolParam(description 订单号格式如 ORD20240001) String orderId) { if (orderId.startsWith(ORD)) { return 订单 orderId 状态已发货预计3天内送达; } return 未找到订单 orderId; } }随后配置一个暴露 MCP 工具的 HTTP 端点。Spring AI Autoconfiguration 中提供了McpServerAutoConfiguration它会自动扫描被Tool标注的 Spring Bean 并注册为 MCP 工具。在application.yml里指定服务端端口server: port: 8080 spring: ai: mcp: server: endpoint: /mcp启动应用后/mcp端点就是一个标准的 MCP HTTP 服务端入口客户端可以通过HttpUrlTransport连接到这里。3.3 客户端连接两种传输方式的选择客户端连接时工程里既要选择传输方式也可以直接使用McpClient注入。先看 Stdio 方式它比较适合本地进程Bean public McpSyncClient stdioClient() { var transport new StdioTransport.builder() .command(List.of(node, path/to/mcp-server.js)) .build(); return McpClient.sync(transport).build(); }再看 HTTP 方式这也是远程部署的主流选择Bean public McpSyncClient httpClient() { var transport new HttpUrlTransport.builder() .url(http://localhost:8080/mcp) .build(); return McpClient.sync(transport).build(); }这里要说清楚一点Stdio 适合开发和本机工具生产环境一旦涉及多实例部署、容器编排魔法就会出很多问题。因为 Stdion 模式要求 MCP 服务器进程与客户端进程保持相同的文件系统、相同的环境变量容器化之后很难管理。而 HTTP 模式没有这个限制只要网络可达即可。所以我强烈建议生产环境统一走 HTTP/SSE。3.4 客户端调用工具并接入 ChatClient工具接到手之后最终目标是让大模型在对话过程中自主调用。Spring AI 的ChatClient支持针对指定工具进行绑定Service public class AssistantService { private final ChatClient chatClient; public AssistantService(ChatClient.Builder chatClientBuilder, McpSyncClient mcpSyncClient, ListToolCallback localTools) { ListToolCallback mcpTools mcpSyncClient.listTools().stream() .map(toolDefinition - McpToolUtils.asToolCallback(mcpSyncClient, toolDefinition.name())) .toList(); ListToolCallback allTools new ArrayList(localTools); allTools.addAll(mcpTools); this.chatClient chatClientBuilder .defaultSystem(你是一个智能助理可以查询订单信息。) .defaultTools(allTools.toArray(ToolCallback[]::new)) .build(); } public String chat(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }核心逻辑其实就两步第一步用listTools()从 MCP 客户端拉取远程工具列表第二步用McpToolUtils.asToolCallback将 MCP 工具包装成 Spring AI 的ToolCallback注册到 ChatClient。我经常见到有人卡在defaultTools这一步只传了本地Tool方法没有把 MCP 客户端拿到的远程工具加进去结果模型始终无法调用远端能力。这里面有个认知必须建立Tool注解负责暴露MCP 客户端负责拉取两者最后都要汇入ToolCallback这个统一的工具注册入口。4. 工具注册机制与调用流程深度梳理4.1 从注解到 ToolCallback 的完整链路很多读者看到这里可能有点迷惑Tool注解到底是怎么变成ToolCallback的我用一个完整链路来拆解。由Tool注解标注的方法在 Spring AI 启动时会经过下面几个阶段工具收集阶段ToolDefinitionResolver扫描应用上下文中的所有 Bean查找带Tool注解的方法。工具注册阶段将注解信息解析成ToolDefinition包含方法名、参数类型、参数顺序、返回值类型、描述信息。回调包装阶段为每个ToolDefinition创建一个MethodToolCallback它持有了 Bean 实例和方法引用等待模型调用时反射执行。模型感知阶段这些ToolCallback序列化成 JSON Schema放入请求的tools字段中发送给大模型。这里有个容易被忽视的点Spring AI 的ChatClient默认只加载标注了Tool且被 Spring 管理的方法。很多人把工具方法写在一个普通类里没有加Component、Service等注解依赖没进入容器自然不会被扫描到。排查这个问题最快的方式就是在启动日志里搜 tool 关键字看有没有输出注册的工具列表。4.2 Tool 与 Spring AI Agent 的关系Spring AI 从 1.0 开始就内置了 Agent原 ChatClient 的高级抽象在 Agent 模式下工具调用的流程被封装得更加自动化。不过核心机制依然逃不开ToolCallback注册。我项目里用的方案是在 Agent 的systemPrompt里事先声明你可以调用订单查询工具获取订单状态同时在toolCallbacks里注册getOrderStatus方法对应的ToolCallback。模型在对话过程中如果需要订单信息就会自动发起getOrderStatus(orderIdxxx)的函数调用。必须强调的是即使 Agent 写得很复杂、甚至接入了 RAG 检索、工作流编排工具调用这一环始终是 MCP 的职责范围。理解了Tool到ToolCallback的转换过程后面不管是做多工具协同、MCP 服务器嵌套都会轻松很多。4.3 多 MCP 服务器连接时的 Bean 管理策略当一个项目需要连接多个 MCP 服务器时比如一个负责文件读取、一个负责数据库查询、一个负责外部 APIBean 管理就开始变得重要。推荐的方案是按连接命名注入Service public class HybridService { private final McpSyncClient fileMcpClient; private final McpSyncClient dbMcpClient; public HybridService(McpClient(file-server) McpSyncClient fileMcpClient, McpClient(db-server) McpSyncClient dbMcpClient) { this.fileMcpClient fileMcpClient; this.dbMcpClient dbMcpClient; } }这时候你可以在application.yml中定义多个 connections每个都有自己的 name 和 url。Spring Boot 的自动配置会为每个 connection 创建一个独立的McpSyncClientBean并将 Bean 的 qualifier 设为配置的 name。之后用McpClient(file-server)就能精准注入对应客户端。不推荐的做法是在配置里全部使用默认 name然后自行维护一个 List 按顺序取。一旦配置顺序调整、服务器上下线代码就很容易取错客户端排查成本非常高。5. 常见问题排查与避坑指南5.1 工具注册了但模型就是不调用这是我在社区里看到提问最多的问题之一。正常情况下Tool方法被注册进 ChatClient 后模型在合适的语境下应该能自主决定调用工具。但如果你把工具方法塞进去了模型却始终用现成的知识硬答可能的原因有三个description 写得太模糊模型无法判断何时应该用这个工具。同一对话上下文里工具太多模型对工具的选择有随机性尤其在弱模型上表现更明显。工具方法没有纳入 ChatClient 的toolCallbacks中只是写了Tool但忘了通过defaultTools或 Agent 的工具注册参数挂进去。我后来在项目里形成了一套约定description 里必须写明触发条件比如仅当用户明确给出订单号时使用此工具查询订单状态当用户询问订单物流时使用此工具。这样写之后模型调用的准确率明显提升。还有一个小技巧优先让工具返回结构化文本比如JSON或带分隔符的字符串。模型在生成最终回复时对结构化结果的理解比自由文本更好。5.2 MCP 客户端首次连接失败客户端报ConnectException或握手超时大多不是代码问题而是网络和启动顺序问题。如果你在同一应用里同时启动 MCP Server端口 8080和 MCP Client连接 8080并且客户端在依赖注入阶段就尝试连接可能因为服务端还没启动完成导致连接失败。我的做法是给客户端连接失败加上重试机制Bean public McpSyncClient resilientClient() { var transport HttpUrlTransport.builder() .url(http://localhost:8080/mcp) .build(); return McpClient.sync(transport) .connectTimeout(Duration.ofSeconds(5)) .build(); }connectTimeout一定要显式配置默认值在某些版本里非常短在服务端启动稍微慢一点的环境下必然超时。5.3 注解扫描不到 Tool 方法排查步骤可以按这个顺序来确认工具类是否被 Spring 容器管理在类上加了Component、Service或RestController没有确认Tool注解的包路径是否正确Spring AI 里Tool有多个来源有的来自org.springframework.ai.tool.annotation.Tool有的来自 MCP SDK 的io.modelcontextprotocol.spec.McpSchema.Tool不要搞混。确认方法的访问修饰符Tool标注的方法必须是public私有方法是不会被扫描的。确认方法不能在接口里只做声明而没有实现类实现Spring AI 的扫描器需要具体 Bean 实例。5.4 模型输出工具参数类型不匹配当你的工具方法参数是Integer、Long、Boolean等封装类型时模型如果在一个不够强的模型上返回了字符串123Spring AI 在反序列化阶段可能报类型转换异常。我的规避办法是参数统一使用 String 或基本类型在方法内部再做解析和校验。不要依赖框架自动转类型尤其是对接千问、DeepSeek 这类模型时参数类型解析偶尔会有偏差。举个实际例子Tool(name calculateSum, description 计算两个整数的和) public String calculateSum( ToolParam(description 第一个数字) String a, ToolParam(description 第二个数字) String b) { int ia Integer.parseInt(a); int ib Integer.parseInt(b); return String.valueOf(ia ib); }虽然看起来不优雅但实际运行中它规避掉了大量参数类型解析问题稳定性很高。5.5 连接外部 MCP 服务器时的鉴权问题连接第三方 MCP 服务器比如付费的数据库工具、文件管理工具通常需要鉴权 head。当前 Spring AI 的HttpUrlTransport可以在构建时指定自定义 Headervar transport HttpUrlTransport.builder() .url(https://mcp.example.com/mcp) .headerProvider(() - Map.of(Authorization, Bearer token)) .build();如果你对接的是 Azure OpenAI、ModelScope 百炼平台这类服务要注意它们各自的 MCP 网关地址以及鉴权 header 的配置差异。从热词里能看到spring ai 2.0 连接百炼 qwen3.7是个热门需求我实际试下来把 MCP 服务地址指向百炼的 MCP 网关并在 header 里带 API Key是可以正常工作的。唯一的坑是 API Key 不要硬编码在代码里用环境变量或配置中心管理避免泄密。5.6 使用表格速查常见异常异常信息原因解决方案NoSuchMethodError: McpSyncClient.listToolsmcp-server 和 mcp-client 版本不一致统一版本走 Spring AI BOMConnectException服务端未启动 / 网络不通显式配置 connectTimeout必要时重试工具方法未被扫描类不在容器中 / 注解包路径错误添加 Component核对 import 路径ClassCastException: String cannot be cast to Integer模型返回参数与 Java 类型不匹配方法参数统一用 String内部解析JSON parse error模型返回非法 JSON在 description 中注明参数格式约束模型输出6. 从 Dify 工作流迁移到 Spring AI 的实践启发热词里提到Dify 工作流转成 Spring AI Java 代码是很多人关注的方向我恰好带团队做过一个类似迁移。Dify 里的工具节点在 Spring AI 中的对应物其实就是带Tool注解的方法。Dify 里的知识库检索节点对应 Spring AI 里的 RAG 工具。Dify 里的HTTP 请求节点对应你自己封装的 HTTP 工具方法。迁移的时候我建议的顺序是先把 Dify 工作流节点画成一张工具清单每个节点对应一个Tool方法。再梳理节点间的数据依赖确定哪些工具调用是串行的、哪些是并行的。最后通过 MCP 客户端将这些工具暴露给模型让模型自主编排。值得注意的一点Dify 的编排是显式图结构而 Spring AI 的 Agent 更多是隐式决策。这意味着迁移后模型可能不会严格按原工作流的顺序执行工具需要你在systemPrompt或工具 description 里把顺序约束写明。比如必须先调用查询订单工具再调用物流信息工具否则模型可能会跳过中间步骤直接给出一个不准确的答案。我个人在这个迁移项目里最大的感受是Spring AI 的高度灵活性是一把双刃剑。它给了你无限的自由去组合工具、方式、模型但也要求你有足够的约束意识。Dify 帮你把很多流程固化住了而 Spring AI 里这个固化工作必须你自己来做。7. 最后再分享几个实战心得做 MCP 客户端注解相关工作大半年踩过的坑不少有几个心得特别想留着给后来人。工具方法命名建议用动词名词结构。getOrderStatus、sendEmail、queryBalance这样模型一眼能看出工具的作用。不要用doTask、handleRequest这类语义模糊的命名模型在众多工具里做选择时命名是最直观的信号。description 花费的精力要占工具开发的 50%。很多人把工具方法 10 分钟写完description 30 秒随便敲一句。模型对工具的理解完全取决于这 30 秒写的文字你说它能准确调用才有鬼了。我写 description 之前会先问自己三个问题什么场景用需要什么参数返回什么形态全部写清楚。在开发调试阶段建议把模型的工具调用过程日志打印出来。Spring AI 的ToolCallingManager会在调用前后触发事件你可以监听事件把调用链完整打出来Component public class ToolCallLogger { EventListener public void onToolCall(ToolExecutionStartEvent event) { System.out.println([调用工具开始] event.instant() - event.toolCallback()); } EventListener public void onToolFinish(ToolExecutionFinishEvent event) { System.out.println([调用工具结束] event.instant() - event.toolExecutionResult()); } }生产环境可以用 Trace 级别的日志收集到链路追踪系统这对排查模型为什么没调工具、工具调用结果如何流转等疑难杂症非常有帮助。最后再分享一个小技巧如果你的 MCP 客户端需要连到本地调试中的 MCP 服务器但你又不想每次改代码都重启服务端可以尝试在启动参数里加--spring.ai.mcp.client.connections[0].urlhttp://localhost:8080/mcp覆盖配置。开发期用命令行参数覆盖配置比改 yaml 文件要高效得多不会污染公共配置。