我最近在把内部的工单查询接口接进大模型对话流程时,第一次真正把 Spring-AI 的 MCP 源码摊开从头读了一遍。MCP 是 Model Context Protocol 的缩写,它解决的问题很直接:让大模型不只有“生成文本”的能力,还能安全地去调用外部工具、查数据库、拉文件、调接口。Spring-AI 把 MCP 客户端和服务端的抽象都做在框架里,我们平时只需要写几个@Tool注解方法,模型就能在对话里自动调用它们。这篇文章算是一篇源码浅析,不是完整的入门教程。适合已经跑通过官方 Demo、但想知道“工具声明、JSON Schema 生成、请求分发、自动配置”这几条链路到底是怎么串起来的人。我会先讲清楚模块边界,再给最小可运行示例,然后分别拆服务端和客户端源码,最后把我在实际调试中踩过的几个坑放出来。我手头的版本是基于 M6 附近的代码,不同小版本的 builder API 可能略有调整,但核心调用链路基本没变。1. Spring-AI 的 MCP 不是一个大模块,而是一组分层的模块1.1 协议 SD K和 Spring AI 桥接包各管一半很多人以为 Spring-AI 的 MCP 源码都写在org.springframework.ai.mcp下面,实际不是。你把依赖树展开就会发现,真正负责协议编解码、JSON-RPC 帧处理、握手协商的代码,在一个独立的 MCP Java SDK 里,包名是io.modelcontextprotocol。Spring-AI 这边只是用org.springframework.ai.mcp起了一套桥接层,把 Spring 的工具抽象和协议 SD K 对接起来。这个拆分是我读源码时最先要接受的现实。如果一直在 Spring-AI 的源码里找JsonRpcMessage或protocolVersion的定义,会找不到,因为它们根本不在 Spring-AI 仓库里。反过来,如果只在协议 SD K 里找@Tool注解和ToolCallback,也找不到,那是 Spring-AI 自己的工具抽象。io.modelcontextprotocol -> 协议层:JsonRpc、握手、工具发现、传输抽象 org.springframework.ai.mcp -> 桥接层:ToolCallback 转换、自动配置、Spring 容器整合 spring-ai-mcp-server -> 服务端模块:让 Spring 应用对外暴露 MCP 服务 spring-ai-mcp-client -> 客户端模块:让 Spring 应用连接远端 MCP 服务为什么要拆成这么多层?因为 MCP 协议本身和 Spring 没有关系,任何 Java 项目都可以用io.modelcontextprotocol这个 SDK 去做一个 MCP 客户端或服务端。Spring-AI 的贡献在于,把这些协议能力包装成 Spring 风格的 Bean,让你在配置类里写一个McpServer,再塞进几个ToolCallback,剩下的启动、握手、注册工具都由框架完成。1.2 先分清两种部署姿态,再看源码就不会乱读 MCP 源码前,必须先分清同一个应用可能存在的两种姿态。第一种是服务端姿态。Spring-AI 应用作为一个 MCP Server 跑起来,外部 MCP Client 过来连接,通过tools/list看到我们暴露的工具,通过tools/call调用我们的@Tool方法。第二种是客户端姿态。Spring-AI 应用作为 MCP Client 去连接外部 MCP Server,把远端服务暴露的工具列表拉回来,转成 Spring-AI 的ToolCallback,然后交给大模型选。大模型决定调用某个工具时,Spring-AI 把请求发出去,通过 MCP Client 转发到远端 Server 执行。这两种姿态对应的核心类分别是McpServer和McpClient。它们虽然都叫 MCP,但一个是处理请求,一个是发起请求。源码里所有看似复杂的逻辑,最后都归于这两个方向。2. 源码阅读前的热身:用两段最小示例确定阅读入口2.1 服务端:让 Spring-AI 应用变成 MCP 服务器先看服务端。一个最小例子通常长这样:@Configuration public class McpServerConfig { @Bean public WebMvcServerTransport mcpTransport() { return new WebMvcServerTransport("/mcp"); } @Bean public McpServer mcpServer(WebMvcServerTransport transport, List<ToolCallback> toolCallbacks) { return McpServer.sync(McpSchema.VERSION_2025_03_26) .transport(transport) .toolCallbacks(toolCallbacks) .build(); } }这里的McpServer.sync是协议 SDK 提供的一个静态入口,transport表示传输方式。WebMvcServerTransport会注册一个 Spring MVC 的 Controller,把/mcp这个 HTTP 端点收到的 JSON-RPC 请求交给McpServer处理。工具集合则是从 Spring 容器里收集到的ToolCallback。与此同时,业务侧只需要写一个普通的 Spring 组件:@Component public class OrderToolService { @Tool(description = "根据订单号查询订单状态", name = "query_order") public String queryOrder(String orderId) { return "订单 " + orderId + " 状态:已发货"; } }@Tool是 Spring-AI 自己的注解。它本身不负责 MCP 通信,只负责描述“这是一个可以被模型使用的工具”。要让 MCP 服务端识别它,还需要有一个地方把它包装成ToolCallback。这一步在很多 starter 版本里已经自动化了,但它背后的原理反而值得单独看。后面第三节我会展开。2.2 客户端:让 Spring-AI 应用接入外部 MCP 服务再看客户端。如果项目要去连接一个外部的 MCP Server,通常是这种写法:@Bean public HttpClientSseClientTransport mcpClientTransport() { return new HttpClientSseClientTransport("http://localhost:8081/sse"); } @Bean public McpClient mcpClient(HttpClientSseClientTransport transport) { return McpClient.sync(McpSchema.VERSION_2025_03_26) .transport(transport) .build(); } @Bean public List<ToolCallback> remoteMcpTools(McpClient mcpClient) { return McpToolUtils.toToolCallbacks(mcpClient, "remote_"); }McpToolUtils是 Spring-AI 桥接层里一个非常关键的类。它把远端 MCP Server 返回的工具定义,转换成本地 Spring-AI 的ToolCallback列表。有了这个列表,大模型调用工具时,Spring-AI 就能像调用本地方法一样,把调用请求转发到远端 MCP Server。这两段示例跑通之后,源码阅读的入口就清楚了:服务端要读McpServer和ToolCallback的映射逻辑,客户端要读McpClient和McpToolUtils的适配逻辑,两头共用的是协议 SDK 的 JSON-RPC 处理部分。3. 服务端源码重点:工具声明、请求分发、方法反射三件事3.1 工具声明阶段:ToolCallback 如何变成 tools/list 里的工具MCP 服务端被 MCP 客户端连接后,第一件重要的事是响应tools/list。这个响应里要告诉对方:我有哪些工具、每个工具叫什么名字、描述是什么、参数长什么样。源码里这一段的源头是 Spring-AI 的ToolCallback接口。ToolCallback里有获取工具定义的方法,返回一个包含name、description、inputSchema的对象。简单理解,@Tool注解上的name和description会被提取到这里,方法的参数列表会被生成一份 JSON Schema。Spring-AI 在处理@Tool方法时,并不是直接拿方法名当工具名,而是通过ToolCallback的工厂方法去构建。我建议你把断点打在tools/list的响应构造处,看一次完整的返回值,能很直观地感受到这个映射结果。它长得很像下面这段 JSON:{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "query_order", "description": "根据订单号查询订单状态", "inputSchema": { "type": "object", "properties": { "orderId": { "type": "string", "description": "订单号" } }, "required": ["orderId"] } } ] } }如果 JSON Schema 生成正确,说明ToolCallback到 MCP 工具的声明阶段没问题。接下来的焦点就是调用阶段。3.2 调用阶段:tools/call 请求如何命中 @Tool 方法当大模型决定调用某个工具时,MCP 客户端会发来一个tools/call请求,请求体里有工具名和参数。服务端收到后,源码里要走这么一条路径:WebMvcServerTransport -> McpServer 内的请求分发器 -> ToolCallingManager -> 根据工具名找到对应 ToolCallback -> 解析参数 -> 反射调用 @Tool 方法 -> 包装返回结果ToolCallingManager是 Spring-AI 执行工具调用的统一入口。它的职责很简单:你给我一个工具调用请求,我根据工具名找到对应的执行器,然后把参数装填好,执行业务方法,最后把结果封装成 MCP 能识别的文本内容。有一点要注意:@Tool方法并不一定就是被 MCP 请求直接触发的。它先被包装成ToolCallback,然后注册到ToolCallingManager,MCP Server 只是这个调用链路的入口之一。换句话说,同一个ToolCallback,既能被 MCP 请求触发,也能被本地其他逻辑显式调用。理解这一点,就不会把“MCP Server”和“工具执行器”混为一谈。返回结果到 MCP 客户端时,通常会包装成一个content数组。这也是我最初不太习惯的地方。普通 Java 方法返回一个字符串就够了,但 MCP 的返回模型要求是结构化内容:{ "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "订单 A1001 状态:已发货" } ], "isError": false } }3.3 源码阅读视角:协议层和桥接层要来回切换读服务端源码时,最大的麻烦是经常在协议 SDK 和 Spring-AI 桥接代码之间跳来跳去。我自己的经验是,凡是看到McpSchema、McpAsyncServer、JsonRpcMessage,先默认它们是协议 SDK 的;凡是看到ToolCallback、ToolCallingManager、McpToolUtils,先默认它们是 Spring-AI 的。有了这个视角,代码层次会清晰很多。MCP Server 收到一个协议帧后,只会做非常有限的拆解,把请求转成内部执行请求;真正的工具发现、参数生成、方法反射,全都在 Spring-AI 这一侧完成。反过来也一样,McpServer 并不关心你的@Tool方法里是查数据库还是调外部接口,它只负责把框架交给它的工具执行器暴露出去。4. 客户端源码重点:远端工具是怎么变成本地 ToolCallback 的4.1 初始化握手里发生了什么相比服务端,客户端源码更容易被忽略。其实McpClient.sync(...).build()这行代码并不只是创建对象,它会立刻触发一次 MCP 握手。握手阶段做的事情包括:交换协议版本、交换客户端能力、拿到服务端信息、初始化工具列表。源码里这个阶段通常会产生一个初始化完成的结果。那之后客户端和服务端才进入可以正常通信的状态。如果两边的协议版本不一致,握手结果里会明确标记出不匹配。这也是很多新手遇到“工具列表为空”或“调用后无响应”的根源,后面第六节我会专门展开。读客户端源码时,我建议直接从握手方法进入,因为后面所有工具调用都依赖握手成功。4.2 McpToolUtils 的适配逻辑是客户端的核心McpToolUtils.toToolCallbacks(mcpClient, "remote_")这行代码,在我第一次读的时候觉得它像个黑盒。后来拆开看,它做的事情其实非常朴素:调用mcpClient.listTools(),向远端 MCP Server 要一次工具列表。拿到协议层定义的Tool列表。每个Tool对应创建一个ToolCallback。在创建回调时,把工具名和远端调用逻辑绑在一起。也就是说,McpToolUtils本质上是一个适配器,把协议层的“工具描述 + 调用指令”翻译成 Spring-AI 能理解的ToolCallback。当大模型选择这个工具时,ToolCallback的调用实现会重新走回McpClient,发起一个tools/call请求,把参数传给远端 MCP Server。这个设计非常典型:协议层保持通用,框架层做适配。如果你需要在自己的代码里手动构建ToolCallback,理解这个适配思路会很有帮助,因为它不是简单地给一个本地方法套壳,而是把远端调用包装成了本地回调。4.3 工具名前缀为什么重要McpToolUtils.toToolCallbacks(mcpClient, "remote_")里的第二个参数,很多人会忽略。它会在每个远端工具名称前面加一个前缀,比如远端工具叫get_weather,转换后本地工具名就变成remote_get_weather。这个前缀不是随便加着玩的。当一个 Spring-AI 应用同时连接多个 MCP Server 时,不同服务端很可能定义同名工具。如果不加前缀,ToolCallingManager在按工具名匹配时就会冲突。加了前缀后,每个工具在本地都有唯一的名称,模型能准确区分该调用哪个远端服务。从源码角度来说,这个前缀是在构建ToolCallback时直接拼到工具定义里的,后面所有调用匹配都会基于拼完后的名字。所以如果你发现模型一直调用不到某个远端工具,先确认前缀是不是拼对了,这比调试半天序列化问题要有效得多。5. 自动配置源码:starter 如何把 MCP 接进 Spring 容器5.1 自动配置类之间的条件装配如果项目用的是 Spring-AI 的 starter,你会发现即使不写上一节那些配置类,应用也能正常跑起来。这是因为源码里有一套自动配置逻辑,常见的关键类是 MCP Server 自动配置和 MCP Client 自动配置。这些自动配置类大量使用条件装配。比如检测到 classpath 里有McpServer类,才会加载服务端相关配置;检测到配置项里有spring.ai.mcp.client.transport=http,才会创建 HTTP 传输需要的 Bean。这种做法让 starter 在不同项目里表现不同,但源码本身是清晰的两条线:一条是服务端自动配置线,一条是客户端自动配置线。我读自动配置源码时,习惯先把META-INF下面的自动配置注册文件打开,看看哪些类被注册了,再逐个去看@ConditionalOnClass、@ConditionalOnMissingBean这些注解。这里的核心思考是:框架不能假设用户一定用 WebMVC 或 WebFlux,也不能假设用户一定希望自动创建某个 Bean,所以必须留出条件装配的手动覆盖空间。5.2 spring.ai.mcp.* 配置项最终绑定了什么自动配置的背后通常有配置属性类。spring.ai.mcp.*这一组前缀,最终会绑定到若干个Properties类上。我实际使用中最常接触的配置项大概有下面这些:配置项作用spring.ai.mcp.server.transport服务端传输方式,可选 webmvc、webflux、stdio 等spring.ai.mcp.server.name服务端暴露给客户端的名称spring.ai.mcp.client.transport客户端传输方式,比如 http、stdiospring.ai.mcp.client.url客户端连接远端 MCP Server 的地址看源码时,不要把这些配置项当成魔法字段。它们的本质工作就是:从Environment里读出值,绑定到Properties对象,然后在自动配置方法里决定创建什么类型、什么参数的 Bean。当你想自定义传输参数时,直接看对应的Properties类,比看文档更快。5.3 为什么建议先看自动配置类再看核心 Bean我个人的源码阅读顺序是先看自动配置类,再看核心 Bean。因为自动配置类决定了“在什么条件下,项目里会出现什么容器实例”。比如你想知道为什么项目里已经有一个McpServer,但自定义配置却没有生效,大概率是因为自动配置类上有@ConditionalOnMissingBean,看到这个注解你就能推理出:框架只在容器里没有对应 Bean 时才创建默认实现。你要做的是先提供一个自己的McpServerBean,或者排除掉自动配置类,而不是在配置文件里反复试spring.ai.mcp.*。这个道理适用于所有 Spring Boot starter。MCP starter 只是把它应用到了协议工具上。6. 源码浅析过程中最常踩的四个坑6.1 WebMVC 与 WebFlux 传输选错,启动直接 404 或连接拒绝MCP 支持多种传输方式,服务端有 WebMVC、WebFlux,客户端有 HTTP 和 stdio。很多人没有意识到的一点是:如果项目本身是 WebFlux 应用,却使用 WebMVC 传输,MCP 端点可能根本不会被正确注册。这类问题在源码里的表现是自动配置根据你依赖的 Web 框架类型来选择默认传输。比如 classpath 里存在DispatcherHandler,自动配置可能偏 WebFlux;但你手动 new 了一个WebMvcServerTransport,两者不对应,请求就可能 404。我的建议是:服务端传输选型前,先看项目里引入的是哪个 Web 模块。如果确实需要 WebMVC 和 WebFlux 混用,那就要用不同的路径或端口,手动注册对应的传输 Bean,不要依赖默认推断。6.2 协议版本不一致:握手失败而不是调用失败MCP 协议版本是带在握手请求里的。如果客户端配的版本是2025_03_26,服务端用的是另一个版本,很多情况下握手就会失败。但失败方式并不像 HTTP 状态码那样显眼,它常常表现为:工具列表拉不到、调用没有结果、日志里只有一条淡淡的警告。源码里,握手结果会记录双方协议的匹配情况。一旦不匹配,后续很多请求都不会正常分发。排查时不要先怀疑业务代码,先确认两端配置的McpSchema.VERSION是不是同一个值。如果是通过自动配置连的远端服务,再看远端服务端到底支持哪个版本,必要时显式降低本地版本去兼容。6.3 参数类型太复杂时,JSON Schema 生成会出问题@Tool方法的参数会被用来生成 JSON Schema。如果参数是 String、Integer 这类简单类型,生成过程很顺;如果参数是泛型嵌套、复杂类型,或者包含无法序列化的对象,生成过程就可能报错,或者生成出的 schema 模型完全不知道该怎么填参数。我遇到过的典型问题是把 Java 8 的时间类型直接放在@Tool方法参数里,生成的 JSON Schema 不够直观,导致模型传参时给了一个不符合预期的字符串。源码里负责这块的是一个 JSON Schema 生成器,它不会魔法般理解业务类型。建议是让@Tool方法的参数尽量简单,必要时用一个普通的 DTO 类接收,并且在 DTO 字段上加清晰的描述。模型是靠工具描述和参数描述来理解“该传什么”的,这一层做不好,源码再对也没用。6.4 排查时优先看协议帧,而不是猜来猜去MCP 本质上是 JSON-RPC 交互。排查问题时,最好的调试方式就是把协议帧打开,看客户端到底发了什么、服务端到底回了什么。实际操作时,可以在配置里把日志级别调高:logging: level: io.modelcontextprotocol: DEBUG org.springframework.ai.mcp: DEBUG打开后,initialize、tools/list、tools/call这些关键帧都会打印出来。很多“工具没注册上”“调用没反应”的问题,在这个日志里一眼就能定位,不需要反复猜测是自动配置还是传输层的问题。我读源码时有个习惯,遇到问题先把调用栈和协议帧对齐,再去看对应的类。比如看到tools/list返回空数组,就说明要么没收集到ToolCallback,要么服务端的工具注册集合是空的;看到tools/call返回错误,再去看方法反射和参数序列化部分。这样源码阅读不是漫无目的地扫代码,而是带着明确问题去验证。
我最近在把内部的工单查询接口接进大模型对话流程时,第一次真正把 Spring-AI 的 MCP 源码摊开从头读了一遍。MCP 是 Model Context Protocol 的缩写,它解决的问题很直接:让大模型不只有“生成文本”的能力,还能安全地去调用外部工具、查数据库、拉文件、调接口。Spring-AI 把 MCP 客户端和服务端的抽象都做在框架里,我们平时只需要写几个@Tool注解方法,模型就能在对话里自动调用它们。这篇文章算是一篇源码浅析,不是完整的入门教程。适合已经跑通过官方 Demo、但想知道“工具声明、JSON Schema 生成、请求分发、自动配置”这几条链路到底是怎么串起来的人。我会先讲清楚模块边界,再给最小可运行示例,然后分别拆服务端和客户端源码,最后把我在实际调试中踩过的几个坑放出来。我手头的版本是基于 M6 附近的代码,不同小版本的 builder API 可能略有调整,但核心调用链路基本没变。1. Spring-AI 的 MCP 不是一个大模块,而是一组分层的模块1.1 协议 SD K和 Spring AI 桥接包各管一半很多人以为 Spring-AI 的 MCP 源码都写在org.springframework.ai.mcp下面,实际不是。你把依赖树展开就会发现,真正负责协议编解码、JSON-RPC 帧处理、握手协商的代码,在一个独立的 MCP Java SDK 里,包名是io.modelcontextprotocol。Spring-AI 这边只是用org.springframework.ai.mcp起了一套桥接层,把 Spring 的工具抽象和协议 SD K 对接起来。这个拆分是我读源码时最先要接受的现实。如果一直在 Spring-AI 的源码里找JsonRpcMessage或protocolVersion的定义,会找不到,因为它们根本不在 Spring-AI 仓库里。反过来,如果只在协议 SD K 里找@Tool注解和ToolCallback,也找不到,那是 Spring-AI 自己的工具抽象。io.modelcontextprotocol -> 协议层:JsonRpc、握手、工具发现、传输抽象 org.springframework.ai.mcp -> 桥接层:ToolCallback 转换、自动配置、Spring 容器整合 spring-ai-mcp-server -> 服务端模块:让 Spring 应用对外暴露 MCP 服务 spring-ai-mcp-client -> 客户端模块:让 Spring 应用连接远端 MCP 服务为什么要拆成这么多层?因为 MCP 协议本身和 Spring 没有关系,任何 Java 项目都可以用io.modelcontextprotocol这个 SDK 去做一个 MCP 客户端或服务端。Spring-AI 的贡献在于,把这些协议能力包装成 Spring 风格的 Bean,让你在配置类里写一个McpServer,再塞进几个ToolCallback,剩下的启动、握手、注册工具都由框架完成。1.2 先分清两种部署姿态,再看源码就不会乱读 MCP 源码前,必须先分清同一个应用可能存在的两种姿态。第一种是服务端姿态。Spring-AI 应用作为一个 MCP Server 跑起来,外部 MCP Client 过来连接,通过tools/list看到我们暴露的工具,通过tools/call调用我们的@Tool方法。第二种是客户端姿态。Spring-AI 应用作为 MCP Client 去连接外部 MCP Server,把远端服务暴露的工具列表拉回来,转成 Spring-AI 的ToolCallback,然后交给大模型选。大模型决定调用某个工具时,Spring-AI 把请求发出去,通过 MCP Client 转发到远端 Server 执行。这两种姿态对应的核心类分别是McpServer和McpClient。它们虽然都叫 MCP,但一个是处理请求,一个是发起请求。源码里所有看似复杂的逻辑,最后都归于这两个方向。2. 源码阅读前的热身:用两段最小示例确定阅读入口2.1 服务端:让 Spring-AI 应用变成 MCP 服务器先看服务端。一个最小例子通常长这样:@Configuration public class McpServerConfig { @Bean public WebMvcServerTransport mcpTransport() { return new WebMvcServerTransport("/mcp"); } @Bean public McpServer mcpServer(WebMvcServerTransport transport, List<ToolCallback> toolCallbacks) { return McpServer.sync(McpSchema.VERSION_2025_03_26) .transport(transport) .toolCallbacks(toolCallbacks) .build(); } }这里的McpServer.sync是协议 SDK 提供的一个静态入口,transport表示传输方式。WebMvcServerTransport会注册一个 Spring MVC 的 Controller,把/mcp这个 HTTP 端点收到的 JSON-RPC 请求交给McpServer处理。工具集合则是从 Spring 容器里收集到的ToolCallback。与此同时,业务侧只需要写一个普通的 Spring 组件:@Component public class OrderToolService { @Tool(description = "根据订单号查询订单状态", name = "query_order") public String queryOrder(String orderId) { return "订单 " + orderId + " 状态:已发货"; } }@Tool是 Spring-AI 自己的注解。它本身不负责 MCP 通信,只负责描述“这是一个可以被模型使用的工具”。要让 MCP 服务端识别它,还需要有一个地方把它包装成ToolCallback。这一步在很多 starter 版本里已经自动化了,但它背后的原理反而值得单独看。后面第三节我会展开。2.2 客户端:让 Spring-AI 应用接入外部 MCP 服务再看客户端。如果项目要去连接一个外部的 MCP Server,通常是这种写法:@Bean public HttpClientSseClientTransport mcpClientTransport() { return new HttpClientSseClientTransport("http://localhost:8081/sse"); } @Bean public McpClient mcpClient(HttpClientSseClientTransport transport) { return McpClient.sync(McpSchema.VERSION_2025_03_26) .transport(transport) .build(); } @Bean public List<ToolCallback> remoteMcpTools(McpClient mcpClient) { return McpToolUtils.toToolCallbacks(mcpClient, "remote_"); }McpToolUtils是 Spring-AI 桥接层里一个非常关键的类。它把远端 MCP Server 返回的工具定义,转换成本地 Spring-AI 的ToolCallback列表。有了这个列表,大模型调用工具时,Spring-AI 就能像调用本地方法一样,把调用请求转发到远端 MCP Server。这两段示例跑通之后,源码阅读的入口就清楚了:服务端要读McpServer和ToolCallback的映射逻辑,客户端要读McpClient和McpToolUtils的适配逻辑,两头共用的是协议 SDK 的 JSON-RPC 处理部分。3. 服务端源码重点:工具声明、请求分发、方法反射三件事3.1 工具声明阶段:ToolCallback 如何变成 tools/list 里的工具MCP 服务端被 MCP 客户端连接后,第一件重要的事是响应tools/list。这个响应里要告诉对方:我有哪些工具、每个工具叫什么名字、描述是什么、参数长什么样。源码里这一段的源头是 Spring-AI 的ToolCallback接口。ToolCallback里有获取工具定义的方法,返回一个包含name、description、inputSchema的对象。简单理解,@Tool注解上的name和description会被提取到这里,方法的参数列表会被生成一份 JSON Schema。Spring-AI 在处理@Tool方法时,并不是直接拿方法名当工具名,而是通过ToolCallback的工厂方法去构建。我建议你把断点打在tools/list的响应构造处,看一次完整的返回值,能很直观地感受到这个映射结果。它长得很像下面这段 JSON:{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "query_order", "description": "根据订单号查询订单状态", "inputSchema": { "type": "object", "properties": { "orderId": { "type": "string", "description": "订单号" } }, "required": ["orderId"] } } ] } }如果 JSON Schema 生成正确,说明ToolCallback到 MCP 工具的声明阶段没问题。接下来的焦点就是调用阶段。3.2 调用阶段:tools/call 请求如何命中 @Tool 方法当大模型决定调用某个工具时,MCP 客户端会发来一个tools/call请求,请求体里有工具名和参数。服务端收到后,源码里要走这么一条路径:WebMvcServerTransport -> McpServer 内的请求分发器 -> ToolCallingManager -> 根据工具名找到对应 ToolCallback -> 解析参数 -> 反射调用 @Tool 方法 -> 包装返回结果ToolCallingManager是 Spring-AI 执行工具调用的统一入口。它的职责很简单:你给我一个工具调用请求,我根据工具名找到对应的执行器,然后把参数装填好,执行业务方法,最后把结果封装成 MCP 能识别的文本内容。有一点要注意:@Tool方法并不一定就是被 MCP 请求直接触发的。它先被包装成ToolCallback,然后注册到ToolCallingManager,MCP Server 只是这个调用链路的入口之一。换句话说,同一个ToolCallback,既能被 MCP 请求触发,也能被本地其他逻辑显式调用。理解这一点,就不会把“MCP Server”和“工具执行器”混为一谈。返回结果到 MCP 客户端时,通常会包装成一个content数组。这也是我最初不太习惯的地方。普通 Java 方法返回一个字符串就够了,但 MCP 的返回模型要求是结构化内容:{ "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "订单 A1001 状态:已发货" } ], "isError": false } }3.3 源码阅读视角:协议层和桥接层要来回切换读服务端源码时,最大的麻烦是经常在协议 SDK 和 Spring-AI 桥接代码之间跳来跳去。我自己的经验是,凡是看到McpSchema、McpAsyncServer、JsonRpcMessage,先默认它们是协议 SDK 的;凡是看到ToolCallback、ToolCallingManager、McpToolUtils,先默认它们是 Spring-AI 的。有了这个视角,代码层次会清晰很多。MCP Server 收到一个协议帧后,只会做非常有限的拆解,把请求转成内部执行请求;真正的工具发现、参数生成、方法反射,全都在 Spring-AI 这一侧完成。反过来也一样,McpServer 并不关心你的@Tool方法里是查数据库还是调外部接口,它只负责把框架交给它的工具执行器暴露出去。4. 客户端源码重点:远端工具是怎么变成本地 ToolCallback 的4.1 初始化握手里发生了什么相比服务端,客户端源码更容易被忽略。其实McpClient.sync(...).build()这行代码并不只是创建对象,它会立刻触发一次 MCP 握手。握手阶段做的事情包括:交换协议版本、交换客户端能力、拿到服务端信息、初始化工具列表。源码里这个阶段通常会产生一个初始化完成的结果。那之后客户端和服务端才进入可以正常通信的状态。如果两边的协议版本不一致,握手结果里会明确标记出不匹配。这也是很多新手遇到“工具列表为空”或“调用后无响应”的根源,后面第六节我会专门展开。读客户端源码时,我建议直接从握手方法进入,因为后面所有工具调用都依赖握手成功。4.2 McpToolUtils 的适配逻辑是客户端的核心McpToolUtils.toToolCallbacks(mcpClient, "remote_")这行代码,在我第一次读的时候觉得它像个黑盒。后来拆开看,它做的事情其实非常朴素:调用mcpClient.listTools(),向远端 MCP Server 要一次工具列表。拿到协议层定义的Tool列表。每个Tool对应创建一个ToolCallback。在创建回调时,把工具名和远端调用逻辑绑在一起。也就是说,McpToolUtils本质上是一个适配器,把协议层的“工具描述 + 调用指令”翻译成 Spring-AI 能理解的ToolCallback。当大模型选择这个工具时,ToolCallback的调用实现会重新走回McpClient,发起一个tools/call请求,把参数传给远端 MCP Server。这个设计非常典型:协议层保持通用,框架层做适配。如果你需要在自己的代码里手动构建ToolCallback,理解这个适配思路会很有帮助,因为它不是简单地给一个本地方法套壳,而是把远端调用包装成了本地回调。4.3 工具名前缀为什么重要McpToolUtils.toToolCallbacks(mcpClient, "remote_")里的第二个参数,很多人会忽略。它会在每个远端工具名称前面加一个前缀,比如远端工具叫get_weather,转换后本地工具名就变成remote_get_weather。这个前缀不是随便加着玩的。当一个 Spring-AI 应用同时连接多个 MCP Server 时,不同服务端很可能定义同名工具。如果不加前缀,ToolCallingManager在按工具名匹配时就会冲突。加了前缀后,每个工具在本地都有唯一的名称,模型能准确区分该调用哪个远端服务。从源码角度来说,这个前缀是在构建ToolCallback时直接拼到工具定义里的,后面所有调用匹配都会基于拼完后的名字。所以如果你发现模型一直调用不到某个远端工具,先确认前缀是不是拼对了,这比调试半天序列化问题要有效得多。5. 自动配置源码:starter 如何把 MCP 接进 Spring 容器5.1 自动配置类之间的条件装配如果项目用的是 Spring-AI 的 starter,你会发现即使不写上一节那些配置类,应用也能正常跑起来。这是因为源码里有一套自动配置逻辑,常见的关键类是 MCP Server 自动配置和 MCP Client 自动配置。这些自动配置类大量使用条件装配。比如检测到 classpath 里有McpServer类,才会加载服务端相关配置;检测到配置项里有spring.ai.mcp.client.transport=http,才会创建 HTTP 传输需要的 Bean。这种做法让 starter 在不同项目里表现不同,但源码本身是清晰的两条线:一条是服务端自动配置线,一条是客户端自动配置线。我读自动配置源码时,习惯先把META-INF下面的自动配置注册文件打开,看看哪些类被注册了,再逐个去看@ConditionalOnClass、@ConditionalOnMissingBean这些注解。这里的核心思考是:框架不能假设用户一定用 WebMVC 或 WebFlux,也不能假设用户一定希望自动创建某个 Bean,所以必须留出条件装配的手动覆盖空间。5.2 spring.ai.mcp.* 配置项最终绑定了什么自动配置的背后通常有配置属性类。spring.ai.mcp.*这一组前缀,最终会绑定到若干个Properties类上。我实际使用中最常接触的配置项大概有下面这些:配置项作用spring.ai.mcp.server.transport服务端传输方式,可选 webmvc、webflux、stdio 等spring.ai.mcp.server.name服务端暴露给客户端的名称spring.ai.mcp.client.transport客户端传输方式,比如 http、stdiospring.ai.mcp.client.url客户端连接远端 MCP Server 的地址看源码时,不要把这些配置项当成魔法字段。它们的本质工作就是:从Environment里读出值,绑定到Properties对象,然后在自动配置方法里决定创建什么类型、什么参数的 Bean。当你想自定义传输参数时,直接看对应的Properties类,比看文档更快。5.3 为什么建议先看自动配置类再看核心 Bean我个人的源码阅读顺序是先看自动配置类,再看核心 Bean。因为自动配置类决定了“在什么条件下,项目里会出现什么容器实例”。比如你想知道为什么项目里已经有一个McpServer,但自定义配置却没有生效,大概率是因为自动配置类上有@ConditionalOnMissingBean,看到这个注解你就能推理出:框架只在容器里没有对应 Bean 时才创建默认实现。你要做的是先提供一个自己的McpServerBean,或者排除掉自动配置类,而不是在配置文件里反复试spring.ai.mcp.*。这个道理适用于所有 Spring Boot starter。MCP starter 只是把它应用到了协议工具上。6. 源码浅析过程中最常踩的四个坑6.1 WebMVC 与 WebFlux 传输选错,启动直接 404 或连接拒绝MCP 支持多种传输方式,服务端有 WebMVC、WebFlux,客户端有 HTTP 和 stdio。很多人没有意识到的一点是:如果项目本身是 WebFlux 应用,却使用 WebMVC 传输,MCP 端点可能根本不会被正确注册。这类问题在源码里的表现是自动配置根据你依赖的 Web 框架类型来选择默认传输。比如 classpath 里存在DispatcherHandler,自动配置可能偏 WebFlux;但你手动 new 了一个WebMvcServerTransport,两者不对应,请求就可能 404。我的建议是:服务端传输选型前,先看项目里引入的是哪个 Web 模块。如果确实需要 WebMVC 和 WebFlux 混用,那就要用不同的路径或端口,手动注册对应的传输 Bean,不要依赖默认推断。6.2 协议版本不一致:握手失败而不是调用失败MCP 协议版本是带在握手请求里的。如果客户端配的版本是2025_03_26,服务端用的是另一个版本,很多情况下握手就会失败。但失败方式并不像 HTTP 状态码那样显眼,它常常表现为:工具列表拉不到、调用没有结果、日志里只有一条淡淡的警告。源码里,握手结果会记录双方协议的匹配情况。一旦不匹配,后续很多请求都不会正常分发。排查时不要先怀疑业务代码,先确认两端配置的McpSchema.VERSION是不是同一个值。如果是通过自动配置连的远端服务,再看远端服务端到底支持哪个版本,必要时显式降低本地版本去兼容。6.3 参数类型太复杂时,JSON Schema 生成会出问题@Tool方法的参数会被用来生成 JSON Schema。如果参数是 String、Integer 这类简单类型,生成过程很顺;如果参数是泛型嵌套、复杂类型,或者包含无法序列化的对象,生成过程就可能报错,或者生成出的 schema 模型完全不知道该怎么填参数。我遇到过的典型问题是把 Java 8 的时间类型直接放在@Tool方法参数里,生成的 JSON Schema 不够直观,导致模型传参时给了一个不符合预期的字符串。源码里负责这块的是一个 JSON Schema 生成器,它不会魔法般理解业务类型。建议是让@Tool方法的参数尽量简单,必要时用一个普通的 DTO 类接收,并且在 DTO 字段上加清晰的描述。模型是靠工具描述和参数描述来理解“该传什么”的,这一层做不好,源码再对也没用。6.4 排查时优先看协议帧,而不是猜来猜去MCP 本质上是 JSON-RPC 交互。排查问题时,最好的调试方式就是把协议帧打开,看客户端到底发了什么、服务端到底回了什么。实际操作时,可以在配置里把日志级别调高:logging: level: io.modelcontextprotocol: DEBUG org.springframework.ai.mcp: DEBUG打开后,initialize、tools/list、tools/call这些关键帧都会打印出来。很多“工具没注册上”“调用没反应”的问题,在这个日志里一眼就能定位,不需要反复猜测是自动配置还是传输层的问题。我读源码时有个习惯,遇到问题先把调用栈和协议帧对齐,再去看对应的类。比如看到tools/list返回空数组,就说明要么没收集到ToolCallback,要么服务端的工具注册集合是空的;看到tools/call返回错误,再去看方法反射和参数序列化部分。这样源码阅读不是漫无目的地扫代码,而是带着明确问题去验证。