资讯动态

Java手写MCP Server接入Claude:从零实战AI工具调用

发布时间:2026/9/11 21:06:41 来源:尧图企业网站定制
说实话第一次接触 MCP 这个概念的时候我第一反应是“又多了一个协议”但当我把套路跑通、让 Claude 真的通过我自己写的 Java 进程拿到服务器实时状态之后我觉得这东西比想象中实用得多。MCPModel Context Protocol模型上下文协议解决的其实是所有 AI 落地场景都绕不开的问题模型再聪明也没法直接访问你数据库里的订单、查你服务器的负载、调你内网的应用。这篇文章我就用 Java 从零手写一个 MCP Server把它接到 Claude 上让它能调用我的自定义工具过程中连协议的握手细节和踩坑点一起讲清楚。1. MCP 到底是什么为什么值得用 Java 实现一个 Server1.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 核数、操作系统信息。结果返回给 ClaudeClaude 组织语言回复用户。这个过程中 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 版本设为 17artifactId 就叫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.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.17.2/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version2.0.13/version /dependency /dependencies注意一个关键点MCP Server 通过 stdout 输出协议响应所以日志绝对不能打到 stdout否则协议流会被日志污染。SLF4J Simple 默认输出到 stderr这个后面我会专门拿一节讲。打包插件用 maven-shade打出一个可执行的 fat jarbuild plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.2/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.example.mcp.McpStdioServer/mainClass /transformer /transformers /configuration /execution /executions /plugin /plugins /build这样打好包后Claude 的配置只需要java -jar /path/to/system-status-mcp.jar一行命令。3. 核心实现手写一个可用的 MCP Server3.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 MapString, 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/callMCP 的协议流程很固定。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.jsonmacOS 通常是~/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 projectclaude 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.0CPU 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 时把签名文件排除掉否则启动时会报SignatureExceptionfilters filter artifact*:*/artifact excludes excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters中文乱码问题前面说过启动命令最好加-Dfile.encodingUTF-8并且在代码里显式用 UTF-8 读取 stdinjava -Dfile.encodingUTF-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 的路子完全能撑得住。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价