1. 为什么我要把Spring Boot服务塞进AI的工具箱
第一次听到MCP这个词,是在一个做智能客服的朋友那里。他当时吐槽说,公司花了大半年搭了一套订单查询、库存管理、售后工单的Spring Boot微服务,结果接入AI助手的时候,全部要重写一遍——每个AI平台都有自己的插件规范,OpenAI一套、Claude一套、国内几家大模型又是各自一套。业务逻辑明明已经写好了,却要为了"让AI能调用"再写三遍胶水代码。
这个痛点我相信很多后端同学都遇到过。我们手里的Spring Boot服务,本质上就是一堆HTTP接口加上业务逻辑,它们已经稳定运行了很久,被前端、被其他服务、被定时任务调用着。现在AI来了,老板说"让AI也能查订单、也能下单、也能查库存",你怎么办?
最笨的办法是给每个AI平台写一个适配层,把HTTP接口包装成那个平台要求的格式。这个办法能跑通,但维护成本极高,平台一升级你就得跟着改。稍微聪明一点的办法是搞一个统一的中间层,所有AI平台都对接这个中间层,中间层再去调Spring Boot。但中间层本身还是要定义一套协议,你还是得为每个平台写适配。
MCP(Model Context Protocol)的出现,本质上是想解决这个"每个AI平台各玩各的"的问题。它定义了一套标准协议,让工具提供方(也就是我们的Spring Boot服务)只需要实现一次,所有支持MCP的AI应用都能直接调用。你可以把它理解成"AI工具界的USB-C"——以前每个设备一个充电口,现在统一了。
这篇文章我想聊的,就是怎么把一个已经存在的Spring Boot服务,从零开始改造成一个MCP Server,让Claude Desktop、各种支持MCP的IDE、以及未来任何兼容MCP的AI应用,都能把它当成自己的工具来用。我会从协议原理讲起,然后一步步带你搭出一个能跑通的MCP Server,把Spring Boot里已有的Service方法暴露成AI可调用的工具,最后聊聊我在实操中踩过的坑和排查思路。
适合谁看?如果你是一个Java后端,手里有现成的Spring Boot项目,想让AI能调用你的业务能力,这篇就是写给你的。如果你只是想了解MCP是什么,前半部分也能帮你建立完整的认知。不需要你有AI算法背景,但需要你懂Spring Boot的基本用法,知道什么是Bean、什么是Controller。
2. MCP到底是个什么东西,为什么值得后端关注
2.1 用一句话说清MCP的角色定位
MCP的全称是Model Context Protocol,翻译过来叫"模型上下文协议"。这个名字听起来很学术,但它的作用其实很朴素:它规定了AI应用(Host)和外部工具(Server)之间怎么对话。
在MCP的世界里,有三个角色:
- Host(宿主):就是AI应用本身,比如某个桌面AI助手、某个IDE插件、某个聊天客户端。它负责跟用户交互,也负责决定什么时候调用哪个工具。
- Client(客户端):Host内部的一个组件,负责跟Server建立连接、发送请求、接收响应。通常Host会为每个Server创建一个Client。
- Server(服务端):就是我们今天要做的这个东西。它对外暴露一组"能力",等待Client来调用。
关键点在于:Host和Server之间是解耦的。Host不需要知道Server是用Java写的还是Python写的,不需要知道Server背后是Spring Boot还是Node.js,它只需要按照MCP协议发请求就行。Server也不需要知道Host是哪个AI应用,它只需要按照MCP协议响应就行。
这就是为什么我说它值得后端关注。以前你要对接N个AI平台,现在你只需要实现一次MCP Server,理论上就能被所有支持MCP的Host调用。虽然目前支持MCP的Host还在增长中,但方向是明确的。
2.2 MCP Server能暴露哪三种能力
MCP协议里,Server可以暴露三种类型的能力,理解这三者的区别非常重要,因为它决定了你的Spring Boot服务该怎么映射。
第一种是Tools(工具)。这是最常用的一种。Tool本质上就是一个"可以被AI调用的函数"。你定义一个Tool,给它起个名字,描述它是干什么的,定义它需要哪些参数,然后AI在需要的时候就会调用它。比如你有一个"查询订单"的Tool,AI在用户问"我的订单到哪了"的时候,就会调用这个Tool,把订单号作为参数传进来。
第二种是Resources(资源)。Resource更像是"可以被AI读取的数据"。它不像Tool那样有副作用,主要是提供上下文信息。比如你可以把一个配置文件、一段文档、一个数据库表的schema作为Resource暴露出去,AI在需要的时候可以读取它来增强自己的理解。
第三种是Prompts(提示模板)。这是Server预定义的一些提示词模板,Host可以拿来直接用。比如你可以定义一个"代码审查"的提示模板,里面写好了审查的步骤和关注点,Host调用这个模板就能得到一个结构化的提示。
对于大多数想把Spring Boot服务接入AI的场景,Tools是核心。因为我们的业务方法本质上就是"输入参数、执行逻辑、返回结果",这跟Tool的模型天然契合。Resources和Prompts属于锦上添花,后面我会简单提一下怎么用,但重点放在Tools上。
2.3 为什么是Spring Boot,而不是别的
有人可能会问,MCP Server用Python写不是更常见吗?确实,官方SDK最早是TypeScript和Python的,社区示例也大多是这两种语言。但Java生态做MCP Server有它独特的优势,尤其是当你已经有一个成熟的Spring Boot项目时。
第一,复用已有业务逻辑。你的订单服务、库存服务、用户服务都已经在Spring容器里了,有依赖注入、有事务管理、有缓存、有各种AOP切面。如果换成Python重写,这些都要重新搞一遍,而且两套代码容易不一致。
第二,Spring Boot的工程化能力。配置管理、日志、监控、健康检查、优雅停机,这些Spring Boot开箱即用的东西,对于一个要长期运行的服务来说非常重要。MCP Server不是玩具,它是要上生产的。
第三,Java MCP SDK已经可用。社区有专门的Java SDK,支持Spring Boot集成,可以通过注解的方式声明Tool,跟Spring MVC的Controller写法非常像。这意味着学习成本很低,你几乎可以用写Controller的经验来写MCP Tool。
所以结论是:如果你已经有Spring Boot服务,用Java做MCP Server是最自然的选择。如果你是从零开始,且团队是Java背景,那也建议用Java,避免技术栈分裂。
3. 动手之前:环境准备与依赖选型
3.1 版本选择与依赖引入
在开始写代码之前,先把环境理清楚。我实测下来比较稳的组合是:JDK 17或21、Spring Boot 3.2.x以上、MCP Java SDK用社区维护的那个spring-ai-mcp或者独立的mcp-java-sdk。
这里有个坑要提前说:MCP的Java SDK目前迭代比较快,不同版本之间的API可能有变化。我建议你在动手前先去项目的release页面看一眼最新稳定版,不要直接抄网上半年前的教程,否则很可能编译不过。
依赖引入大概是这样,以Maven为例:
<dependency> <groupId>io.modelcontextprotocol</groupId> <artifactId>mcp-spring-boot-starter</artifactId> <version>你查到的稳定版本</version> </dependency>如果你用的是Gradle,对应换成implementation即可。引入之后,Spring Boot的自动配置会帮你把MCP Server的基础设施搭起来,你只需要关注怎么定义Tool。
注意:MCP Server有两种传输方式,一种是stdio(标准输入输出),一种是SSE(Server-Sent Events,基于HTTP)。stdio适合本地桌面应用,SSE适合远程服务。选哪种取决于你的Host怎么连你。桌面AI助手通常用stdio,远程调用用SSE。这个选择会影响你的配置,后面会细说。
3.2 传输方式的选择逻辑
这是新手最容易懵的地方,我单独拎出来讲。
stdio模式的意思是,Host会启动你的MCP Server进程,然后通过标准输入输出跟它通信。你的Server就像一个命令行程序,读一行请求,写一行响应。这种模式的好处是简单、无需网络配置、天然隔离;坏处是Server必须跟Host在同一台机器上,而且每次Host启动都要拉起你的进程。
SSE模式的意思是,你的MCP Server是一个独立的HTTP服务,Host通过HTTP连过来。好处是可以远程部署、可以被多个Host共享、可以独立扩缩容;坏处是要处理网络、认证、跨域这些问题。
对于"把已有Spring Boot服务变成MCP Server"这个场景,我的建议是:如果你的Spring Boot服务本来就部署在服务器上,用SSE。因为你的服务本来就是个Web服务,加一个SSE端点是最自然的。如果你只是想在本地桌面AI助手里用一下,那stdio更省事。
我下面主要按SSE模式来讲,因为这是生产环境的主流选择,stdio的差异我会在关键处点出来。
3.3 项目结构规划
在动手改代码之前,先想清楚代码放哪。我的建议是新建一个独立的模块或者包,比如mcp包,专门放MCP相关的东西,不要跟原有的Controller混在一起。
原因有两个:一是MCP Tool的定义方式跟REST Controller不一样,混在一起容易乱;二是MCP Server可能需要独立的配置(比如是否启用、暴露哪些Tool),独立包便于管理。
一个典型的包结构大概是这样:
com.example.demo ├── controller // 原有的REST接口 ├── service // 原有的业务逻辑 ├── mcp │ ├── config // MCP相关配置 │ ├── tool // Tool定义 │ └── model // Tool的入参出参模型 └── DemoApplication // 启动类这样组织的好处是,MCP这一层是"薄"的,它只负责把请求转成对Service的调用,业务逻辑还是留在Service里。这符合我们"复用已有逻辑"的初衷。
4. 核心实操:把Service方法变成AI可调用的Tool
4.1 第一个Tool:从最简单的查询开始
我们先从一个最简单的例子入手:假设你有一个OrderService,里面有个方法叫getOrderStatus(String orderId),返回订单状态。我们要把它暴露成一个MCP Tool。
用注解的方式写,大概长这样:
@Component public class OrderTool { private final OrderService orderService; public OrderTool(OrderService orderService) { this.orderService = orderService; } @Tool(name = "query_order_status", description = "根据订单号查询订单的当前状态,返回状态描述和更新时间") public String queryOrderStatus( @ToolParam(description = "订单号,通常是18位数字") String orderId) { OrderStatus status = orderService.getOrderStatus(orderId); return String.format("订单%s当前状态:%s,更新时间:%s", orderId, status.getDesc(), status.getUpdateTime()); } }就这么几行,一个Tool就定义好了。@Tool注解标记这是一个可被AI调用的工具,name是工具名(AI看到的就是这个名字),description是给AI看的说明,@ToolParam描述参数。
这里有几个细节值得展开说。
第一,description的写法极其重要。AI是根据description来判断"什么时候该调用这个工具"的。你写"查询订单状态",AI就知道用户问订单相关的问题时该用它。你如果写"order query",AI可能就懵了。description要写得像给一个新同事解释这个函数干什么一样,清楚、具体、包含关键信息。
第二,参数描述也要认真写。@ToolParam里的description会告诉AI这个参数是什么格式。比如你写"订单号,通常是18位数字",AI在用户说"帮我查一下订单"但没给订单号时,就知道要追问订单号,而且知道格式是18位数字。
第三,返回值用String最省事,但不是最优。上面我直接返回了一个格式化好的字符串。这样AI能读懂,但结构化程度低。更好的做法是返回一个对象,让SDK帮你序列化成JSON。后面我会讲怎么返回结构化数据。
4.2 参数映射:AI传进来的参数怎么对上Java类型
MCP协议里,参数是通过JSON传递的。AI会生成一个JSON对象,里面是参数名到值的映射。SDK负责把这个JSON反序列化成你方法签名里的Java类型。
这里有几个映射规则你需要知道:
- 基本类型:String、int、long、double、boolean都能直接映射。但要注意,AI有时候会把数字传成字符串,比如把
123传成"123"。SDK通常会做兼容处理,但你不能假设它一定处理得好。稳妥的做法是参数用包装类型,然后在方法里做校验。 - 复杂对象:你可以定义一个POJO作为参数,SDK会把JSON反序列化成这个POJO。比如查询订单列表,参数可能是
{status: "PAID", page: 1, size: 10},你可以定义一个OrderQuery类来接。 - 可选参数:如果某个参数不是必填的,用
Optional<T>或者给个默认值。但要注意,AI不一定能理解"可选"的含义,description里要写清楚"不传则默认XXX"。 - 枚举:枚举类型也能映射,但AI可能传一个不在枚举范围内的值。建议在方法里做校验,或者干脆用String接收然后自己转换。
我踩过的一个坑是:参数名。Java编译后参数名默认会丢失(变成arg0、arg1),如果你没开-parameters编译选项,SDK可能拿不到正确的参数名,导致映射失败。解决办法是在Maven的compiler插件里加上<parameters>true</parameters>,或者在IDE里开启"存储方法参数名"。
4.3 返回值设计:让AI真正读懂你的结果
前面说返回值用String最省事,但实际项目里我强烈建议返回结构化对象。原因很简单:AI拿到结构化JSON后,能更准确地提取信息、做后续推理。
比如查询订单,返回一个OrderStatus对象:
public class OrderStatus { private String orderId; private String status; // PAID, SHIPPED, DELIVERED private String statusDesc; // 已支付、已发货、已送达 private LocalDateTime updateTime; private String logisticsNo; // 物流单号,可能为空 // getter/setter省略 }SDK会把它序列化成JSON返回给AI。AI看到status: "SHIPPED"和logisticsNo: "SF123456",就能自然地回答用户"您的订单已发货,物流单号是SF123456"。
但这里有个细节:时间类型要处理好。LocalDateTime默认序列化出来可能是数组或者ISO格式,AI不一定能读懂。建议统一格式化成字符串,比如yyyy-MM-dd HH:mm:ss。可以在字段上加@JsonFormat注解,或者在返回前手动格式化。
还有一个经验:返回结果不要太长。AI的上下文窗口是有限的,如果你返回一个包含几百条记录的列表,会挤占其他信息。对于列表类查询,一定要分页,并且在description里告诉AI"默认返回前10条,可以用page参数翻页"。
4.4 多个Tool的组织与命名规范
一个Spring Boot服务通常有很多业务方法,你不可能把所有方法都暴露成Tool。这里要有取舍。
我的原则是:只暴露AI真正需要的、语义清晰的、无副作用的或副作用可控的方法。
- 查询类方法(查订单、查库存、查用户)优先暴露,因为AI最常做的是信息检索。
- 创建类方法(下单、创建工单)要谨慎,因为AI可能会误调用。如果一定要暴露,description里要写清楚"此操作会创建真实订单,请确认用户意图后再调用"。
- 删除类方法(取消订单、删除用户)最好别暴露,或者加严格的确认机制。
命名上,我建议用"动词_名词"的格式,全小写,下划线分隔。比如query_order_status、create_ticket、list_inventory。这样AI容易理解,也不容易跟其他Tool混淆。
另外,Tool的name要全局唯一。如果你有多个Tool类,注意别重名。SDK在启动时会检查,重名会报错。
5. 进阶玩法:Resources、Prompts与SSE部署
5.1 用Resource暴露配置和文档
Tool是"AI主动调用",Resource是"AI按需读取"。有些信息不适合做成Tool,比如一份产品文档、一个配置表、一段数据库schema,这些更适合做成Resource。
Resource的定义方式跟Tool类似,也是注解:
@Component public class ConfigResource { @Resource(uri = "config://app/settings", name = "应用配置", description = "当前应用的配置项,包括超时时间、重试次数等") public String getSettings() { return "timeout=3000\nretry=3\n..."; } }Resource的uri是一个自定义的标识,Host可以通过这个uri来读取。注意Resource是只读的,不要在里面做写操作。
实际用下来,Resource的场景比Tool少很多。大多数时候,AI需要的信息都可以通过Tool动态获取。Resource更适合那些"静态但重要"的上下文,比如系统提示词、领域知识库。
5.2 用Prompt预定义常用任务模板
Prompt是Server预定义的提示模板。比如你可以定义一个"生成周报"的模板,里面写好了周报的结构和要求,Host调用这个模板就能得到一个完整的提示词。
@Component public class ReportPrompt { @Prompt(name = "weekly_report", description = "根据本周的工作记录生成周报") public String weeklyReport( @PromptParam(name = "records", description = "本周工作记录") String records) { return "请根据以下工作记录生成一份周报,包含本周完成、下周计划、风险三个部分:\n" + records; } }Prompt的价值在于"标准化"。如果你们团队有一套固定的分析流程,把它做成Prompt,所有人调用出来的结果格式都一致。
5.3 SSE模式的配置与部署要点
前面提到,生产环境建议用SSE模式。配置上,你需要在application.yml里指定传输方式:
mcp: server: transport: sse sse: endpoint: /mcp/sse message-endpoint: /mcp/message启动后,你的MCP Server就会在/mcp/sse这个路径上监听SSE连接。Host连上来之后,会先建立一个SSE长连接,然后通过/mcp/message这个端点发送请求。
部署时有几个点要注意:
- 认证:MCP协议本身不规定认证方式,但生产环境一定要加。可以在SSE端点前面加一个Filter,校验API Key或者Token。Host那边配置对应的凭证。
- 超时:SSE是长连接,要注意网关和负载均衡的超时设置。如果超时太短,连接会被断开。建议把超时设长一点,或者配置心跳。
- 并发:一个Host可能同时发起多个Tool调用,你的Server要能处理并发。Spring Boot默认的线程池通常够用,但如果Tool里有耗时操作,要注意别把线程占满。
- 日志:MCP的请求响应要记日志,方便排查问题。但要注意别把敏感数据(比如用户手机号)记进去。
6. 踩坑实录:那些文档里不会写的问题
6.1 Tool调用失败但没有任何报错
这是我遇到的第一个坑,也是最让人抓狂的。现象是:AI那边显示调用了Tool,但你的Server没有任何日志,方法也没执行。
排查下来,原因通常是参数映射失败。AI传过来的JSON跟你的方法签名对不上,SDK在反序列化时抛了异常,但这个异常被吞掉了,没有打到日志里。
解决办法:把MCP SDK的日志级别调到DEBUG,看它到底收到了什么请求。然后在Tool方法的第一行加日志,确认方法有没有被进入。如果方法没进,那就是映射问题;如果进了但结果不对,那就是业务逻辑问题。
还有一个隐蔽的原因:Tool的name大小写。AI调用时用的name必须跟你@Tool(name=...)里写的完全一致,包括大小写。如果AI那边配置的时候写错了,就会调用失败。
6.2 AI总是调用错误的Tool
有时候你会发现,用户问"查订单",AI却调用了"查库存"的Tool。这通常是description写得不够清晰导致的。
AI选择Tool的依据是description的语义匹配。如果两个Tool的description太相似,AI就会混淆。解决办法是让description更有区分度。比如"查订单"写成"根据订单号查询订单的物流状态和支付状态","查库存"写成"根据商品SKU查询仓库中的可用库存数量",这样AI就不容易搞混。
另一个技巧是在description里加"使用场景"。比如"当用户询问订单进度、物流信息时使用此工具",这样AI的匹配会更准。
6.3 返回值太大导致AI响应变慢
前面提过,返回值太大会挤占上下文。我实测过一个案例:一个查询Tool返回了200条记录,每条记录有20个字段,结果AI处理这个响应花了十几秒,而且后续回答质量明显下降。
解决办法是分页+字段裁剪。分页就是限制单次返回的数量,字段裁剪就是只返回AI真正需要的字段。比如订单列表,AI可能只需要订单号、状态、金额,不需要创建人、修改时间这些内部字段。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Tool调用无响应 | 参数映射失败 | 开DEBUG日志,检查参数名和类型 |
| AI调用错误Tool | description区分度低 | 优化description,加使用场景 |
| 响应慢 | 返回值过大 | 分页、裁剪字段 |
| 连接断开 | SSE超时 | 调整网关超时,加心跳 |
| 启动报错 | Tool重名 | 检查所有@Tool的name唯一性 |
| 参数为null | 编译未保留参数名 | 开启-parameters编译选项 |
6.5 几个我总结的实操心得
心得一:先跑通一个最简单的Tool,再扩展。不要一上来就把所有Service方法都暴露出去。先写一个查询Tool,确认整条链路通了,再逐步加。这样出问题容易定位。
心得二:Tool的粒度要适中。太细的话,AI要调用很多次才能完成一个任务;太粗的话,参数复杂、复用性差。我的经验是,一个Tool对应一个"用户能理解的完整操作",比如"查询订单状态"是一个Tool,"查询订单列表"是另一个Tool。
心得三:给Tool加幂等性。AI可能会因为重试而重复调用同一个Tool。如果是查询类,无所谓;如果是创建类,要保证幂等,比如用订单号做去重。
心得四:本地调试用stdio,上线用SSE。stdio模式下,你可以直接用命令行工具测试,不需要起完整的Web服务,调试效率高很多。
7. 这套方案能走多远:扩展思路与边界
把Spring Boot服务变成MCP Server,本质上是在做一件事:让已有的业务能力,以AI能理解的方式重新暴露一遍。这个思路可以扩展到很多场景。
比如你有一个内部的权限系统,可以暴露一个"查询用户权限"的Tool,AI在回答"我能不能访问某个资源"时就能调用。你有一个监控系统,可以暴露"查询服务健康状态"的Tool,AI在运维场景下就能用。你有一个知识库,可以暴露"搜索文档"的Tool,AI在回答专业问题时就能检索。
但也要清楚边界。MCP不是万能的,它解决的是"AI怎么调用工具"的问题,不解决"AI怎么理解业务"的问题。Tool的description写得再好,AI也可能误解。所以对于高风险操作,一定要有人工确认环节,不能完全交给AI。
另外,MCP生态还在早期,不同Host对协议的支持程度不一样。有的Host支持Tools但不支持Resources,有的对SSE的支持还不完善。所以在选型时,要先确认你的目标Host支持哪些能力。
最后分享一个小技巧:如果你想让AI更好地使用你的Tool,可以在Tool的description里加一些"示例"。比如"例如:用户说'我的订单到哪了',应该调用此工具,参数为订单号"。这样AI的调用准确率会明显提升。这个技巧是我在实际项目中反复验证过的,比单纯写功能描述有效得多。