从一行 JSON 到一个可扩展的 Agent 工具世界: 精简版 MCP Client 实现
MCP 并不神秘。它只是把"模型想调用什么"和"程序真正执行什么"之间,铺好了一条标准化的路。
写在前面:为什么还要造一个 MCP Client?
第一次接触 MCP 时,很容易产生一个直觉:既然外部工具最终也要变成 Agent 的 Tool,为什么不直接在 Java 里写几个方法,然后注册到 ToolRegistry?
这个想法在工具很少、代码全部由自己维护时完全成立。问题是,Agent 一旦走出 Demo,就会遇到另一种现实:浏览器工具可能来自 Node.js,数据库工具可能来自 Python,Git 工具可能由第三方维护;它们有不同的生命周期、不同的版本节奏,甚至运行在另一台机器上。
这时,真正需要解决的就不再是"如何调用一个方法",而是:
text
如何发现一个外部工具?
如何知道它需要什么参数?
如何跨语言、跨进程或跨网络调用它?
多个请求同时在途时,响应如何不串线?
工具变化后,Agent 如何感知并热更新?
PaiCLI 的做法是实现一条精简但完整的 MCP 核心链路:用 JSON-RPC 2.0 作为消息外壳,用 stdio 和 Streamable HTTP 作为运输方式,再把发现到的外部工具桥接进统一的 ToolRegistry。
这里的"精简"很重要:它覆盖常用的 initialize、工具发现、工具调用、资源、通知和两种 Transport,但不是官方 SDK 的完整替代品。OAuth、Sampling、完整恢复机制等高级能力,仍然属于后续演进方向。
一、先把问题拆到最简单
最简单的跨进程调用其实只需要三步:
text
PaiCLI 写一段 JSON
→ MCP Server 执行工具
→ PaiCLI 读回一段 JSON
单请求、单线程下,这已经足够。但真实 Agent 运行起来后,问题会一层层冒出来。
问题一:外部工具不在当前 JVM
Java 进程不能直接调用 Node.js 进程里的 JavaScript 函数。两者之间必须有 IPC 或网络协议。
问题二:多请求响应可能乱序
text
先发 tools/list
再发 tools/call
结果却先返回 tools/call
如果只看发送顺序,客户端无法判断某个结果属于哪一次请求。
问题三:不能让每个请求线程抢着读 stdout
stdio 是一条共享输入流。让多个线程同时 readLine(),就像让几个人同时从同一个快递口抢包裹:谁拿到哪一件不可控,通知消息也可能被误当成普通响应。
问题四:响应在未来到达,如何交给原请求?
发送请求的线程已经在等待,而响应是在未来由公共读取线程收到。需要一种机制,让"收到响应的线程"主动唤醒"等待响应的线程"。
于是,设计自然演进成:
text
请求 ID 解决跨进程身份问题
单一 reader 解决共享 stdout 竞争问题
pending Map 解决 ID 到等待对象的查找问题
CompletableFuture 解决异步结果交付问题
二、整体架构:每一层只做一件事
PaiCLI 没有把所有代码塞进一个巨大的 callTool(),而是把链路拆成了几层:
text
Agent / Worker
│ LLM Tool Calling
▼
ToolRegistry
│ 找到本地 Tool 或 MCP Bridge
▼
McpClient
│ MCP 业务语义
▼
JsonRpcClient
│ JSON-RPC 2.0 消息与请求关联
▼
McpTransport
├── StdioTransport
└── StreamableHttpTransport
▼
MCP Server
ToolRegistry:Agent 的统一入口
模型不需要知道底层是 Java Lambda、Node.js Server 还是远程 HTTP 服务。它只看到一个稳定的 Tool Schema。
MCP 工具注册时会加上 Server 命名空间,例如:
text
mcp__filesystem__read_file
mcp__git__status
命名空间解决同名工具冲突,也让权限和审计可以按 Server 维度追踪。
McpClient:懂 MCP,不懂运输细节
McpClient 提供的是 MCP 语义接口:
text
initialize()
listTools()
callTool()
listResources()
readResource()
listPrompts()
它负责把工具名和参数组织成 tools/call 的业务参数,也负责将 Server 返回的 MCP Content 转成 PaiCLI 的 ToolOutput。
JsonRpcClient:懂消息,不懂工具
JsonRpcClient 不关心当前调用的是天气查询还是文件读取。它只负责:
- 生成请求 ID;
- 封装 JSON-RPC Request;
- 保存等待中的请求;
- 分发 Response 与 Notification;
- 处理超时和异常。
Transport:只负责把消息运过去
Transport 的接口非常小:
java
void send(JsonNode message)
void onReceive(Consumer<JsonNode> listener)
void close()
正因为接口小,上层不用关心消息究竟穿过了子进程管道,还是 HTTP 连接。
三、MCP 与 JSON-RPC:一层业务,一层外壳
一次 MCP 工具调用包含两层结构。
MCP 业务结构
json
{
"name": "read_file",
"arguments": {
"path": "README.md"
}
}
这是 McpCallToolRequest 负责的内容:调用哪个工具,以及传什么参数。
JSON-RPC 2.0 消息外壳
json
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {
"path": "README.md"
}
}
}
这里的 id、method、result、error 和 Notification 规则属于 JSON-RPC 层。
可以用一句话区分:
text
MCP:这次业务要做什么?
JSON-RPC:这条消息是谁发的、属于哪次请求、结果如何返回?
四、一次真实的工具调用
假设用户让 Agent 读取 README,模型返回:
text
LLM Tool Call:mcp__filesystem__read_file
参数:{"path":"README.md"}
完整路径如下:
text
LLM
→ ToolRegistry 找到 mcp__filesystem__read_file
→ Bridge 调用 filesystem 对应 McpClient
→ McpClient 构造 tools/call 参数
→ JsonRpcClient 生成 requestId=10
→ StdioTransport 写入 Server stdin
→ filesystem MCP Server 执行读取
→ Server 向 stdout 返回 id=10 的 Response
→ stdout reader 交给 JsonRpcClient
→ JsonRpcClient 完成 requestId=10 对应的 Future
→ McpClient 解析 content
→ ToolRegistry 返回 ToolOutput
→ Agent 用原始 LLM Tool Call ID 回灌 tool message
→ LLM 继续 ReAct
注意这里有两个 ID:
text
LLM Tool Call ID:用于 LLM ↔ Agent 的 tool message 对应
JSON-RPC Request ID:用于 PaiCLI ↔ MCP Server 的 Response 对应
它们处在不同协议边界,不能混为一谈。
五、为什么是 CompletableFuture,而不是普通 Future?
普通 Future 最适合什么?
java
Future<String> future = executor.submit(() -> calculateLocally());
这里结果来自 Callable 的 return。线程池执行完任务,Future 自己就有结果了。
MCP 的结果从哪里来?
MCP 的结果不是某个本地 Callable 返回的,而是:
text
请求线程发出消息并等待
stdout reader 在未来收到响应
reader 线程把响应交给原请求线程
因此需要读取线程主动执行:
java
future.complete(response);
future.completeExceptionally(error);
CompletableFuture 不是线程,也不是跨进程对象。它只是 JVM 内的一个"未来结果盒子":请求线程可以 get() 等待,响应线程可以 complete() 填入结果。
pending Map 如何工作?
java
ConcurrentHashMap<Long, CompletableFuture<JsonNode>> pending;
发送前:
text
requestId = 10
future10 = new CompletableFuture<>()
pending.put(10, future10)
发送带 id=10 的 JSON-RPC Request
请求线程等待 future10
收到响应:
text
Response.id = 10
→ pending.remove(10)
→ future10.complete(result)
→ 原请求线程从 future.get() 返回
为什么是 ConcurrentHashMap?因为请求线程、stdout reader 和超时清理线程可能同时读写 pending。Map 保证映射结构安全,但不保证工具执行的外部副作用安全。
六、stdio:两个进程、三条流
stdio Transport 的方向站在 Server 视角看:
text
MCP Server stdin:接收请求
MCP Server stdout:输出协议响应
MCP Server stderr:输出日志
在 PaiCLI 的 Java Process API 中则是:
text
process.getOutputStream() → Server stdin
process.getInputStream() ← Server stdout
process.getErrorStream() ← Server stderr
为什么是 stdout reader?
PaiCLI 不需要"读取 stdin",因为 stdin 是 PaiCLI 写给 Server 的方向。它需要读取的是 Server 的 stdout,所以代码里会启动:
text
paicli-mcp-stdio-stdout
paicli-mcp-stdio-stderr
stdout reader 负责读取 JSON-RPC Response 和 Notification;stderr reader 只负责排空日志并保留最近几行。
如果不读 stderr,Server 的日志写满操作系统管道后,Server 可能阻塞,看起来就像工具调用卡死。这是一个很典型的"看似协议问题,实际是 OS 管道问题"。
为什么不能为每个请求各开一个 stdout reader?
因为多个线程会争抢同一个输入流:
text
请求 A reader 读到请求 B 的响应
请求 B reader 读到 Notification
请求 C reader 读到请求 A 的响应
所以设计选择是:
text
一个 reader 统一收消息
→ requestId 解复用到不同 Future
→ Notification 交给通知路由
这在思想上类似"多个逻辑请求共享一条通道,再按 ID 解复用",但它不是自己实现 Linux epoll。
七、Notification 与工具热更新
Request/Response 是"我问你答",Notification 则是"我告诉你,不需要回复"。例如:
json
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
PaiCLI 的处理路径是:
text
stdout reader
→ JsonRpcClient 判断没有 id
→ NotificationRouter
→ 独立通知线程
→ McpClient.listTools()
→ ToolRegistry 替换该 Server 的工具
为什么一定要独立通知线程?假设 reader 线程收到通知后,直接在自己内部调用 tools/list 并等待结果:
text
reader 线程发出 tools/list
reader 线程等待 response
response 也需要 reader 线程读取
reader 等自己,形成死锁
因此 reader 只负责"快速转交",真正的刷新逻辑由通知执行器完成。notifications/resources/list_changed 和 notifications/resources/updated 则分别触发资源缓存失效。
八、Streamable HTTP:同一套协议,换一种运输方式
远程 MCP Server 不一定由 PaiCLI 启动。StreamableHttpTransport 通过 HTTP POST 发送 JSON-RPC,并兼容两种响应:
text
application/json:直接返回 JSON
text/event-stream:从 SSE data 事件中解析 JSON
Server 可能通过响应 Header 返回:
text
Mcp-Session-Id: session-123
PaiCLI 保存这个 Session ID,并在后续请求中继续携带。关闭时使用 DELETE 释放会话。
远程鉴权当前采用配置式 Header,例如:
json
{
"headers": {
"Authorization": "Bearer ${MCP_TOKEN}"
}
}
这里要把两件事分开:
text
Authorization Header:身份认证
Mcp-Session-Id:会话关联
当前项目没有实现 MCP OAuth 流程,因此不能把 Header 鉴权描述成完整 OAuth 支持。
九、MCP Server 如何接入 ToolRegistry?
初始化阶段:
text
initialize
→ notifications/initialized
→ tools/list
→ 读取 name / description / inputSchema
→ 构造 McpToolDescriptor
→ 增加 mcp__server__ 前缀
→ 注册到 ToolRegistry
调用阶段:
text
LLM 选择 namespaced tool
→ ToolRegistry 找到对应 invoker
→ invoker 调用绑定的 McpClient
→ McpClient.callToolOutput(原始工具名, 参数)
→ tools/call
→ 返回 ToolOutput
因此 Agent 的工具视角是统一的:
text
本地 Java Tool 和远程 MCP Tool 都是 ToolRegistry 中的 Tool
差别只存在于 ToolRegistry 后面的执行适配层。
十、为什么不直接使用官方 MCP SDK?
直接使用 SDK 是生产系统很合理的选择,它能承担协议版本演进和兼容性成本。
手写这版 Client 的意义主要有三点:
- 理解协议边界:知道 MCP 业务语义和 JSON-RPC 消息机制分别解决什么问题;
- 控制 Runtime 集成:可以在工具调用前后接入 HITL、PathGuard、CommandGuard、AuditLog 和本地生命周期;
- 验证 Agent 基础设施:把跨进程通信、异步响应关联、通知死锁和工具动态注册真正跑通。
更适合生产化的演进方式通常是:
text
官方 MCP SDK:承担标准协议和版本兼容
+
自研 Adapter:负责 ToolRegistry、权限、审计、事件和产品体验
所以"手写 MCP"不是为了证明 SDK 没用,而是为了理解底层并掌握自己的扩展边界。
十一、这套实现的边界
当前实现已经覆盖 MCP 的常用核心路径,但仍有边界:
- 没有实现 OAuth 授权流程;
- 没有完整覆盖 Sampling、Roots、Progress、Cancellation;
- Streamable HTTP 侧主要做 JSON/SSE 响应兼容,不是完整的长期双向事件平台;
- Worker 共享项目工作区时,文件写入冲突仍需更强的资源锁或 worktree 隔离;
- 协议升级和跨 Server 兼容性测试需要持续维护。
能清楚说出边界,反而比声称"完整实现了 MCP SDK"更可信。
十二、结语:所谓中间层,不过是把问题放对地方
回头看,MCP Client 并不是凭空多出来的复杂度:
text
直接调用 Java 方法
→ 无法接入跨语言外部工具
→ 引入 MCP Server
直接收发 JSON
→ 并发响应无法匹配
→ 引入 JSON-RPC Request ID
每个请求线程读取 stdout
→ 共享输入流竞争
→ 引入单一 stdout reader
reader 收到响应
→ 不知道交给哪个请求线程
→ 引入 pending Map
普通 Future 无法由 reader 主动填值
→ 引入 CompletableFuture
通知处理阻塞 reader
→ 可能自等待死锁
→ 引入独立 NotificationRouter executor
最后形成的并不是"层层包装的炫技",而是一条职责清楚的链路:
text
Agent Tool Calling
→ ToolRegistry
→ McpClient
→ JsonRpcClient
→ stdio / Streamable HTTP
→ MCP Server
→ Tool Result
→ Agent ReAct 上下文
这就是 精简版 MCP Client 的设计思路:让模型负责选择工具,让协议负责可靠通信,让 Runtime 负责治理和扩展。