说实话,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 没有 Client,Server 就是个孤儿
先摆正一个认知: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 子进程、HTTP+SSE、或者流式 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的依赖后,里面主要分两类 API:io.modelcontextprotocol.spec是协议规范层的消息定义、类型定义;io.modelcontextprotocol.client才是真正的 client 实现。还有些辅助的 transport 包。版本上我建议直接用最新稳定版,因为 MCP 规范还在快速演进,老版本可能有协议字段缺失问题。用 Maven 的话大致长这样:
<dependency> <groupId>io.modelcontextprotocol</groupId> <artifactId>mcp</artifactId> <version>0.10.0</version> </dependency>需要注意:这个 SDK 为了兼容,核心逻辑大量基于 Reactor 的Mono/Flux,所以即使你只写一个同步工具,返回值长的也不是普通 Java 对象,而是一层层包裹的异步流。官方为此提供了McpSyncClient这个同步包装层,建议先从这个入手。
2.2 Transport 层:Client 的“入口和出口”
transport 在 MCP client 里的角色就是“消息的管道”:收 JSON-RPC 消息进来,把发送请求推出去。SDK 里常见的两条路:
StdioClientTransport:Client 在本地起一个子进程,通过 stdin/stdout 和作为 server 的进程通信。适合本地工具类 server,比如 Claude Code 启动一个 node 的 MCP server。HttpClientSseClientTransport:通过 HTTP 建立 SSE 流,服务端在远端。适合部署在服务器上的 MCP server,比如你给团队部署的共享工具网关。
我在项目里两条路都踩过,姿态上的核心差异是:stdio 子进程的生命周期是 client 管的,写完代码必须记得调close(),否则子进程会成为僵尸进程;HTTP+SSE 则要处理断线重连的问题,连接被服务端关闭后Flux会发出onComplete,你需要自己决定是否重连。
2.3 同步还是异步:开发体验的分水岭
SDK 同时提供McpAsyncClient和McpSyncClient。异步版本灵活、能接onError回调,但代码写出来容易嵌套多层,调试的时候全靠 log 兜底。同步版本内部把异步都 block 住,调用链路更直白,适合工具类场景。
我的建议是:你的业务代码如果本身是 WebFlux 那套,直接用异步 client,配合flatMap做链式调用很丝滑;如果是传统 Spring MVC 之类的阻塞模型,老老实实用McpSyncClient,不然block()出现在不该出现的地方会引发诡异问题。
3. 实操:从零写一个最小可用的 Java MCP Client
3.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 里的入参是Map<String, Object>,值可以是基础类型、Map、List,最终会被序列化成 JSON 发送给 server。调用一个加法工具的代码:
Map<String, 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 抛异常,而是正常返回一个isError=true的响应。你如果不看这个标记直接解析 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 就要走 HTTP+SSE。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 error=2, 系统找不到指定的文件。这通常不是代码问题,是环境变量的问题。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 的Map<String, Object>在序列化时,Long和Integer都是以整数输出,但如果某个值是从 JSON 字符串解析来的,可能已经成了BigDecimal,序列化结果就变成1.0甚至科学计数法格式,一个整数类型直接没戏。
处理方式:不要直接拿反序列化出来的原始类型当工具入参,自己写一层参数转换,显式按 schema 要求构造类型结构。不要嫌麻烦,这一步能省下你和 Server 端开发人员互相甩锅的时间。
4.4 服务器返回的 MCP 错误被静默吞掉
MCP 的响应结构里,错误信息有时不是 JSON-RPC 的 error 字段,而是正常返回的content数组里塞了一段文本,标记isError=true。如果你的 client 只管content而忽略isError,用户就会看到一段“工具执行失败”的原始文本,不会看到 stack trace 和错误类型。
我在写 client 时加了一个统一出口:任何isError=true的响应都封装成自定义异常,日志里输出 server 返回的完整内容,同时把structuredContent(如果有的话)提取出来。这样出了问题可以直接从日志定位。
4.5 连接泄露与进程残留
这是一个看起来不起眼但很恶心的坑。McpSyncClient使用完毕必须调用close(),否则底层 Reactor 的连接和线程资源不释放。而 stdio transport 的 close 还会负责杀掉子进程。如果你在循环里频繁创建 client 而不 close,系统会逐渐出现大量僵死 node 进程,最后把内存和端口资源耗尽。
我习惯用 try-with-resources 就不安全,因为它直接实现了AutoCloseable,但有的老版本可能没有,所以写代码前先确认一下接口签名。线上跑了几天之后,我加了一个启动参数-Dreactor.netty.ioWorkerCount=4控制线程数,效果比默认配置稳定。
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(); Mono<InitializeResult> initMono = asyncClient.initialize(); initMono.subscribe(result -> System.out.println("初始化完成:" + result.serverInfo())); asyncClient.listTools() .flatMapMany(tools -> Flux.fromIterable(tools.tools())) .flatMap(tool -> { Map<String, 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里,内部用ConcurrentHashMap<ServerId, 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 显式装箱 |
| isError=true 被忽略 | 误以为错误必须抛异常 | 统一检查 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)的实战记录,到时候那些适配细节和参数坑都能直接抄作业。