
我最近在把内部的工单查询接口接进大模型对话流程时第一次真正把 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, ListToolCallback 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 ListToolCallback 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然后注册到ToolCallingManagerMCP 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.transporthttp才会创建 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返回错误再去看方法反射和参数序列化部分。这样源码阅读不是漫无目的地扫代码而是带着明确问题去验证。