Java 8也能跑MCP Server:像写Spring MVC一样开放AI工具 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 ControllerMCP Tool声明入口RestControllerMcpTool接口定义GetMapping(/order)Tool(description ...)参数绑定RequestParamToolParam返回值序列化为 HTTP JSON 响应序列化后作为工具结果返回给模型调用方浏览器、AppLLM、智能体底层协议HTTPJSON-RPCstdio / 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 Server3.1 依赖长什么样我们先看工程配置。我用的是官方 Java SDK 的核心包这个包不依赖 Spring Boot可以独立跑而且对 Java 8 很友好。新建一个 Maven 工程JDK 直接选 1.8pom 里加上依赖properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdio.modelcontextprotocol.sdk/groupId artifactIdmcp/artifactId version0.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 ListOrderInfo 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 8Java 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 时遇到特别复杂的泛型类型就可能生成出错。我之前写过一个工具参数是一个嵌套的 ListMapString, List...结果生成的参数 Schema 完全没法看模型调用时参数校验直接失败。后来我改成定义一个明确的 DTO 类参数类型直接写 DTOSchema 就正常了。这也是为什么我建议工具方法参数尽量用标量类型真要传复杂结构就定义一个单独的请求类字段写清楚别玩泛型花活。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-8Maven 编译器编码、Java 启动参数加上-Dfile.encodingUTF-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 消息都摊开给你看顺着协议一层层排查问题定位很快。等工具数量上来了再去研究资源、提示词这些更高级的能力那时候你已经有了足够的基础不会觉得抽象。