精简MCP Client 实现 Java版

从一行 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"
    }
  }
}

这里的 idmethodresulterror 和 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());

这里结果来自 Callablereturn。线程池执行完任务,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_changednotifications/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 的意义主要有三点:

  1. 理解协议边界:知道 MCP 业务语义和 JSON-RPC 消息机制分别解决什么问题;
  2. 控制 Runtime 集成:可以在工具调用前后接入 HITL、PathGuard、CommandGuard、AuditLog 和本地生命周期;
  3. 验证 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 负责治理和扩展。

相关推荐
掰头战士3 小时前
AgentLoop: 从 while(true) 到生产级循环
typescript·llm·agent
张忠琳3 小时前
【deepseek-harness】DeepSeek Harness Agent Loop 模块深度架构分析之二
ai·agent·deepseek·harness·dsh
用户8082598666873 小时前
给 Agent 装上记忆:多轮对话的历史管理——token 预算、按轮裁剪与滚雪球摘要
agent
用户8082598666873 小时前
给 Agent 接上知识库:RAG 检索链路——分块策略、混合检索与重排
agent
山间小僧3 小时前
「AI学习笔记」Agent Memory(一)会话内记忆
aigc·agent·vibecoding
染指11106 小时前
113.Agent-LangChain核心组件-大模型Short-term_memory短期记忆和PostgreSQL记忆存储
人工智能·langchain·agent·agents
用户283209679376 小时前
Function Calling 只是开始:Agent 工具系统到底难在哪
agent
AaronLou6 小时前
Effect 的类型报错怎么读:认全 `Effect<A, E, R>` 这三个位置,一半报错自己就解释了
agent
AaronLou6 小时前
TypeScript 后端那些你自己手写的样板,Effect 一次性收掉
agent