说实话MCP 这个概念从 2024 年底火到现在绝大多数人的关注点其实都停在“连个 server 用用”的层面——比如往 Cursor、Codex 里塞个 Figma MCP、Playwright MCP能跑就行。但真正到了要自己动手开发 mcp client 的时候很多人就懵了要么照着官方仓库的 demo 抄跑通了但不知道每一步在干嘛要么被 SDK 里的异步回调、传输层抽象、JSON-RPC 生命周期绕晕卡在“工具调用没反应”这种问题上大半天。这篇是“知识体系——MCP”系列的第三篇前两篇分别聊了 MCP 协议规范本身和 Server 侧开发基于 io.modelcontextprotocol.sdk。这篇专门讲 Client 侧用 Java SDK 怎么从零写一个能用的 MCP 客户端把io.modelcontextprotocol.sdk里 client 相关的核心类、初始化握手流程、工具调用链路、以及我最想吐槽的几个坑全过一遍。适合已经写过 MCP server、或者至少跑通过现成 MCP 工具现在想自己做客户端的开发者。1. Client 在 MCP 架构里的真实定位1.1 没有 ClientServer 就是个孤儿先摆正一个认知MCP 协议里 Server 是“能力提供方”但它自己是不会主动干活的。工具定义、资源内容、提示词模板全部摆在 Server 那边等着某个 Client 来发现和调用。换句话说你之前用的 Cursor、Claude Code、Codex 里面那个“MCP 配置”按钮本质上就是帮你内置了一个 mcp client。你配的mcp.json里写的command和args就是 client 启动并连接 server 的指令。所以当你在热搜里看到一堆“cursor 打开 mcp”“codex 配置 figma mcp”“blue:---- 这类关键词”时要意识到这些工具各自内置的 client 实现都不一样——有的只实现了 tools 调用有的连 resources 也支持有的对 SSE 支持得很烂。用别人的 client 永远是黑盒你无法控制超时时间、无法自定义工具调用前的参数校验、没法做缓存和审计。自己开发 client 的核心价值就是拿回控制权知道握手是怎么完成的、每个字节从哪里来、失败的时候到底是哪一端掉了链子。1.2 一个 Client 需要完成的最小职责从协议角度看一个标准的 mcp client 至少要干四件事建立与 server 的传输通道stdio 子进程、HTTPSSE、或者流式 HTTP。完成initialize握手协商协议版本和能力集合。通过tools/list、resources/list、prompts/list发现服务端能力。代表用户执行tools/call、resources/read、prompts/get等操作并处理结果。别小看这四步。很多新手写 client 的时候直接跳过第 2 步就发tools/call结果 server 端协议状态机直接报错“Client not initialized”或者直接静默断开。MCP 的握手不是摆设它是 JSON-RPC 2.0 之上的状态化流程双方必须在特定阶段才能发特定消息。1.3 Java SDK 为什么值得学虽然现在有 Python、TypeScript 的 SDK但 Java 生态在中间件、平台型系统里仍然有不可替代的地位。你如果要在自己的 Java 后端系统里接入 MCP 能力比如让内部 BI 平台通过 MCP 调用一个 Playwright server 去抓页面截图、让运维平台通过 MCP 调 IDA 插件做自动化逆向分析用io.modelcontextprotocol.sdk是最合理的选择。官方 SDK 的问题在于文档偏薄很多 API 要翻源码才知道用法这也是这篇文章想帮你解决的问题。2. 开发前必须搞懂的核心抽象2.1 包结构和版本选择拿到io.modelcontextprotocol.sdk的依赖后里面主要分两类 APIio.modelcontextprotocol.spec是协议规范层的消息定义、类型定义io.modelcontextprotocol.client才是真正的 client 实现。还有些辅助的 transport 包。版本上我建议直接用最新稳定版因为 MCP 规范还在快速演进老版本可能有协议字段缺失问题。用 Maven 的话大致长这样dependency groupIdio.modelcontextprotocol/groupId artifactIdmcp/artifactId version0.10.0/version /dependency需要注意这个 SDK 为了兼容核心逻辑大量基于 Reactor 的Mono/Flux所以即使你只写一个同步工具返回值长的也不是普通 Java 对象而是一层层包裹的异步流。官方为此提供了McpSyncClient这个同步包装层建议先从这个入手。2.2 Transport 层Client 的“入口和出口”transport 在 MCP client 里的角色就是“消息的管道”收 JSON-RPC 消息进来把发送请求推出去。SDK 里常见的两条路StdioClientTransportClient 在本地起一个子进程通过 stdin/stdout 和作为 server 的进程通信。适合本地工具类 server比如 Claude Code 启动一个 node 的 MCP server。HttpClientSseClientTransport通过 HTTP 建立 SSE 流服务端在远端。适合部署在服务器上的 MCP server比如你给团队部署的共享工具网关。我在项目里两条路都踩过姿态上的核心差异是stdio 子进程的生命周期是 client 管的写完代码必须记得调close()否则子进程会成为僵尸进程HTTPSSE 则要处理断线重连的问题连接被服务端关闭后Flux会发出onComplete你需要自己决定是否重连。2.3 同步还是异步开发体验的分水岭SDK 同时提供McpAsyncClient和McpSyncClient。异步版本灵活、能接onError回调但代码写出来容易嵌套多层调试的时候全靠 log 兜底。同步版本内部把异步都 block 住调用链路更直白适合工具类场景。我的建议是你的业务代码如果本身是 WebFlux 那套直接用异步 client配合flatMap做链式调用很丝滑如果是传统 Spring MVC 之类的阻塞模型老老实实用McpSyncClient不然block()出现在不该出现的地方会引发诡异问题。3. 实操从零写一个最小可用的 Java MCP Client3.1 准备一个调试用的 Server写 client 前必须有一个稳定的调试对象。最快的方式是找一个现成的 node 写的 MCP server。本地起一个npx的 server 当靶机比直接连线上 server 好在对端行为可控。你也可以用之前的系列文章里自己写的那个 Java server如果已经留了 jar 包直接用java -jar跑起来。调试连接前先手动curl或者直接用你看得上眼的 MCP inspector 验证一下 server 能正常响应别一上来就是 client 这边查半天结果发现是 server 进程根本没起来。3.2 Stdio Transport 的完整流程第一步是构建 transport随后创建 client。// 1. 构造 stdio transport指定启动 server 的命令 var transport new StdioClientTransport(new StdioClientTransport.Builder() .command(npx) .args(List.of(-y, modelcontextprotocol/server-everything)) .build()); // 2. 通过 transport 创建异步 client McpAsyncClient asyncClient McpClient.async(transport) .capabilities(ClientCapabilities.builder() .roots(true) .sampling() .build()) .build(); // 3. 同步包装使用 McpSyncClient client asyncClient.blocking();这里面capabilities很容易被忽略。你的 client 声明支持什么能力server 在 initialize 阶段会根据你的声明决定给你发什么配置数据。比如你声明了roots(true)server 可能会要求你提供 root 资源列表如果你完全不声明初始化时对方还能省点事。我的习惯是非必要不声明多余能力声明了就得实现对应的回调。接下来是握手和发现能力// 4. 初始化握手协商版本 InitializeResult initResult client.initialize(); System.out.println(协商的协议版本: initResult.protocolVersion()); System.out.println(Server 信息: initResult.serverInfo()); // 5. 列出所有工具 ListToolsResult tools client.listTools(); for (Tool tool : tools.tools()) { System.out.println(发现工具: tool.name() - tool.description()); }跑通这段代码你的 client 就已经拿到了 server 的“菜单”。这里有一个很反直觉的点initialize 之后要发一条 notification告诉 server“我初始化完了”。如果你用的是同步 client 的initialize()这个 notification 会被自动发送但如果你手工调用异步 API漏了sendInitializedNotification()工具调用阶段可能会收到奇怪的超时。就这个细节我至少见过三次排查半天最后发现是漏发 notification 的案例。3.3 工具调用带参数校验的完整实现拿到 tools 列表之后核心操作就是tools/call。工具调用在 SDK 里的入参是MapString, Object值可以是基础类型、Map、List最终会被序列化成 JSON 发送给 server。调用一个加法工具的代码MapString, Object args new HashMap(); args.put(a, 10); args.put(b, 32); CallToolResult result client.callTool(new CallToolRequest(add, args)); if (result.isError()) { System.err.println(工具执行失败: result.content()); } else { for (Content content : result.content()) { if (content instanceof TextContent text) { System.out.println(结果: text.text()); } } }这里result.isError()必须检查。很多 SDK 封装里工具的业务失败并不会导致 client 抛异常而是正常返回一个isErrortrue的响应。你如果不看这个标记直接解析 content拿到一段错误文本时会一头雾水。另一个细节是参数类型。JSON Schema 里number类型在 Java 这边推荐用Integer或Double时一定要看 server 端 schema 怎么定义。有的 server 定义的是type: integer你传一个Double过去会被序列化成10.0对方反序列化时如果严格按Long接就可能报类型错误。踩过几次之后我的习惯是在打包参数时写一个小工具方法根据 schema 的type决定装箱类型别偷懒。3.4 资源读取与提示词获取资源和提示词在实际业务中用的比工具少但一个完备的 client 值得把接口都实现一遍。资源读取要和 server 在握手阶段交互如果 server 声明支持 resources会给你发resources/list的结果client 发起resources/read时要带上 URI。// 读取一个文本资源 ReadResourceResult readResult client.readResource(new ReadResourceRequest(file:///demo.txt)); for (ResourceContents content : readResult.contents()) { if (content instanceof TextResourceContents textContent) { System.out.println(textContent.text()); } }注意有些 server 的资源是动态生成的每次 read 的结果都可能不同这很正常别把资源当成静态文件去缓存。提示词获取prompts/get则是把“模板字符串 参数插值”的逻辑交给 server 做client 更像一个参数收集器。对于做 Agent 工具链的同学这部分才是真正能提升 LLM 交互质量的地方——通过 MCP 的 prompts 模板把系统提示词、few-shot 样例统一放在 server 侧管理多个 client 之间还保持逻辑一致。3.5 HTTP SSE 方式连接远程 Server有时候 server 不在本地比如你给局域网内的团队部署了一个 MCP 服务client 就要走 HTTPSSE。SDK 的写法也很直接HttpClientSseClientTransport transport new HttpClientSseClientTransport( URI.create(http://192.168.1.100:8080/mcp)); McpSyncClient client McpClient.sync(transport).build();走 HTTP 之后原来 stdio 那种“client 管进程生命周期”的模式就变了连接状态变成 client 和服务端共同维护。SSE 连接断开后有的 SDK 实现会自动尝试重连但重连是否触发取决于传输层的配置。我把超时时间调过很多次最终用的是 30 秒。结合热搜里那个“codex_apps timed out after 30 seconds”的报错我的体会是MCP 的超时千奇百怪但要先分清是网络超时还是协议握手超时别一超时就盲目调大数值——如果是协议阶段卡住调大多久都没用。4. 你做 Client 时几乎必然踩到的坑4.1 子进程启动失败黑盒中的第一根刺用 stdio transport 时最常见的报错是java.io.IOException: Cannot run program npx: CreateProcess error2, 系统找不到指定的文件。这通常不是代码问题是环境变量的问题。SDK 起子进程用的是ProcessBuilder它继承当前 Java 进程的 PATH。你在 IDE 里启动 client 时IDE 的 PATH 可能不包含 Node 的安装路径。解决方式有两个一是把 server 命令的绝对路径写进 transport比如/usr/local/bin/npx二是手动把 Node 的 bin 目录加进程序启动时的 PATH。我推荐前者因为后者影响面太大容易干扰别的程序。你要是连node -v都输出为空那就先解决 Node 环境再说。4.2 initialize 完成之后立刻调用工具却超时这个问题我在 3.2 提过漏发 initialized notification。它的典型表现是 initialize 正常返回了 server 信息但callTool之后请求石沉大海直到请求超时。原因在于 server 的实现通常会监听 initialized 事件才会把 client 状态置为“可用”。用同步 client 时 SDK 虽然内部处理了但如果你的代码混用异步 API比如从某个Flux回调里手动调了callTool就可能绕过自动通知逻辑。我的排查心得抓包或者看 server 端日志。如果 server 端日志里根本没出现tools/call的记录那大概率是消息被协议状态机挡在门外了优先检查初始化通知。4.3 参数序列化不一致工具存在但永远报参数错误有些 server 工具定义里标注了required是[a, b]你的代码也传了这两个 key但 server 说校验失败。这种事通常发生在 float、long、嵌套对象这三个地方。Java 的MapString, Object在序列化时Long和Integer都是以整数输出但如果某个值是从 JSON 字符串解析来的可能已经成了BigDecimal序列化结果就变成1.0甚至科学计数法格式一个整数类型直接没戏。处理方式不要直接拿反序列化出来的原始类型当工具入参自己写一层参数转换显式按 schema 要求构造类型结构。不要嫌麻烦这一步能省下你和 Server 端开发人员互相甩锅的时间。4.4 服务器返回的 MCP 错误被静默吞掉MCP 的响应结构里错误信息有时不是 JSON-RPC 的 error 字段而是正常返回的content数组里塞了一段文本标记isErrortrue。如果你的 client 只管content而忽略isError用户就会看到一段“工具执行失败”的原始文本不会看到 stack trace 和错误类型。我在写 client 时加了一个统一出口任何isErrortrue的响应都封装成自定义异常日志里输出 server 返回的完整内容同时把structuredContent如果有的话提取出来。这样出了问题可以直接从日志定位。4.5 连接泄露与进程残留这是一个看起来不起眼但很恶心的坑。McpSyncClient使用完毕必须调用close()否则底层 Reactor 的连接和线程资源不释放。而 stdio transport 的 close 还会负责杀掉子进程。如果你在循环里频繁创建 client 而不 close系统会逐渐出现大量僵死 node 进程最后把内存和端口资源耗尽。我习惯用 try-with-resources 就不安全因为它直接实现了AutoCloseable但有的老版本可能没有所以写代码前先确认一下接口签名。线上跑了几天之后我加了一个启动参数-Dreactor.netty.ioWorkerCount4控制线程数效果比默认配置稳定。4.6 超时配置到底应该放在哪一层刚才提到 30 秒超时问题这里展开说一下。MCP client 的超时发生在好几层HTTP transport 的连接超时SSE 流等待消息的读超时JSON-RPC 请求等待响应的请求超时。热搜里那个codex_apps timed out after 30 seconds的场景多半是发生在请求等待响应这一层。SDK 里McpClient的默认请求超时我没有具体量化但实测下来不调大确实容易在慢工具上栽跟头。调的时候别全局乱改先想清楚是哪一类工具慢再针对性设置。5. 进阶异步回调、采样与 Roots 的完整链路5.1 用异步 client 处理并发工具调用如果你的场景是一次要调多个工具同步一个个 block 的效率很低。异步 client 的优势就体现出来了McpAsyncClient asyncClient McpClient.async(transport).build(); MonoInitializeResult initMono asyncClient.initialize(); initMono.subscribe(result - System.out.println(初始化完成: result.serverInfo())); asyncClient.listTools() .flatMapMany(tools - Flux.fromIterable(tools.tools())) .flatMap(tool - { MapString, Object args Map.of(a, 1, b, 2); return asyncClient.callTool(new CallToolRequest(tool.name(), args)); }) .subscribe(result - System.out.println(调用结果: result));这样多个工具调用是并发执行的能省下不少等待时间。不过用的时候要小心subscribe之后的异常不会直接抛给你的主线程一定要单独接onError或者doOnError做日志记录。我见过好几次生产事故都是因为异步没有错误处理回调里异常直接打到了 stderr。5.2 Sampling 回调让 Client 自己处理 LLM 调用MCP 里 Sampling 是 client 给 server 提供的一种能力回调。也就是说server 在运行过程中如果需要一个 LLM 的补全结果它会向 client 发一条 sampling 请求由 client 调用真正的大模型服务然后把结果返回给 server。这一块国内用的不多因为 server 一般会自己接模型 API。但如果你的 client 是面向企业内部服务的模型调用可能要走统一的网关那实现采样回调就有了价值。SDK 里开启方式是在构建 client 时McpClient.async(transport) .sampling(request - { // request 里有 prompt、modelPreferences 等字段 // 这里调用你自己的 LLM 网关 return Mono.just(new CreateSamplingResult(...)); }) .build();这个回调如果返回Mono.empty()Server 可能直接“失联”。处理策略是企业内部用场景里我会在回调里做熔断——连续失败三次就快速失败防止拖垮业务主链路。5.3 Roots 的“根”到底有什么用Roots 机制是告诉 server“你可以访问我这些目录/资源”。在 Java client 上实现 Roots 要往RootsProvider里注册根路径。它的持久化很弱roots/list并不会自动持久化每次 client 启动都要重新设置。用途上它更适合 IDE 插件这类场景——告诉 MCP server 当前打开的项目目录在哪Server 才能在沙箱内帮你去读写相对路径。很多人忽略 Roots 是因为对它的语义理解不够它不是安全边界更像是一种“上下文提示”。Server 可以完全忽略你的 root 列表也可以借它来限制自己的操作范围。别过度依赖它做权限控制安全还是得在 server 实现本身做好。6. 实战经验如何基于 MCP Client 搭建存量系统可用的工具层6.1 Client 应该放在哪个代码层很多 Java 后端团队引入 MCP 时会纠结 client 对象放哪。我的建议是不要在每个请求里现建 client也不要做成无限常驻的单例。用连接池或者按 server 维度缓存 client 是合理的选择。MCP client 虽然比较轻量但底层 transport 有连接资源频繁开关很浪费。我在项目里是把 client 封装在一个McpClientManager里内部用ConcurrentHashMapServerId, McpSyncClient做缓存提供getClient(serverConfig)方法。这样多个 Server 可以并存业务代码也不需要关心 transport 细节。6.2 工具发现结果要不要缓存tools/list的结果在大多数 server 上是相对稳定的每次调用都重新拉一次很浪费。我建议启动时拉一次放到本地缓存然后定期比如每 5 分钟刷新一次。如果业务场景里工具动态增删很频繁再考虑每次实时拉取但那是少数情况。还有一个小细节工具返回的输入 schema 缓存下来以后可以做本地参数校验。在 client 发起callTool前先用json-schema-validator预检一遍参数能挡掉大部分低级错误。这样 server 端也不会因为参数错误产生大量无意义的调用日志。6.3 兼容不同协议的 Server 版本MCP 协议版本协商是initialize阶段完成的。不同 server 版本可能支持不同协议能力你的 client 在解析响应时要尽量宽容。SDK 内部处理得还算好但如果你需要访问响应里某个新增字段就要小心旧版本 SDK 或者旧版 server 下字段不存在的情况建议先判空。我见过有团队直接把serverInfo里的 version 值正则提取出来小于某个版本就禁用某些工具按钮。这种策略可以用但别把这个判断做成“硬失败”降级为“隐藏高级特性”会更稳。7. 调试工具箱如何高效从“跑不起来”到“跑得很顺”7.1 日志怎么打才有效开发 MCP client 最忌讳不看日志直接闷头猜。先在 logback/log4j2 配置里把io.modelcontextprotocol的日志级别调成DEBUG。这样能看到每一次 JSON-RPC 消息的收发内容。如果消息太多可以单独把io.modelcontextprotocol.client.McpClient和io.modelcontextprotocol.transport调成 DEBUG。我还习惯在 transport 层加一层wiretap类似 Netty 的 wiretap把原始字节流也打到日志里。这对排查诡异的编码问题很有用。记住MCP 的错误往往不是简单的异常堆栈而是消息内容的语义错误所以日志必须包含消息全景。7.2 用官方 Inspector 辅助测试虽然这篇讲的是自研 client但调试期间借助官方modelcontextprotocol/inspector帮你快速验证 server 行为能大大缩短定位问题的半径。你自研 client 报错时先用 inspector 连同一个 server看 inspector 是否也报错。如果 inspector 能正常调用而你的 client 报错问题基本在你 client 侧如果 inspector 也报同样错误那大概率是 server 本身的问题。7.3 复现问题的脚本化思路MCP 的交互链路较长问题复现依赖完整上下文。我建议在开发期写一个专门的 demo client把初始化、列工具、调用工具、读资源这几步固定下来每次改完 transport 或者 SDK 版本就跑一遍 demo作为回归用例。这个 demo 类我起名叫DebugMcpClient放在测试源码集下平时不参与业务打包。8. 踩坑汇总速查表现象可能原因解决办法initialize 成功但 tools/call 超时漏发 initialized notification用同步 client 的 initialize 方法或手动补发npx / node 找不到IDE 或父进程 PATH 缺少 Node 路径transport 里指定绝对路径参数序列化类型不对Java 的 Long/Double 和 schema 类型不匹配写参数转换层按 schema type 显式装箱isErrortrue 被忽略误以为错误必须抛异常统一检查 isError 并封装成异常子进程越来越多client close 没调导致进程残留try-with-resources 或显式 close异步没有错误日志subscribe 缺少 error 回调统一 doOnError/onError 打日志30 秒超时请求等待响应超时或工具本身执行慢区分网络和协议超时针对性调整超时参数server 动态新增工具看不到缓存了 tools/list 结果增加定时刷新或手动失效缓存这张表每一条都是我真金白银踩出来的。新手阶段如果能先把表里前三条记住至少能少熬两个通宵。9. 我的一些体会MCP client 在当下的热度被“各种 IDE 内置支持”掩盖了很多开发者觉得没必要自己写。但在真实的企业研发环境中内置 client 往往满足不了定制化需求统一的工具权限控制、细颗粒度的调用审计、与内部系统的参数联动都需要自己做 client 才能实现。如果让我给一个建议先用 stdio transport 同步 client 把闭环跑通再往异步、SSE、多 server 缓存的方向演进。不要一开始就上全套异步加回调不然问题叠加起来会让你怀疑是 SDK 的问题。SDK 本身还在快速迭代遇到诡异问题时先怀疑自己的代码再去翻 SDK 的 issue这样效率最高。后面如果时间允许我打算再写一篇基于这个 Java MCP client 去对接国内几个常见 server比如蓝湖、Figma、Playwright的实战记录到时候那些适配细节和参数坑都能直接抄作业。