说实话,第一次接触 MCP 这个概念的时候,我第一反应是“又多了一个协议”,但当我把套路跑通、让 Claude 真的通过我自己写的 Java 进程拿到服务器实时状态之后,我觉得这东西比想象中实用得多。MCP(Model Context Protocol,模型上下文协议)解决的其实是所有 AI 落地场景都绕不开的问题:模型再聪明,也没法直接访问你数据库里的订单、查你服务器的负载、调你内网的应用。这篇文章我就用 Java 从零手写一个 MCP Server,把它接到 Claude 上,让它能调用我的自定义工具,过程中连协议的握手细节和踩坑点一起讲清楚。
1. MCP 到底是什么,为什么值得用 Java 实现一个 Server
1.1 MCP 协议的核心定位
MCP 是 Anthropic 在 2024 年底开源的一套标准化协议,全称 Model Context Protocol。它设计了一个通用的“中间层”,让 AI 模型能够通过统一的方式发现外部工具、读取外部资源、获取上下文信息。
我理解 MCP 的方式很直白:它像是 AI 世界的 USB-C 接口。以前你给 AI 接一个数据库是一个私有接口、接一个支付系统又是一个私有接口,每个接入方都要单独建模、单独开发。MCP 出现之后,宿主应用(比如 Claude Desktop、Claude Code)只需要支持一种协议,所有的外部能力只要实现同一个协议的 Server 就能即插即用。
协议本身有三个核心抽象:
- Tool:一个可被模型调用的函数。模型通过自然语言理解用户意图后,按 JSON Schema 拼出参数,调用 Tool 获取结果。
- Resource:一段可被读取的上下文数据,通常用于注入知识库、文档内容。
- Prompt:一段预先设计好的提示词模板,可以被动态填充。
对大多数业务系统来说,第一优先级就是把“Tool”跑通。这也是实际项目里最常用的能力。你把自己的业务封装成一个个工具,Claude 碰到相关问题时会自己决定调用哪个工具、传什么参数,然后把结果整理成自然语言回复给用户。
1.2 MCP Server 在整条链路中的位置
从架构上看,链路非常清晰:
- MCP Client:运行在 Claude 宿主应用里,负责发起协议请求。
- MCP Server:独立进程,通过标准输入输出(stdio)或 HTTP 与 Client 通信,执行具体工具。
- 业务系统:Server 后面连的真实资源,可能是数据库、Redis、内网 REST API,也可能是 Java 能访问到的任何系统信息。
比如我后面要做的这个 Server,它暴露了一个叫get_system_status的工具。Claude 收到“当前服务器状态怎么样”这种问题时,会经过以下过程:
- Claude 向我的 Server 发送初始化握手。
- 客户端请求工具列表
tools/list。 - Claude 根据用户意图选择
get_system_status,并构造参数。 - 客户端发送
tools/call,我的 Java 代码读取 JVM 内存、CPU 核数、操作系统信息。 - 结果返回给 Claude,Claude 组织语言回复用户。
这个过程中 Claude 完全不关心我的工具是用什么语言实现的,它只知道这个工具有名字、有描述、有参数 Schema。这就是协议的价值。
1.3 为什么偏偏用 Java 开发 MCP Server
现在 Python 和 TypeScript 的 MCP SDK 确实更热闹,但我依旧推荐 Java 后端团队优先考虑 Java 方案,理由很现实:
- 存量资产复用:很多企业核心业务都在 Java 体系里,用 Java 写 MCP Server 可以直接在自己的 Service 层上包一层,不用跨语言调 Python。
- 类型安全和生态:工具参数校验、JSON 序列化、Spring 依赖注入这些成熟能力,Java 都有非常稳定的方案。
- 资源与性能:MCP Server 要处理高并发请求时,Java 线程池、虚拟线程、监控体系都更成熟。
当然,直接用官方 SDK 是最省力的方式。但如果你的目的是搞清楚协议到底怎么工作,或者需要在一个轻量环境里快速上线,手写一套基于 JSON-RPC 2.0 的实现反而更直观。这篇实战文章我就选择“手写”,因为把协议层彻底摊开之后,后续不管换 SDK 还是换语言,你都能快速上手。
2. 动手前的准备:技术选型与项目骨架
2.1 传输层与开发语言选型
MCP 协议底层基于 JSON-RPC 2.0,传输方式有两种主流选择:本地进程用stdio,远端服务用HTTP + SSE。
我在把 Server 接入 Claude 时,第一版用的是 stdio。原因是本地模式最简单,Claude 会启动一个 Java 子进程,通过标准输入写请求、从标准输出读响应。整个生命周期都由宿主应用管理,不需要考虑端口分配、鉴权、公网暴露这些麻烦事。对开发调试来讲,你在命令行里模拟输入一段 JSON,直接就能看到响应,排查效率非常高。
Java 版本方面,我建议直接上 JDK 17 以上。不是为了追赶新版本,而是 MCP 工具场景经常要处理流式响应、虚拟线程挂载这类能力,JDK 17 的现代语法写起来也舒服很多。构建工具用 Maven 就好,团队里几乎都是这个,不用额外折腾 Gradle。
2.2 Maven 项目结构与依赖
新建一个普通 Maven 项目,Java 版本设为 17,artifactId 就叫system-status-mcp。依赖方面我们尽可能精简,只保留两个:Jackson 负责 JSON 序列化,SLF4J Simple 负责日志输出。
为什么不引入 Spring Boot?因为 MCP Server 本身是一个面向协议的长驻进程,不需要 Web 容器。把 Spring Boot 塞进来会让 jar 体积大好几倍,启动也慢,而实际用到的能力可能只有 JSON 解析。如果后续要连接数据库、接 Spring Bean,再考虑引入 Spring Boot 不迟。
pom.xml 核心部分长这样:
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.2</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.13</version> </dependency> </dependencies>注意一个关键点:MCP Server 通过 stdout 输出协议响应,所以日志绝对不能打到 stdout,否则协议流会被日志污染。SLF4J Simple 默认输出到 stderr,这个后面我会专门拿一节讲。
打包插件用 maven-shade,打出一个可执行的 fat jar:
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.2</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.mcp.McpStdioServer</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin> </plugins> </build>这样打好包后,Claude 的配置只需要java -jar /path/to/system-status-mcp.jar一行命令。
3. 核心实现:手写一个可用的 MCP Server
3.1 通信层:基于 stdio 的 JSON-RPC 收发
MCP 的 stdio 传输本质上非常朴素:Client 往 stdout 写,Server 从 stdin 读;Server 处理完以后往 stdout 写,Client 从 stdin 读。消息是逐行 JSON,每一行一条独立消息。
主程序的核心就是一个无限读循环:
public class McpStdioServer { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final Map<String, ToolHandler> TOOL_HANDLERS = new HashMap<>(); public static void main(String[] args) throws Exception { registerTools(); BufferedReader reader = new BufferedReader( new InputStreamReader(System.in, StandardCharsets.UTF_8)); String line; while ((line = reader.readLine()) != null) { if (line.isBlank()) { continue; } String response = ProtocolProcessor.process(line); if (response != null) { System.out.println(response); System.out.flush(); } } } private static void registerTools() { TOOL_HANDLERS.put("get_system_status", new SystemStatusTool()); } }两个细节要特别注意。
第一,InputStreamReader必须指定 UTF-8。如果不指定,Windows 上默认编码可能是 GBK,中文描述会乱码,Claude 解析工具描述时很容易失败。
第二,每写一条响应都要flush。这是缓冲区问题,不 flush 的话,Claude 那边可能长时间等不到数据,最后判定为超时。我第一次写的时候就是没 flush,调试半天,还以为是协议格式错了。
3.2 协议层:initialize、tools/list、tools/call
MCP 的协议流程很固定。Claude 启动连接后,第一件事就是发initialize请求。我收到后返回服务端信息和一个协议版本。
这里有个容易踩坑的地方:初始化响应的protocolVersion最好直接用客户端传过来的版本号,而不是自己写死。原因是不同版本的 Claude 可能携带不同的协议版本,如果你写死一个较新的版本,老客户端可能不接受。我实际采用的方式是:取客户端传的版本,如果为空再默认"2024-11-05"。
初始化请求长这样:
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}我的响应:
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"system-status-mcp","version":"1.0.0"}}}初始化完成后,Client 会发一个notifications/initialized通知,这个通知没有id,也不需要响应。然后就是真正干活的两个请求:
tools/list:返回我支持的所有工具列表。tools/call:传入工具名和参数,执行并返回结果。
再加上一个 JSON-RPC 标准的ping方法,用来做存活检测。
我把这些分发逻辑统一放在ProtocolProcessor里:
public static String process(String message) throws JsonProcessingException { JsonNode root = MAPPER.readTree(message); String method = root.path("method").asText(""); // 判断是请求还是通知 if (!root.has("id")) { handleNotification(method, root.path("params")); return null; } int id = root.get("id").asInt(); try { switch (method) { case "initialize": return buildResponse(id, buildInitializeResult(root.path("params"))); case "tools/list": return buildResponse(id, buildToolsList()); case "tools/call": return handleToolCall(id, root.path("params")); case "ping": return buildResponse(id, MAPPER.createObjectNode()); default: return buildError(id, -32601, "Method not found: " + method); } } catch (Exception e) { return buildError(id, -32602, "Invalid params: " + e.getMessage()); } }buildResponse和buildError就是包一层 JSON-RPC 的外壳:
private static String buildResponse(int id, JsonNode result) throws JsonProcessingException { ObjectNode response = MAPPER.createObjectNode(); response.put("jsonrpc", "2.0"); response.put("id", id); response.set("result", result); return MAPPER.writeValueAsString(response); } private static String buildError(int id, int code, String message) throws JsonProcessingException { ObjectNode error = MAPPER.createObjectNode(); error.put("code", code); error.put("message", message); ObjectNode response = MAPPER.createObjectNode(); response.put("jsonrpc", "2.0"); response.put("id", id); response.set("error", error); return MAPPER.writeValueAsString(response); }这里顺序很重要:一定要先判断root.has("id"),再决定是处理请求还是通知。因为通知没有 id,如果你把通知当成请求去处理,响应里没有匹配的 id,客户端会直接丢弃,但这个可以先耽误你半天调试时间。
3.3 工具层:一个真实可用的 status 工具
工具是 MCP Server 的灵魂。我选了“系统状态查询”来做演示,因为它不依赖第三方服务,任何机器上都能跑,而且结果直观。
工具类实现方式很简单,实现一个统一接口:
public interface ToolHandler { JsonNode execute(JsonNode arguments) throws Exception; }具体实现SystemStatusTool读取 JVM 自带的ManagementFactory,不需要额外依赖:
public class SystemStatusTool implements ToolHandler { @Override public JsonNode execute(JsonNode arguments) { OperatingSystemMXBean os = ManagementFactory.getOperatingSystemMXBean(); MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); MemoryUsage heap = memoryBean.getHeapMemoryUsage(); boolean detail = arguments.path("detail").asBoolean(false); StringBuilder sb = new StringBuilder(); sb.append("当前系统: ").append(os.getName()).append(" ").append(os.getVersion()); sb.append("\nCPU 核数: ").append(os.getAvailableProcessors()); sb.append("\nJVM 堆内存: 已用 ") .append(String.format("%.1f", heap.getUsed() / 1024.0 / 1024.0)) .append(" MB, 最大 ") .append(String.format("%.1f", heap.getMax() / 1024.0 / 1024.0)) .append(" MB"); if (detail) { MemoryUsage nonHeap = memoryBean.getNonHeapMemoryUsage(); sb.append("\n非堆内存: ") .append(String.format("%.1f", nonHeap.getUsed() / 1024.0 / 1024.0)) .append(" MB"); } return buildTextContent(sb.toString()); } }注意返回值不是普通字符串,而是一个 MCP 标准的 Tool Result 结构。content是一个数组,每个元素类型为text,里面才是最终给模型的文本:
public static JsonNode buildTextContent(String text) { ObjectNode result = MAPPER.createObjectNode(); ArrayNode content = result.putArray("content"); ObjectNode item = content.addObject(); item.put("type", "text"); item.put("text", text); result.put("isError", false); return result; }对应地,tools/call的响应就是:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"当前系统: Linux 5.15.0\nCPU 核数: 8\nJVM 堆内存: 已用 512.3 MB, 最大 4096.0 MB"}],"isError":false}}有了这个结构,Claude 就知道工具正常执行,并把text内容当作结果组织语言告诉用户。
前面tools/list的响应里最关键的部分,是每个工具的description和inputSchema。这个 schema 决定了模型能不能正确调用工具。我的工具 schema 写得很简单,但注意一点:即使是空参数,也要声明type: object和required: [],否则某些客户端会认为 schema 非法。
private static JsonNode buildToolsList() { ObjectNode result = MAPPER.createObjectNode(); ArrayNode tools = result.putArray("tools"); ObjectNode tool = tools.addObject(); tool.put("name", "get_system_status"); tool.put("description", "获取当前服务器的系统状态信息,包括操作系统、CPU核数、JVM内存使用情况。用户询问服务器状态、机器负载、内存占用时使用。"); ObjectNode inputSchema = MAPPER.createObjectNode(); inputSchema.put("type", "object"); ObjectNode properties = inputSchema.putObject("properties"); ObjectNode detail = properties.putObject("detail"); detail.put("type", "boolean"); detail.put("description", "是否返回详细的非堆内存信息,默认 false"); inputSchema.putArray("required"); tool.set("inputSchema", inputSchema); return result; }description不是随便写的。模型不是人,它不知道你这个工具背后是什么,它完全依赖这段描述来判断“什么时候该调用”。我一开始写的是“获取服务器状态”,结果 Claude 经常在用户问“机器卡不卡”时不去调用。后来我把描述改成“用户询问服务器状态、机器负载、内存占用时使用”,命中率明显提升。
3.4 启动自测:手动灌入请求验证
把代码写完以后,不用急着接 Claude。先在命令行里自测一遍,否则到时候 Client 那边报错,你能拿到的信息非常有限。
打包之后直接运行 jar,然后手动向 stdin 粘贴内容。完整测试流程:
- 启动
java -jar system-status-mcp.jar。 - 粘贴初始化请求,回车,看到正确响应。
- 粘贴
tools/list,确认工具列表返回。 - 粘贴
tools/call,确认工具能执行并返回结果。
注意每行 JSON 要一次性粘贴,因为程序是按行读取的。实测效果类似:
》 {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"1.0"}}} 《 {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"system-status-mcp","version":"1.0.0"}}}我习惯把这三条请求放到一个文本文件里,需要验证的时候直接cat test-requests.jsonl | java -jar xxx.jar,一次跑完。这个办法对回归测试特别有用。
4. 接入 Claude:配置与实测
4.1 Claude Desktop 配置方式
如果你的宿主应用是 Claude Desktop,配置文件在用户目录下,文件名叫claude_desktop_config.json。支持 MCP 的版本会自动读取这个配置。
Windows 路径通常是%APPDATA%\Claude\claude_desktop_config.json,macOS 通常是~/Library/Application Support/Claude/claude_desktop_config.json。
配置格式固定为:
{ "mcpServers": { "system-status": { "command": "java", "args": ["-jar", "/absolute/path/to/system-status-mcp.jar"] } } }填完之后要完全重启 Claude Desktop,不是关窗口,是彻底退出进程再打开。配置生效后,界面上通常能看到本地的 MCP 工具列表,或者你直接问它“你有哪些工具可以用”,它会告诉你。
4.2 Claude Code 配置方式
Claude Code 是命令行模式,配置更灵活。用命令添加是最简单的方式:
claude mcp add system-status -- java -jar /absolute/path/to/system-status-mcp.jar默认配置范围是用户级,如果你想只对当前项目生效,加--scope project:
claude mcp add system-status --scope project -- java -jar /absolute/path/to/system-status-mcp.jar添加完可以查看状态:
claude mcp list输出的Disabled状态为 false,说明连接是好的。如果状态不对,说明 Server 启动时就失败了,这时候去看 stderr 日志最直接。
项目级配置最终会被写入当前目录的.mcp.json,格式和 Desktop 的配置基本一致。这种方式有个额外好处:配置文件跟随代码仓库走,团队其他人拉下来就能用同一个 MCP 工具集合。
{ "mcpServers": { "system-status": { "command": "java", "args": ["-jar", "/absolute/path/to/system-status-mcp.jar"] } } }4.3 实测效果与调用链验证
配置好以后,我在 Claude Code 里直接问:
帮我看一下当前这台服务器的运行状态。
正常情况下,Claude 会识别到“服务器状态”和get_system_status工具匹配,于是走如下链路:
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}} {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_system_status","arguments":{}}}然后返回结果给用户:
当前这台服务器是 Linux 5.15.0,CPU 8 核,JVM 堆内存已用 512.3 MB,最大 4096 MB。
如果你不确定是模型自己编的,还是真的走了 MCP,有个笨但有效的验证方法:在 Server 代码里加一行 stderr 日志,每次tools/call都打印工具名和时间戳。然后在 Claude 里再问一次,回来看终端日志,看到打印记录就说明链路是真的通的。
System.err.println("[MCP] tools/call -> " + name + " at " + System.currentTimeMillis());5. 踩坑实录与排查思路
5.1 日志污染 stdout 导致握手失败
这个坑我必须要放在最前面。MCP 的 stdio 模式靠 stdout 传输协议数据,如果你在代码里用了System.out.println打日志,Claude 收到的第一条消息就是一行奇怪的普通文本,不是合法 JSON,于是握手直接失败。
症状很典型:Claude 显示 MCP Server 连接失败,但你自己启动 jar 却一切正常,手工输入请求都有响应。原因就是只有在 Claude 启动子进程时,stdout 才被协议占用,你自己调试时 stdout 是终端,日志和协议混在一起你也不容易察觉。
解决办法很简单:所有日志统一走System.err,或者用 SLF4J 默认输出到 stderr。我用 SLF4J Simple 就是为了这个。检查一遍代码,凡是System.out.println一律删掉或改成System.err.println。
5.2 工具 Schema 写错导致 AI 不识别
tools/list返回的工具列表是模型了解工具的唯一入口。如果inputSchema写得不对,或者参数类型描述不清楚,模型要么不用这个工具,要么调用时报参数错误。
我遇到过的具体问题:
inputSchema少了"type": "object",客户端直接丢弃该工具定义。- 参数名称是 Java 风格
isDetail,模型按自然语言习惯猜成detail,调用时参数对不上。 - 工具描述里没有包含足够的触发条件词,比如“状态”“负载”“内存”,模型判断不出来什么时候该用。
针对这些,我的固定写法是:
{ "name": "get_system_status", "description": "获取当前服务器的系统状态信息,包括操作系统、CPU核数、JVM内存使用情况。用户询问服务器状态、机器负载、内存占用时使用。", "inputSchema": { "type": "object", "properties": { "detail": { "type": "boolean", "description": "是否返回详细的非堆内存信息,默认 false" } }, "required": [] } }描述里的触发词宁可多写几个,也不要写得太抽象。
5.3 进程残留、打包体积与中文乱码
MCP Server 的进程生命周期由 Client 管理。正常情况,Claude 退出之后,子进程会因为 stdin 关闭而读到null,我的 while 循环随之退出。但如果你在代码里额外启动了线程池,或者有非守护线程没有关闭,Java 进程就不会退出,变成一个僵尸进程。
排查方法很简单:Claude 退出后看系统进程列表,如果 java 进程还在,就去检查代码里有没有没关闭的线程池。我自己的代码里任何时候都不会裸用ExecutorService,要么手动 shutdown,要么完全依赖主线程。
打包体积的问题,我之前提过尽量少引入依赖。但如果你已经引入了 Spring Boot 或其他大依赖,注意用 maven-shade 时把签名文件排除掉,否则启动时会报SignatureException:
<filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters>中文乱码问题前面说过,启动命令最好加-Dfile.encoding=UTF-8,并且在代码里显式用 UTF-8 读取 stdin:
java -Dfile.encoding=UTF-8 -jar system-status-mcp.jar5.4 工具结果质量与调用延迟
当 Claude 调用工具后,返回的结果会作为上下文参与后续生成。这意味着:
- 工具返回结果要尽量精简,只保留模型需要的信息,不要一长串几百行的 JSON 日志。
- 返回之前最好做格式化,让模型更容易提取重点。我的
SystemStatusTool把内存数值格式化到小数点后一位,既能满足展示需求,也不会因为一串冗长浮点数干扰模型理解。 - 如果工具执行时间超过几秒,客户端可能报超时。日志、网络请求这类慢操作,尽量异步化或加缓存。
我还建议在工具内部做参数兜底。arguments.path("detail")这种取法,即使客户端传了 null 也不会抛异常,因为 Jackson 的path方法找不到节点时返回一个 MissingNode,调用asBoolean(false)会返回默认值。这个习惯帮我挡掉了很多非预期的线上调用。
最后再分享一个我自己的体会:MCP 项目最核心的资产不是 Server 框架代码,而是工具的定义和描述。框架代码写一遍就固定在那了,但工具描述需要根据实际使用反馈持续优化。Claude 不调用某个工具时,先别怀疑协议,回去看看description和inputSchema是不是足够清楚。把工具的“说明书”写到位了,接入效果基本就成功了一半。我后续准备把同一个 Server 接上更多业务工具,先查数据库再暴露查询接口给 Claude,这套 Java + MCP 的路子完全能撑得住。