MCP 这几个字母,最近在 Java 圈子里出现得越来越频繁。Model Context Protocol,说白了就是给大模型开一扇调用外部业务系统的门——AI 不光能聊天,还能替我查订单、查库存、走审批流。过去做这件事,得按不同模型平台分别写 function calling 的适配层,参数格式、调用约定各不一样,维护起来相当头疼。而 MCP 把这一切标准化了,Java 服务只需要把自己的方法暴露成“工具”,任何支持 MCP 的客户端都能直接调用。我接触这套 SDK 之后最大的感受是:写一个 MCP Server,跟写一个 Spring MVC Controller 几乎是一回事——注解声明、参数绑定、返回 JSON,老一套的 Web 开发经验完全能迁移过来。这篇文章就围绕这个思路展开,顺便重点聊聊很多老项目卡了很久的 Java 8 兼容问题。适合正打算把现有业务能力开放给 AI 的 Java 开发者,也适合还在 Java 8 上挣扎、不敢随便升级的团队参考。
1. 先想明白:MCP 到底解决了什么问题
1.1 没有 MCP 之前,AI 集成有多痛苦
接触 MCP 之前,我团队里有个典型的场景:让大模型根据用户一句话去查订单。听起来简单,做起来全是活。首先你要给每个模型平台写一套 JSON Schema,描述这个“查订单函数”的参数;然后要在后端接收模型返回的 tool_calls 调用请求,自己做分发;最后还要把结果按平台要求的格式塞回去。最难受的是,不同平台的参数命名规范、调用方式、返回格式都不一样,接 A 平台一套代码,接 B 平台又得重写一套。
这还只是协议层的差异。业务侧更麻烦:参数描述散落在各种配置文件和文档里,代码里改了个字段名,文档忘了同步,模型就开始瞎猜参数;调试的时候,你根本不知道模型到底有没有理解这个函数的用途。本质上,问题出在“工具能力描述”和“业务代码实现”被硬生生拆开了。MCP 的做法是把这套东西标准化,让工具定义跟着代码走,注解写在哪里,协议能力就生成到哪里。
1.2 工具、资源、提示词:MCP 的三大能力
MCP 协议定义了三种给 AI 使用的能力。第一是工具(Tools),也就是可以被模型显式调用的函数,这是大家用得最多的能力;第二是资源(Resources),相当于只读的数据源,模型可以按 URI 去读取上下文,比如读取某个文件、查询某段文档;第三是提示词(Prompts),相当于预置好的提示模板,把一些常用的多轮交互套路固化下来。
对大多数团队来说,最值得先做的是工具。因为业务系统里真正有价值的,是一堆有副作用的操作和实时查询,这些恰恰是模型自己没法凭空生成的。工具的定义天然贴合“方法”这个概念,一个 Java 方法,加上描述、参数说明,就能变成一个 AI 可调用的工具。这也是为什么我坚持认为 Java 开发者上手 MCP 的门槛,比想象中低得多。
1.3 为什么 Java 团队应该认真考虑 MCP
我经常看到一种误区:AI 相关的基建都是 Python 和 Node 的天下,Java 是“传统行业”才用的东西。但实际碰一碰就知道,真正掌握核心业务数据、订单、库存、财务逻辑的系统,九成都跑在 Java 上。你不是要新写一个 AI 应用,而是要把已有的业务能力开放给 AI。
用 Java 写 MCP Server 有一个天然优势:可以直接复用企业内部的服务层、权限体系、监控埋点,不用把逻辑搬到另一个语言里重写。再加上 Java 生态里成熟的依赖管理、测试框架和运维体系,做出来的 MCP 服务更容易融入现有的发布流程。我第一次把内部订单服务包装成 MCP 工具时,前后只花了半天,剩下的时间全在研究怎么让描述写得更准确,而不是在处理语言差异。
2. 把 MCP Server 当成 Controller 来设计
2.1 注解驱动:从 @RestController 到 @McpTool
写 Spring MVC 的时候,你只需要在一个类上标 @RestController,再在方法上标 @GetMapping,框架就会自动帮你处理 HTTP 请求的接收、参数绑定、JSON 序列化和响应返回。你从来不用自己去解析 HTTP 报文。MCP 的 Java SDK 走的是同一条路:类上标 @McpTool,方法上标 @Tool,参数上标 @ToolParam,剩下的 JSON-RPC 协议解析、消息分帧、生命周期管理,框架全包了。
我习惯把这两种开发方式放在一起对比,思路一下子就很清晰。
| 关注点 | Spring MVC Controller | MCP @Tool |
|---|---|---|
| 声明入口 | @RestController | @McpTool |
| 接口定义 | @GetMapping("/order") | @Tool(description = "...") |
| 参数绑定 | @RequestParam | @ToolParam |
| 返回值 | 序列化为 HTTP JSON 响应 | 序列化后作为工具结果返回给模型 |
| 调用方 | 浏览器、App | LLM、智能体 |
| 底层协议 | HTTP | JSON-RPC(stdio / SSE / HTTP) |
| 文档生成 | Swagger 注解 | 自动生成工具描述与参数 Schema |
看到这个对比就明白了,写 MCP 工具的本质是换了一套“接口描述语言”:以前是给前端工程师看的,现在是给大模型看的。Controller 经验里那些关于参数校验、返回结构设计的积累,全部可以平移过来。
2.2 方法签名就是协议,描述就是文档
MCP 工具有一个特性是普通 Web 接口没有的:模型需要靠你的描述来决定“什么时候调用、传什么参数”。你的方法签名写得多清楚,模型就能用得多准。SDK 会根据方法名、参数名、注解里的描述,自动生成一份工具定义,类似下面这个结构:
{ "name": "getOrder", "description": "根据订单号查询订单详情,返回订单状态、金额和收货信息", "inputSchema": { "type": "object", "properties": { "orderNo": { "type": "string", "description": "订单号,例如 20250115001" } }, "required": ["orderNo"] } }这份结构就是“给模型看的接口文档”。我刚上手时犯过一个错:description 写得太笼统,比如只写“查询订单”,结果模型在用户问“我上周买的东西发货了没”时,根本不知道该不该调用这个工具。后来把描述改成了“根据订单号查询订单状态,状态取值范围为 CREATED、PAID、SHIPPED、DONE”,模型的选择准确率明显提升。所以我把这一条放在最前面强调:方法签名是代码层面的协议,注解描述才是模型层面的协议,两者同样重要。
2.3 传输层:stdio、SSE 与 HTTP 怎么选
MCP 的工具定义和调用逻辑,与底层传输方式是解耦的。SDK 提供了几种 transport,最常见的是 stdio 和 SSE。
stdio 模式适合本机集成场景,比如桌面客户端、本地命令行工具。MCP 客户端会启动你的 Java 进程,通过标准输入输出传递 JSON-RPC 消息。这个模式的好处是没有网络暴露面,部署简单,坏处是只能本地调用,不适合做远程服务。
SSE 或 HTTP 模式适合把 MCP Server 部署成远程服务,其他机器上的客户端通过网络访问。Java 团队在这块特别有优势,因为可以直接把 MCP transport 挂到已有的 Web 容器里。我的建议很简单:先在本地用 stdio 跑通逻辑,再根据实际部署环境决定是否切换成 SSE。切换 transport 时,工具类代码一行都不用改,这一点的抽象做得确实舒服。
2.4 同步还是异步:一个容易忽略的选择
MCP Java SDK 同时提供了同步和异步两套 Server API。如果你的工具方法本身耗时很短,比如查缓存、查单条记录,用同步就够了,写起来简单直接。但如果你的工具要调用外部接口、跑批处理、或者做长时间的分析任务,建议用异步模式,避免阻塞底层的事件循环。
我实际开发时,会把同步 API 作为默认选项,等出现明显的耗时操作再切异步。这个选择和 Spring MVC 里的同步/异步处理取舍很像,核心原则就是:不要为了“看起来高级”而选择异步,要让调用链路的实际需求决定。
3. 实战:用 Java 8 从零搭一个 MCP Server
3.1 依赖长什么样
我们先看工程配置。我用的是官方 Java SDK 的核心包,这个包不依赖 Spring Boot,可以独立跑,而且对 Java 8 很友好。新建一个 Maven 工程,JDK 直接选 1.8,pom 里加上依赖:
<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> <version>0.10.0</version> </dependency> </dependency>版本号建议以 Maven 中央仓库里最新稳定版为准,我这边用的是 0.10.x 这一代,核心包已经把字节码兼容等级控制得很好,老项目跑起来没有报什么兼容性错误。这里特别提醒一句:如果你看到某些 MCP 相关 starter 要求 JDK 17,那是另一个技术栈的东西,别混着引。Java 8 场景下,就用这个轻量核心包。
3.2 写第一个工具:订单查询
先定义一个返回对象 OrderInfo,用最传统的 JavaBean 写法,不引入任何高版本特性:
public class OrderInfo { private String orderNo; private String status; private BigDecimal amount; private String receiverName; private String receiverPhone; public String getOrderNo() { return orderNo; } public void setOrderNo(String orderNo) { this.orderNo = orderNo; } // 其余 getter / setter 省略 }然后写工具类。注意类和方法的注解:
import io.modelcontextprotocol.sdk.annotation.McpTool; import io.modelcontextprotocol.sdk.annotation.Tool; import io.modelcontextprotocol.sdk.annotation.ToolParam; @McpTool public class OrderQueryTool { private final OrderService orderService; public OrderQueryTool(OrderService orderService) { this.orderService = orderService; } @Tool(description = "根据订单号查询订单详情,返回订单状态、金额和收货信息") public OrderInfo getOrder( @ToolParam(required = true, description = "订单号,例如 20250115001") String orderNo) { return orderService.findByNo(orderNo); } @Tool(description = "查询某个客户最近一段时间内的订单列表") public List<OrderInfo> listRecentOrders( @ToolParam(required = true, description = "客户ID") String customerId, @ToolParam(required = false, description = "查询最近天数,整数,默认 7") Integer days) { return orderService.queryRecent(customerId, days == null ? 7 : days); } }这段代码和 Controller 的区别只有一个:不是暴露给浏览器,而是暴露给模型。工具名默认取方法名,也可以按需要在注解里指定。每个参数都要认真写 required 和 description,因为模型不会像前端那样有表单校验,你写清楚它才知道怎么填。
3.3 装配 Server 并启动
工具类写好后,剩下的就是把 Server 组装起来。以 stdio transport 为例:
import io.modelcontextprotocol.sdk.server.McpServer; import io.modelcontextprotocol.sdk.transport.StdioServerTransport; public class McpServerApplication { public static void main(String[] args) throws Exception { StdioServerTransport transport = new StdioServerTransport(); OrderService orderService = new OrderService(); // 实际替换为业务服务 OrderQueryTool orderTool = new OrderQueryTool(orderService); McpServer server = McpServer.sync(transport) .tools(orderTool) .register(); server.start(); // stdio 模式下,主线程保持存活,等待客户端消息 Thread.currentThread().join(); } }流程分三步:创建 transport,注册工具,启动服务。sync 表示同步模式,如果你要做异步长任务,把sync换成对应的异步入口即可。如果是远程部署,把 StdioServerTransport 换成 SSE 的 transport 实现,比如 SseServerTransport,工具类代码完全不用动。
3.4 用 MCP Inspector 验证工具调用
服务启动后,怎么验证?我推荐直接用官方提供的 MCP Inspector 调试工具。安装 Node 环境后执行npx @modelcontextprotocol/inspector,在页面里填上启动命令,比如java -jar your-mcp-server.jar,就能看到工具列表、工具描述,还能手动传参数调用。
第一次调用成功后,你会在 Inspector 里看到类似这样的 JSON-RPC 请求:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "getOrder", "arguments": { "orderNo": "20250115001" } } }响应则是一个 result 对象,里面 content 数组携带序列化后的业务数据。看到这个结果,基本就说明你的 MCP Server 已经通了大半,可以接真实客户端了。
4. Java 8 兼容性:老项目接入的细节与取舍
4.1 为什么大家还死守 Java 8
Java 8 在企业里的生命力远超很多人的想象。我接触过的团队,卡在 Java 8 的原因千奇百怪:有的是中间件版本不兼容,有的是内部框架只测过 JDK 8,有的是历史代码里用了大量反射和字节码操作,还有的单纯就是“不敢升”。但不管原因是什么,现实就是:一个不支持 Java 8 的 SDK,在不少公司里等于“不能用的 SDK”。
所以当我发现 MCP 核心 Java SDK 在 Java 8 上也能跑时,确实有点惊喜。这意味着老项目不需要为了接 AI 能力先做一次伤筋动骨的升级,完全可以低风险试点。
4.2 核心 SDK 的兼容策略
核心 SDK 之所以能兼容 Java 8,关键在于两点:一是字节码编译等级没有锁死在 17 或 21,二是不依赖新版本 JDK 的库特性。但注意,这个结论只适用于核心 mcp 包。如果你想用 Spring Boot 3 那一套 MCP Starter,那需要 JDK 17 起步,老项目就享受不到了。
我的经验是把这两条线分开看:项目升级走项目升级的节奏,MCP 接入走 MCP 接入的节奏。Java 8 项目完全可以用核心包先做一个小工具验证价值,等后面基础设施升级到 JDK 17,再平滑切到 Starter 方案,工具定义代码基本可以复用。
4.3 老项目落地的三种路径
实操层面,我总结出三条路。第一条是在现有服务里加一个独立入口类,用核心 SDK 直接启动 MCP Server,工具方法内部调用现有 service,进程共用,部署零改动。第二条是把 MCP Server 做成一个独立的轻量服务,通过内部接口调用老系统,适合不想动老进程、还想隔离风险的场景。第三条是等条件成熟升级 JDK 17 后,直接用官方 Starter 自动集成。
我个人最推荐第一条:成本最低,见效最快,而且工具方法里复用现有业务逻辑,不用担心两边数据不一致。
4.4 代码层面要注意的坑
Java 8 下写 MCP 工具,有几件事必须注意。第一,不要用 Java 9 之后的语法和 API,比如 record、var、List.of、String.isBlank,编译期就过不去,老老实实用 Lombok 或者手写 getter/setter。第二,如果返回值里有 LocalDateTime 这类时间类型,要记得注册 Jackson 的 JavaTimeModule,否则序列化出来要么报错要么是一串难读的数字。第三,泛型别绕太深,SDK 通过反射生成工具参数 Schema,太复杂的嵌套泛型容易出问题,工具方法参数尽量用简单类型,返回对象用明确的 DTO。
5. 踩坑记录:九成新手都会遇到的那些问题
5.1 stdout 日志把 stdio 协议搞坏了
这个坑我印象最深。当时工具写好了,用 MCP Inspector 一连接,对面直接乱码、解析失败。排查了半天,发现是代码里有一处 System.out.println 调试日志。stdio transport 就是靠标准输出传输 JSON-RPC 消息的,任何额外的打印都会污染协议数据流。
解决办法很简单:日志一律走 SLF4J,并且把日志输出重定向到 stderr 或文件,不要碰 stdout。如果你用了某些框架,它默认往 stdout 打印 banner,也要记得关掉。这个坑在本地 IDE 里可能不明显,一旦用命令行方式启动服务就会立刻暴露。
5.2 类型擦除导致 Schema 生成不对
Java 的泛型在运行期会被擦除,MCP SDK 靠反射读取方法签名、生成参数 Schema 时,遇到特别复杂的泛型类型就可能生成出错。我之前写过一个工具,参数是一个嵌套的 List<Map<String, List<...>>>,结果生成的参数 Schema 完全没法看,模型调用时参数校验直接失败。
后来我改成定义一个明确的 DTO 类,参数类型直接写 DTO,Schema 就正常了。这也是为什么我建议工具方法参数尽量用标量类型,真要传复杂结构,就定义一个单独的请求类,字段写清楚,别玩泛型花活。
5.3 工具名冲突与重载问题
MCP 协议里,工具名是唯一的标识。两个工具方法如果取了一样的名字,后面注册的会覆盖前面的,模型可能调到错误的方法,而且这种错误非常隐蔽。另外,Java 方法重载在 MCP 里是行不通的:同名的重载方法,SDK 无法区分成两个工具。我一开始习惯性地把一个类里两个语义相近的方法都叫 query,结果工具列表里只剩了一个。
解决办法是在命名阶段就做好规划,比如用业务域做前缀:order_get、order_list、user_get。这样既避免冲突,也让模型从名字上就能理解工具的用途,一举两得。
5.4 版本匹配与协议兼容
MCP SDK 的迭代速度不算慢,不同大版本之间的 API 和协议行为可能有差异。我踩过的坑是:服务端用了较新版本的 SDK,客户端却还在用旧版本,两边协议握手失败,报一些看不懂的错误。排查的时候很容易误判成业务代码问题。
建议团队在接入初期就把 SDK 版本固定下来,服务端和客户端保持一致,升级时一起升级。如果某个版本引入了破坏性变更,参考官方 changelog 逐个核对,尤其是 transport 初始化和工具注册相关的 API。
5.5 中文乱码与编码问题
MCP 工具描述里大量使用中文,编码问题处理不好,会出现工具描述变成乱码,直接影响模型理解。这个问题在 Windows 环境下尤其常见,因为默认编码可能不是 UTF-8。
我的做法是三个地方全设成 UTF-8:Maven 编译器编码、Java 启动参数加上-Dfile.encoding=UTF-8、IDE 的工程编码。这三个地方都统一了,中文描述基本不会再出问题。检查的时候不要只看界面,直接看生成出来的工具 Schema JSON,那个才是模型真正拿到的东西。
6. 把 MCP 接入业务系统的扩展建议
6.1 做好防腐层,别直接暴露 DAO
最容易被忽略的一点是:不要直接在 DAO 或者 Repository 方法上标 @Tool。数据库查询方法和业务工具方法,在接口语义上完全是两回事。DAO 方法返回的是内部数据模型,字段名可能是 order_id、create_time,模型看不懂,你也不好加描述。
正确做法是单独建一个工具层,把内部模型转换成对外 DTO,在这个转换过程中顺便把数据结构重新整理一遍:字段名改成人话、加上说明、控制返回的字段范围。这就像 Controller 层要做 VO 转换一样,只不过服务对象从“前端页面”变成了“大模型”。
6.2 安全与权限:让 AI 有边界
MCP 工具是有副作用的函数,AI 一旦被恶意诱导,可能调用不该调的操作。比如一个“删除订单”的工具,如果收到用户一句“把昨天所有订单删掉”,模型可能真的会去执行。所以安全边界一定要在工具层做,而不是寄希望于模型自己判断。
我的建议:所有非查询类工具,必须在方法内部做二次权限校验;参数校验要对齐 Controller 的校验标准;危险操作用只读查询先验证,或者返回给客户端让用户确认。另外,工具调用日志要完整记录,包括入参、出参、调用者上下文,方便出事时回溯。把 MCP 工具当成暴露在公网上的 API 来对待,安全标准绝不能降。
6.3 描述写得好,模型调用才准
我调试过很多次模型不调用工具的情况,最后发现根因往往不是代码逻辑,而是描述写得不够清楚。模型的决策依赖描述,描述里如果把参数取值范围、单位、示例都写出来,调用准确率会提升好几个档次。比如“查询天数”这个参数,写成“查询最近天数,整数,默认 7”就比只写“天数”强得多。
我还有个习惯:在 description 里补上典型的业务场景,比如“用于回答用户关于订单状态、物流进度的提问”。这样模型在意图匹配阶段就能更快锁定工具。这个经验的本质还是把工具当产品来做,让使用方(模型)一眼看懂你的能力边界。
6.4 监控与复盘
MCP 服务的监控,比普通 Web 服务多一层需要考虑的东西:除了常规的 QPS、延迟、错误率,还要关注模型对工具的调用意图是否准确。这部分的观测方式,可以通过记录模型发来的原始请求文本,对比最终调用了哪个工具来判断。
我目前的做法是给每个工具调用加一个统一的切面日志,输出时间、工具名、参数哈希、耗时。每周复盘一次:哪些工具被高频调用,哪些工具从未被调用,未调用的是描述不准,还是业务上根本不需要。这些东西积累下来,对后续扩展工具集非常有帮助。
7. 最后几点个人体会
我建议第一件事别想着搭多大的平台,先找一个高频但低风险的业务查询,做成第一个 @Tool。跑通之后你会发现,后面加工具基本就是加方法、写描述、加返回 DTO 的体力活。Java 8 完全不用成为心理障碍,核心 SDK 实测跑得很稳。踩坑的时候也别慌,MCP Inspector 会把 JSON-RPC 消息都摊开给你看,顺着协议一层层排查,问题定位很快。等工具数量上来了,再去研究资源、提示词这些更高级的能力,那时候你已经有了足够的基础,不会觉得抽象。