我们的内部 Agent 平台需要对接外部 MCP Server,把对方的工具发现并注册进平台,供 Agent 在推理时调用。主流做法是用 Spring AI 的 MCP starter 或官方 SDK,但我们最终选择手写了一个不到 200 行的 JSON-RPC Client。这篇文章讲清楚三件事:为什么手写值得、手写版长什么样、以及它如何与 Spring AI 的 FunctionToolCallback 体系桥接。
1. 先看清 MCP 协议的本质
MCP(Model Context Protocol)听起来很唬人,但剥开传输层,它本质就是 JSON-RPC 2.0 over HTTP。一个工具型 Agent 平台作为 MCP Client,真正需要的方法只有三个:
| 方法 | 功能 | 实现成本 |
|---|---|---|
initialize |
握手,交换 server info | ~5 行 |
tools/list |
列出工具及 Schema | ~15 行 |
tools/call |
调用工具 | ~5 行 |
交互时序:
Agent 平台 (MCP Client) 外部 MCP Server
───────────────────── ──────────────
POST /mcp (JSON-RPC) ─────────→ 握手
{ "method": "initialize" }
POST /mcp (JSON-RPC) ─────────→ 发现工具
{ "method": "tools/list" }
POST /mcp (JSON-RPC) ─────────→ 调用工具
{ "method": "tools/call",
"params": { "name": "...", "arguments": {...} } }
协议这么薄,引入一个带自己生命周期管理、线程模型和版本耦合的 SDK,收益和成本是不对等的。
2. 为什么不用官方 SDK:四个具体理由
2.1 我们需要完全控制 HTTP Header 透传
这是最硬的理由。我们对接的业务方 MCP 网关在标准协议之外有自己的鉴权约定:每个请求必须携带 X-API-Key,涉及终端用户数据的工具还要求 X-End-User-Id 头,由网关在服务端做数据隔离。连接配置里存了一份静态 headers(JSON map 字符串),每次 RPC 都要原样透传;调用期还要把当前请求上下文里的终端用户身份动态提升为请求头。用官方 SDK 做这件事,要么 fork 它的 transport,要么在它的 hook 体系里绕路——都比自己写一个 POST 贵。
2.2 同步阻塞 + 虚拟线程,比 reactive 更适配我们的调用点
工具调用的触发点在推理引擎的工具执行分支里,那里是命令式代码(ToolGateway.execute() 返回一个普通 ToolResult)。官方 Java SDK 的默认传输是 reactive 的,在命令式调用点里 .block() 会很别扭。我们直接用 Spring 的同步 RestClient,配合 Java 21 虚拟线程处理并发(健康检查、启动发现),代码直白且没有背压问题。
2.3 避免依赖与版本耦合
平台本身已经依赖 Spring AI 2.x(模型抽象、工具回调)。再引入 MCP SDK,等于把 MCP 协议演进节奏和 Spring AI 的版本节奏都绑进自己的发布列车。手写之后,我们对协议的依赖面就是三个 method 名和一份响应 JSON 结构——这是 MCP 里最稳定的部分。
2.4 协议之外我们要做的"脏活"本来就多于协议本身
启动时发现并注册工具、失败时保留存量定义、工具名合法性校验、健康检查缓存、按名称去重……这些才是真实工作量,SDK 一概不管。
3. 传输层实现
3.1 Streamable HTTP(主力模式)
新部署的 MCP Server 推荐 Streamable HTTP:一个端点,POST 收发 JSON-RPC。客户端核心如下(包名已脱敏):
package com.example.agent.infrastructure.mcp;
/**
* 轻量 JSON-RPC 2.0 MCP 客户端(同步 RestClient,兼容 reactive 线程调用)。
*/
public class McpHttpClient implements AutoCloseable {
private final RestClient restClient;
/**
* @param headers 透传到每个 JSON-RPC 请求的静态 HTTP Header
* (如业务方网关的 from-source / user_id / X-API-Key)。
*/
public McpHttpClient(String baseUrl, Map<String, String> headers) {
var builder = RestClient.builder().baseUrl(baseUrl);
if (headers != null) headers.forEach(builder::defaultHeader);
this.restClient = builder.build();
}
/** 发送 JSON-RPC 请求。 */
public Map<String, Object> rpc(String method, Map<String, Object> params) {
var body = new LinkedHashMap<String, Object>();
body.put("jsonrpc", "2.0");
body.put("id", UUID.randomUUID().toString().substring(0, 8));
body.put("method", method);
body.put("params", params != null ? params : Map.of());
String resp = restClient.post()
.uri("")
.body(body)
.retrieve()
.body(String.class);
return JsonUtil.toMap(resp);
}
/** 发现工具列表。 */
public List<Map<String, Object>> listTools() {
var resp = rpc("tools/list", null);
var tools = (List<Map<String, Object>>) result(resp).get("tools");
return tools != null ? tools : List.of();
}
/** 调用工具。 */
public String callTool(String name, Map<String, Object> arguments) {
var resp = rpc("tools/call", Map.of("name", name, "arguments", arguments));
var content = (List<Map<String, Object>>) result(resp).get("content");
if (content != null && !content.isEmpty()) {
return (String) content.get(0).getOrDefault("text", resp.toString());
}
return resp.toString();
}
private static Map<String, Object> result(Map<String, Object> rpcResponse) {
return (Map<String, Object>) rpcResponse.getOrDefault("result", rpcResponse);
}
}
注意几个细节:
- 每次新建 client 而不是长驻连接:MCP Server 的配置(URL、headers)允许运行时修改,无状态短连接让我们不用维护连接池失效逻辑。成本是一次 TCP 握手,对工具调用频率来说可忽略。
result()兜底:个别实现会把结果平铺返回而不是包在result字段里,getOrDefault("result", resp)兼容两种。callTool的文本提取:MCP 响应的content是数组,我们取第一个 text 块;拿不到就把整个响应 toString 兜底,绝不让一次奇怪响应炸掉推理流。
3.2 SSE 模式(兼容存量部署)
我们验证阶段对接的一个存量 MCP Server(40+ 个工具)用的是老的 SSE 传输,流程是:
① GET /sse → 建立 SSE 长连接
② 服务器推送 endpoint 事件 (含 message 端点 URL,带 sessionId)
③ POST /mcp/message { jsonrpc: "2.0", method: "tools/list", ... }
④ 服务器通过 SSE 流返回响应
该 Server 还要求先调 auth_login 工具换 session token,后续请求携带。SSE 模式用 WebClient 实现(bodyToFlux(ServerSentEvent.class),监听 endpoint 事件拿到 message 端点)。两种传输实现同一个 McpClient 接口(initialize / listTools / callTool / close),上层无感。经验是:新接入一律谈 Streamable HTTP,SSE 只作为兼容存量部署的备选。
4. 工具发现:启动时注册,失败不丢存量
发现逻辑由 McpToolDiscovery 承担,监听 ApplicationReadyEvent(不要用 ApplicationRunner + Thread.sleep 等 web server 就绪,那是早期踩过的坑):
启动就绪 → 遍历 mcp_server_config WHERE enabled = 1
│
├── McpHttpClient.rpc("initialize", null) → 握手
├── McpHttpClient.listTools() → 拿工具列表
│
└── 对结果先校验、后落库:
1. 工具名必须匹配 ^[a-zA-Z0-9_-]+$(不合法直接拒)
2. 同 server 内 name 去重(重复名视为 server 端 bug,整批放弃)
3. 批量 DELETE 该 server 的旧工具(1 条 SQL)
4. 批量 INSERT 新工具(Db.saveBatch,消除 N+1)
三个刻意的设计决策:
- 先发现成功,再删旧数据。如果 MCP Server 恰好宕机,
listTools()抛异常,存量工具定义原样保留——Agent 至少还能看到工具、拿到一个可理解的调用错误,而不是工具凭空消失。 - toolId 命名空间化:
{serverId}__{toolName}。多个 MCP Server 可能暴露同名工具,serverId 前缀天然隔离;对外暴露给 LLM 的name则保持原样(LLM 看到的是get_customer_profile,不是内部 id)。 - 发现期的占位身份:业务方网关在
X-API-Key鉴权下连tools/list都要求X-End-User-Id(数字)。发现握手与真实用户无关,我们固定传占位值"0"——它只用于拉 schema,真实工具调用由执行层强制传真实身份(见第 6 节)。
5. 健康检查:握手探测 + 30 秒缓存
管理后台需要展示每个 MCP Server 的可达性。健康检查就是一次 initialize 握手——它是协议里最便宜的无副作用调用:
@Service
public class McpHealthService {
private final ConcurrentMap<String, HealthResult> cache = new ConcurrentHashMap<>();
private static final Duration CACHE_TTL = Duration.ofSeconds(30);
private static final Duration HEALTH_TIMEOUT = Duration.ofSeconds(5);
public HealthResult checkHealth(McpServerConfig server) {
var cached = cache.get(server.getServerId());
if (cached != null && Duration.between(cached.timestamp, Instant.now())
.compareTo(CACHE_TTL) < 0) {
return cached; // 30s 内直接命中缓存
}
var result = doCheck(server); // initialize 握手 + 计时
cache.put(server.getServerId(), result);
return result;
}
/** 并发检测所有服务器,单个失败不阻塞整体。 */
public Map<String, HealthResult> checkAll(Iterable<McpServerConfig> servers) {
var executor = Executors.newVirtualThreadPerTaskExecutor();
var futures = new ConcurrentHashMap<String, CompletableFuture<HealthResult>>();
for (var s : servers) {
futures.put(s.getServerId(),
CompletableFuture.supplyAsync(() -> checkHealth(s), executor));
}
// 每个 future 最多等 HEALTH_TIMEOUT + 2s,超时的记为不可用
...
}
}
要点:缓存 30 秒避免管理页面刷新一次就打一波握手;checkAll 用每任务一个虚拟线程并发探测,几十个 server 的总耗时约等于最慢那个,而不是求和;错误信息截断到冒号前(ConnectException: Connection refused → ConnectException),避免把内部地址写进返回给前端的错误串。
6. 桥接 Spring AI:FunctionToolCallback
发现到工具只是落库,真正让 LLM 能用上,是把每个 MCP 工具包装成 Spring AI 的 ToolCallback。我们实现了一个 ToolCallbackProvider:
@Component
public class McpToolCallbackProvider implements ToolCallbackProvider {
@Override
public ToolCallback[] getToolCallbacks() {
return getToolCallbacks(null);
}
/**
* @param endUserId 当前调用方的终端用户身份。仅对网关鉴权
* (headers 含 X-API-Key)的 MCP 工具注入, 由执行层
* 剥离并提升为请求头 X-End-User-Id; 其它 MCP 工具
* 不受影响, 参数原样透传。
*/
public ToolCallback[] getToolCallbacks(String endUserId) {
List<ToolDefinition> mcpTools = toolMapper.selectList(/* type=MCP AND status=ACTIVE */);
var callbacks = new ArrayList<ToolCallback>();
var seen = new HashSet<String>();
for (var def : mcpTools) {
if (!seen.add(def.getName())) continue; // 同名工具按 name 去重
var server = mcpServerMapper.selectById(def.getMcpServerId());
boolean gatewayAuth = server != null
&& McpHttpClient.parseHeaders(server.getHeaders()).containsKey("X-API-Key");
var cb = FunctionToolCallback
.<Map<String, Object>, String>builder(def.getName(), args -> {
Map<String, Object> params = new HashMap<>(args != null ? args : Map.of());
Map<String, String> extraHeaders =
(gatewayAuth && endUserId != null && !endUserId.isBlank())
? Map.of("X-End-User-Id", endUserId)
: Map.of();
ToolResult result = toolGateway.execute(spaceId, def.getToolId(), params, extraHeaders);
return result.isSuccess() && result.getData() != null
? result.getData().toString() : "";
})
.description(def.getDescription())
.inputSchema(def.getInputSchema()) // tools/list 拉到的原始 JSON Schema 直接透传
.inputType(Map.class)
.build();
callbacks.add(cb);
}
return callbacks.toArray(new ToolCallback[0]);
}
}
桥接层的关键认识:
- MCP 工具的
inputSchema和 Spring AI 的inputSchema是同一种东西(JSON Schema),所以注册时从tools/list拿到的 schema 原文存库,桥接时原样喂给FunctionToolCallback,零转换成本。 - 身份注入放在桥接层而不是 client 层:
FunctionToolCallback的 lambda 按当前调用方决定要不要附加X-End-User-Id,执行层(ToolGatewayImpl.executeMcp)再把它从参数里剥离、提升为 HTTP 头。这样终端用户身份永远不会出现在 LLM 可见的工具参数里——LLM 不需要也不应该知道这个值,也就无从伪造它。 - 返回值统一转字符串:LLM 只消费文本,结构化结果
toString()即可;失败的工具返回空串而不是抛异常,避免一次工具失败打断整条推理流(失败信息由调用日志承载)。
7. 数据模型与管理面
存储上只有一张新表加一个字段:
ALTER TABLE tool_definition
ADD COLUMN mcp_server_id VARCHAR(64) COMMENT '所属 MCP Server (仅 MCP 类型工具)';
CREATE TABLE mcp_server_config (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
url VARCHAR(512) NOT NULL COMMENT 'MCP Server 端点',
headers TEXT COMMENT '静态 HTTP Header (JSON map)',
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
MCP 工具作为 tool_definition.type = 'MCP' 与 BUILTIN / CUSTOM 并列,执行路径在网关里分派:
ToolGatewayImpl.execute()
├── type == "BUILTIN" → executeBuiltin()
├── type == "CUSTOM" → HTTP POST
└── type == "MCP" → McpHttpClient.callTool() ★ 新增分支
管理 API 就四个:POST/GET/DELETE /api/admin/mcp-servers 加一个 POST .../{id}/discover 手动触发发现。开发环境的 server 配置走 Config as Code(config/mcp/*.json,启动时幂等载入),生产环境走 API 注册,密钥不落配置文件。
8. 顺带手:MCP Server 也可以手写
同样的逻辑反过来也成立。我们平台对外暴露工具时,内置的 MCP Server 只是一个 85 行的 Controller——实现 initialize / tools/list / tools/call 三个端点,零 MCP SDK 依赖,直接复用已有 Spring 端口和鉴权过滤器。对已有 Spring Boot/WebFlux 应用、工具数量不多的团队,这比引入一个 MCP Server 框架划算得多。
9. 什么时候该用框架
手写不是信仰,是算账。我们的决策表:
| 场景 | 推荐 |
|---|---|
| 内嵌已有 Web 应用,复用端口与鉴权 | 手写 |
| 需要自定义 Header / 身份透传约定 | 手写 |
| 调用点是命令式代码(同步语义) | 手写 |
| 只需要 Streamable HTTP,方法面限于 tools/* | 手写 |
独立部署的 MCP Server,要用 @Tool 注解批量管理 20+ 工具 |
Spring AI MCP starter |
| 需要 SSE 长连接 + 完整的协议特性(resources/prompts/sampling) | 官方 SDK |
| 想跟着协议版本白嫖升级 | 官方 SDK |
一句话总结:MCP 的协议复杂度低于大多数团队的心理预期,真正的工作量全在协议之外——发现、注册、健康检查、身份透传、与现有工具体系的桥接。这些部分 SDK 帮不了你,而协议部分薄到不值得为它引入依赖。手写不是重复造轮子,是把轮子缩减到你能完全掌控的尺寸。