像写Controller一样写MCP Server:Java注解驱动实战与Java 8兼容方案
发布时间:2026/10/2 15:11:59来源:尧图网络
MCP 最近在 AI 工具链里几乎成了标配但 Java 开发者第一次想接的时候大多会被官方 SDK 的复杂性劝退。你其实可以换一种思路把 MCP Server 设计得像 Controller 一样写——类上加注解、方法上加注解、参数上加注解协议握手、Schema 生成、方法调用这些脏活全部交给框架而且这套封装可以跑在 Java 8 上存量项目也能直接用。这篇文章就围绕这个思路把注解怎么设计、兼容 Java 8 要避哪些坑、一个能跑的 Server 怎么搭、线上会遇到哪些问题全部说透。1. MCP 与 Controller 的相似性为什么这个类比成立1.1 MCP 到底做了什么MCPModel Context Protocol模型上下文协议看着名字唬人拆开看其实只是一套基于 JSON-RPC 2.0 的服务发现加调用协议。AI 客户端想用你的能力本质就两步先拿到你的菜单再按菜单点菜。对应到协议里就是tools/list和tools/call两个方法。打个比方MCP Server 就是一家餐厅AI 是客人。客人进门先看菜单tools/list菜单上写着每道菜的名字、描述、需要哪些原料参数看完后点菜tools/call后厨照着菜谱做做完把菜端出来返回content。协议细节无非是端盘子的礼仪消息格式、换行分隔、超时重连、版本协商这些跟具体业务没有任何关系。所以你会发现一个 MCP 工具方法的本质和一次 HTTP 接口调用没有任何区别输入参数返回结果。既然 Java Web 开发者早就习惯了RestController那套用注解声明接口的玩法为什么 MCP 这边不能同样来一遍这就是标题里像写 Controller 一样这个类比的核心出发点。1.2 官方 SDK 的样板代码到底有多重我最早用官方 SDK 搭 Java MCP Server 的时候最直观的感受是业务代码没写几行协议代码写了一堆。你要手动构造Tool对象把工具名、描述、inputSchema 用 JSON 字符串一层层包好要处理 initialize 生命周期监听客户端的连接建立、初始化完成tools/call进来以后还要把arguments这个JsonNode手动反序列化成 Java 参数执行完方法以后再手动把结果包装成content数组。这个过程不是说多难而是它把你的注意力从业务上拽走。每加一个工具就要重复一遍取参数、转类型、包结果的机械动作。Java 8 时代我们写 Controller 之前也经历过这种痛苦后来 Spring MVC 把 URL 到方法的映射、参数绑定、返回值序列化全部自动搞定开发者只需要写方法本身。MCP 的注解化封装本质上做的就是这个事注册 HandlerMapping、参数解析 HandlerAdapter、返回结果处理器等一整套机制让业务方法上方的复杂度和一个 Controller 方法对齐。1.3 为什么一定要支持 Java 8很多做 AI 基建的团队容易忽略一个问题真正能落地的生产环境不都在跑最新 JDK。大量金融、政企、传统制造的系统还稳在 JDK 8 Spring Boot 2.x 上升级 JDK 17/21 不是改个版本号那么简单牵扯中间件兼容、启动脚本、监控 agent、团队技能栈拖个一年半载很正常。如果这个 MCP 封装要求 JDK 17那这些存量的 Spring Boot 服务就接不上 AI 能力。支持 Java 8 不是技术炫技而是让 MCP 有机会直接嵌进现有业务代码库复用已有的 Service、DAO、事务和权限体系。这一点在实操中的价值比单纯追求新语法要大得多。2. 注解驱动设计的核心思路2.1 核心注解服务、工具、参数我自己的封装里核心注解只有三个McpServer标注在类上声明这是一个 MCP 服务Tool标注在方法上声明这是一个可被 AI 调用的工具ToolParam标注在参数上声明每个字段的说明、是否必填、默认值。给你看一个具体例子McpServer(name weather, description 天气查询服务) public class WeatherTool { Tool(name getWeather, description 查询某城市的实时天气) public String getWeather( ToolParam(name city, description 城市名比如杭州, required true) String city, ToolParam(name days, description 未来N天预报默认1天, required false, defaultValue 1) Integer days) { // 这里写真实业务逻辑或者调用已有的 Service WeatherService svc WeatherService.getInstance(); return svc.query(city, days); } }注意getWeather方法里没有任何 MCP 协议相关代码。它不知道什么是 JSON-RPC也不知道什么是 content 数组它就是纯业务逻辑。协议的事情全部由框架在方法外面包一层。仔细想想这和 Spring MVC 的GetMapping方法是不是很像Controller 方法也不关心 HTTP 报文长什么样那是框架的事。2.2 一个注解方法如何变成协议行为客户端第一次调用tools/list时框架扫描到WeatherTool类读取McpServer和Tool注解根据方法签名反射生成工具清单。上面的getWeather方法对应的 schema 长这样{ name: getWeather, description: 查询某城市的实时天气, inputSchema: { type: object, properties: { city: { type: string, description: 城市名比如杭州 }, days: { type: integer, description: 未来N天预报默认1天 } }, required: [city] } }客户端拿到这个清单决定要不要用。等它真正发起tools/call时框架就按照工具名找到getWeather方法把 JSON 格式的 arguments 反序列化到方法参数表然后反射执行方法再把返回值包装成统一格式。整个过程你可以想象成 Spring MVC 的 DispatcherServlet 在做 HandlerMapping 和 HandlerAdapter 的匹配只不过从 HTTP 换成了 JSON-RPC 消息。2.3 为什么注解驱动更抗迭代手写 Tool 对象的方案我在早期也试过最难受的是 schema 和 Java 方法签名会漂移。业务方法加了字段忘了去同步那个手写的 inputSchema客户端请求传进来的参数就对不上。而注解方案直接从真实的方法签名生成 schema代码变了schema 自动跟着变不存在两份配置要维护的问题。还有一个隐藏好处因为工具描述信息是注解里的字符串团队内部做代码评审时一眼就能看到。这个描述写得不够清楚这个参数没写 required在 pull request 阶段就能提出来不需要跑到线上调了才发现工具描述有问题。对 AI 场景来说描述文字是模型选择工具的重要依据这个可维护性特别值钱。3. 兼容 Java 8 是怎么做到的3.1 Java 8 的现实意义回到开头那个命题支持 Java 8。很多新框架上来就要求 JDK 17因为可以放心用var、List.of、record、文本块这些新语法写起来舒服。但如果你想把这个 MCP 封装塞进一个生产多年、跑着 Spring Boot 2.x 的老项目里JDK 8 就是一道绕不开的门槛。我自己的经验是凡是看起来只是兼容性细节的改动最后往往决定一个中间件能不能在存量系统里活下来。把 MCP 接入老项目本质上是在原有代码库上开一个 AI 入口如果这个入口要求项目先升级 JDK那么推进阻力会非常大。保证 Java 8 兼容就保证了普通业务项目不需要任何额外技术改造就能接入。3.2 编码层面的约束清单兼容 Java 8 不是嘴上说说实现的时候得管住手我自己踩过几个坑第一不能用 JDK 9 的 API。List.of、Map.of、Optional.isEmpty、var这些都要避开集合工厂统一用Collections.unmodifiableList(Arrays.asList(...))或者直接new ArrayList()。这个说起来简单但写习惯新语法以后很容易随手打出来编译一跑才发现问题。第二依赖库版本要卡准。Jackson 2.x 还在持续支持 Java 8 的版本范围内所以我推荐用 Jackson 做 JSON 序列化和反序列化如果项目里已经用了 Gson那更省事Gson 对老版本 Java 的支持一直很好。这里有个检查点把所有依赖的字节码版本验一遍确保没有某个间接依赖已经编译成 Java 11 的 class。第三编译配置要固定。Maven 里用maven-compiler-plugin把 source 和 target 都设成 1.8同时加上-parameters参数方便反射拿参数名plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.11.0/version configuration source1.8/source target1.8/target parameterstrue/parameters /configuration /plugin-parameters这个参数很关键。Java 8 默认编译出来的字节码不保留方法参数名反射拿到的是arg0、arg1所以注解里显式写name就成了最稳妥的方案不依赖编译参数也能保证字段名正确。3.3 反射与模块化的隐藏细节还有一个 Java 版本差异很容易被忽略JDK 8 时代没有模块系统类反射几乎不受限到了 JDK 17强封装机制下直接反射 JDK 内部类会抛InaccessibleObjectException。我当时第一反应是怕这个框架在 JDK 17 环境跑不起来后来想清楚了我们反射的是用户自己的类不是 JDK 内部的java.*模块类所以不涉及--add-opens的问题。真正需要关注的是别依赖sun.misc.Unsafe或者sun.reflect.*这类内部 API。JDK 8 上能跑JDK 17 上可能直接启动失败。框架里凡是涉及反射的地方尽量用标准的Class.getMethods()、Method.invoke()不碰特殊句柄这样在 Java 8 和 Java 17 混合部署的产线环境里才不会炸。最后建议测试矩阵同时跑 JDK 8 和 JDK 17。CI 里配两个 job一个用 JDK 8 编译跑单测一个用 JDK 17 跑一遍同样的用例。虽然支持 Java 8但很多客户端环境其实是 JDK 17 或更高两个版本都跑过才能放心发出去。4. 从零写一个可运行的 MCP Server4.1 工程依赖与启动配置我自己这套封装落地时变成了一个小 starter依赖坐标大概是这样的你实际用的时候换成对应版本即可dependencies dependency groupIdio.github.example/groupId artifactIdmcp-annotation-starter/artifactId version1.0.0/version /dependency /dependencies如果不依赖 Spring Boot这个框架可以直接在 main 方法里启动如果在 Spring Boot 项目里也可以把扫描到的McpServer类注册成 Spring 管理的 Bean享受依赖注入。两种方式我都试过纯 Java 方式更轻Spring 方式跟存量系统融合度更高。4.2 写一个业务工具类为了让你看得更清楚我把工具类写得极端简单做一个 echo 和一个加法McpServer(name demo, description 演示用的 MCP 服务) public class DemoMcpTool { Tool(name echo, description 原样返回输入文本用于连通性测试) public String echo( ToolParam(name text, description 要回显的内容, required true) String text) { return [echo] text; } Tool(name add, description 两个整数相加) public Integer add( ToolParam(name a, description 第一个加数, required true) Integer a, ToolParam(name b, description 第二个加数, required true) Integer b) { return a b; } }注意这里用的是Integer而不是int。用包装类型可以区分客户端没传这个参数和传了 0在参数校验时更灵活。Integer的 null 可以做逻辑判断int如果没传值反序列化就会直接报错。4.3 引导启动接下来写一个入口类负责启动服务public class McpServerApplication { public static void main(String[] args) { McpServerBuilder.forJava8() .scanBasePackage(com.example.mcp) .transport(args) .start(); } }这个启动器做的事情概括起来就是四步扫描指定包路径下所有带McpServer注解的类。读取每个方法上的Tool、ToolParam生成工具注册表。根据启动参数选择传输层默认 stdio加--transporthttp --port8080可以切到 HTTP/SSE 模式。启动对应的监听循环。整个启动过程和SpringApplication.run()的心智模型完全一致你不需要了解 MCP 协议细节就能把一个 Server 跑起来。4.4 用客户端完成第一次 tools/list 与 tools/call如果你不想接任何第三方客户端我建议先用一个最原始的调试方式手动往 stdio 里灌 JSON-RPC 消息。Python 脚本可以这样写简化版实际握手还需要带 protocolVersion 等字段这里只是演示通信路径import subprocess import json proc subprocess.Popen( [java, -jar, target/mcp-server.jar], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, textTrue ) def send(msg): proc.stdin.write(json.dumps(msg) \n) proc.stdin.flush() return json.loads(proc.stdout.readline()) print(send({jsonrpc: 2.0, id: 1, method: initialize, params: {protocolVersion: 2024-11-05, capabilities: {}}})) print(send({jsonrpc: 2.0, id: 2, method: tools/list, params: {}})) print(send({jsonrpc: 2.0, id: 3, method: tools/call, params: {name: add, arguments: {a: 2, b: 5}}}))如果你用现成的 MCP 客户端配置连接客户端完成后会显示tools/list返回的工具列表当你点它提供的工具调用按钮或直接在对话里触发客户端发出的请求大致是这样{ jsonrpc: 2.0, id: 3, method: tools/call, params: { name: add, arguments: { a: 2, b: 5 } } }服务端的返回{ jsonrpc: 2.0, id: 3, result: { content: [ { type: text, text: 7 } ], isError: false } }框架把Integer返回值 7 自动包装成了content数组里的文本。AI 客户端拿到这个文本再组织成用户能看懂的回复。整个链路里你的核心代码就一个方法加两个注解其余全是框架在忙活。5. 部署模式与项目融合5.1 stdio 模式别污染 stdoutstdio 模式是把 MCP Server 作为客户端的一个子进程跑两边用标准输入输出通信。这种模式最适合本地开发工具比如桌面客户端、IDE 插件。好处是不用开端口、不用处理跨域鉴权部署成本极低。但这个模式下有一个特别坑人的地方stdout 是协议通道不是日志通道。如果有人不小心在工具方法里用System.out.println打日志整条 MCP 通信流会立刻被污染。客户端那边表现出来就是连接后进程秒退或者读到无法解析的响应。我的做法是两件事同时做一是在日志框架里把 ConsoleAppender 的 target 改成System.err二是在项目规范里明确禁止在业务代码中用System.out输出任何日志。日志文件可以单独落盘但绝不往 stdout 打。客户端那边的配置也要注意格式以 Claude Desktop 这类客户端为例正确写法是把启动程序和参数分开{ mcpServers: { demo: { command: java, args: [-jar, /opt/mcp-server/mcp-server.jar] } } }很多新手会把 command 写成一整串java -jar /opt/xxx.jar结果客户端拿去按命令名找可执行文件直接报找不到命令。先分清哪个是 command、哪个是 args能少踩一半的坑。5.2 SSE/HTTP 模式远程部署要做的事如果 MCP Server 要部署在远程给多个客户端共享那 stdio 就不合适了。用 HTTP/SSE 模式客户端通过 HTTP 端点发送消息、通过 SSE 流接收服务端推送这是早期 MCP 规范里常用的传输方式。后续规范演进到 Streamable HTTP 后整体交互模型类似但更统一。远程部署时至少要解决三件事连接保活、认证鉴权、超时控制。SSE 是长连接中间有反向代理的话要把超时时间调大给 keep-alive 留口子。认证鉴权不能省因为 MCP 工具背后可能直接连着数据库、文件系统甚至能触发状态变更。我在实际项目中就是用一个过滤器统一校验请求头里的 token同时配了 IP 白名单和接口限流。这个思路跟 Controller 层防爬虫、防刷接口完全一致不在业务方法里做临时校验而是在网关层把所有冒烟请求挡在外面。5.3 与 Spring Boot 存量服务融合在真实项目里我更推荐把 MCP Server 集成进已有的 Spring Boot 应用而不是单独起一个进程。思路是这样的扫描 Spring 容器里所有标注了McpServer的类让它们依赖已有的 Service、Mapper这样 AI 工具能直接复用事务机制和业务校验逻辑。比如你有一个OrderService既被OrderController使用又被Tool注解的queryOrder方法调用两边用的是同一套代码不存在AI 接口一套逻辑、Web 接口另一套逻辑的割裂。需要提醒的是MCP 工具比 Web Controller 更应该加权限校验。浏览器场景下还有同源策略、验证码、登录 cookie 这些天然护栏MCP 场景里 AI 客户端只要能连上服务器就能发起调用。所以在暴露工具时尽量在框架层加一层统一的权限拦截器和操作审计每个工具的调用记录都留痕。5.4 主流客户端怎么连不同客户端的配置入口不一样但核心机制都是让你填一个启动命令。桌面客户端、IDE 里常见的配置方式就是上面那个 JSON指定command和args。有的客户端还支持通过 URL 连接 HTTP 模式的 Server那就填服务地址和 token。遇到客户端找不到 MCP 服务这种问题时我的排查习惯是先不看客户端界面直接打开终端把 command 和 args 原样跑一遍。如果命令行里能正常启动、没有报错那问题大概率出在客户端配置格式上如果命令行本身就跑不起来那先解决进程启动问题。很多所谓无法找到 MCP的问题最后都发现是路径写错或者没装对应版本的 Java。6. 实战常见问题速查与排查思路这部分是踩坑记录列成速查表方便直接翻问题常见原因处理方式客户端连接后进程秒退业务日志打到 stdout污染协议流日志输出改到 stderr 或文件单独在终端运行 jar 观察输出tools/list 返回空列表扫描包路径不对注解 retention 不对检查scanBasePackage路径确认注解Retention(RUNTIME)tools/call 提示找不到工具工具名大小写不一致或方法重载工具名显式指定避免重载方法检查 name 是否完全匹配客户端传的参数拿到是 nullJava 8 反射拿不到参数名加-parameters编译参数或在注解里显式写name复杂对象反序列化失败POJO 缺无参构造器或 getter/setter补齐标准 JavaBean 结构Jackson 依赖无参构造器返回中文乱码编码不一致统一 UTF-8启动参数加-Dfile.encodingUTF-8协议版本握手失败新旧客户端版本差异服务端兼容旧版本号协商时降级处理远程调用表现很慢工具方法内是阻塞 IO占满线程池给执行线程池设最大并发数加超时避免在工具方法里写无限等待逻辑这里有两个容易被忽略的细节展开说一下。第一个是工具描述。MCP 的客户端模型完全依赖描述文字来判断用哪个工具描述写得含糊AI 就不会选。getData这样的描述等于没有描述写根据用户ID返回该用户的订单列表按创建时间倒序才是有效描述。我见过很多团队上线工具以后发现 AI 总是选错一查不是逻辑问题是 description 写得太烂。第二个是工具拆分。一个工具方法只做一件事跟 Controller 里一个接口只完成一个动作是同一个道理。不要写一个万能的execute(String action, Map params)那样 AI 很难判断该传什么参数你的参数校验也会很痛苦。宁可拆细一点每个工具只回答一个精准问题模型的选择准确率会大幅提升。7. 一些更底层的经验写到这里核心内容基本讲完了。最后分享几条我自己在实际项目里沉淀下来的经验。如果你第一次把一个 Java MCP Server 接到存量项目我建议先别急着写业务工具。先用一个 echo 工具把整条链路打通确认客户端配置、协议握手、tools/list、tools/call 都正常再开始加业务工具。这跟写 Controller 时先搞一个 health 接口是一个道理先把网络通路验证了再谈业务。日志和审计一定要从第一天就做。MCP 工具是 AI 直接触发的入口一旦出问题你面对的是一堆你根本预料不到的参数组合。把每次 tools/call 的入参、出参、耗时、来源全部记录下来排起问题来会轻松很多。最后说一句关于兼容性的体会这套封装在 JDK 8 和 JDK 17 混布的产线环境跑了一年多最深的感受是支持 Java 8看起来是个技术细节实际上决定了一个 AI 中间件能不能在存量系统里活下来。新语法当然香但能让老项目平滑接入 AI 能力这种不折腾的设计反而更值钱。如果你的项目还在 Java 8不应该成为接 MCP 的阻碍——用注解把工具定义清楚剩下的交给框架就好。
网站建设高端定制企业官网