MCP 协议双向打通:让 AgentX 既能被 Claude 调用,又能调度全球工具生态|AgentX 专栏⑨
本文是 AgentX 技术专栏 第九篇。基于真实项目源码(
McpToolServer/McpRequestHandler/McpController/McpTool/LangChainConfig.mcpClients),从技术简介到设计思路,从核心代码到生产踩坑,循序渐进拆解 AgentX 如何用一套 MCP 协议实现"双向互通"------既把自己的 Java 工具暴露给 Claude/Cursor 调用,又能挂载外部 Python/Node 工具为己所用。让 Java 程序员一次性看懂 2025 年最火的 AI 互操作协议。
本文速览:
- 没有 MCP 之前,AI 工具生态是什么样的"信息孤岛"?
- MCP 协议到底是什么?为什么说它是"AI 世界的 USB-C 接口"
- AgentX 的双向设计:同时做 MCP Server(被调用)和 MCP Client(调别人)
McpTool接口 vs@Tool注解:两种工具如何在一个 Server 里合并暴露McpRequestHandler的 JSON-RPC 风格分发:EXECUTE / LIST_TOOLS / GET_TOOL / PING- LangChain4j Schema → 标准 JSON Schema 的递归转换(这块最容易踩坑)
- 三个实战大坑:Bean 初始化顺序、Schema 类型丢失、外部 Server 拖慢启动
文章约 42 KB,每一节为下一节做铺垫------建议从头读。读完文末有完整代码包(含可运行的 MCP Server + Client Demo)获取方式。
一、先抛个问题:没有 MCP 之前,工具生态有多割裂
假设你做了一个很棒的 Java 工具------比如一个"企业风控规则查询器"。现在你想让它被各种 AI 客户端调用:
- 让 Claude Desktop 能调它
- 让 Cursor 编辑器能调它
- 让你自己的 LangChain4j Agent 能调它
- 让同事用 Python 写的 AutoGen 也能调它
在 MCP 出现之前,你得做这些事:
| 目标客户端 | 你需要做的对接工作 |
|---|---|
| Claude Desktop | 研究 Anthropic 的私有插件格式,写适配层 |
| Cursor | 研究 Cursor 的扩展 API,再写一份适配 |
| LangChain4j | 用 @Tool 注解包一层 |
| Python AutoGen | 用 Flask 起个 HTTP 服务,自定义 JSON 格式 |
四个客户端,四套对接代码。 而且每次客户端升级,你的适配层可能就崩了。反过来也一样------你想让你的 Agent 调用别人写好的"GitHub 操作工具""网页搜索工具",又得为每一个第三方工具写一份接入代码。
这就是 AI 工具生态的"信息孤岛"困境:N 个工具 × M 个客户端 = N×M 套对接代码。
MCP(Model Context Protocol)就是来终结这个困境的。有了它,N 个工具只要各实现一次 MCP,M 个客户端只要各支持一次 MCP,对接复杂度从 N×M 降到 N+M。
这篇文章就来拆解 AgentX 怎么用 MCP 实现"一次实现,全生态可用"------而且是双向的:既被别人调,也调别人。
二、技术简介:MCP 到底是什么
2.1 一句话定义
MCP(Model Context Protocol,模型上下文协议)是 Anthropic 在 2024 年底推出的开放标准,用统一的 JSON-RPC 风格协议,规范 AI 模型与外部工具/数据源之间的交互。
业界有个很形象的比喻:MCP 是 "AI 世界的 USB-C 接口"。
USB-C 出现之前,每个设备都有自己的充电口(Micro-USB、Lightning、各种专有口)。USB-C 统一之后,一根线插遍所有设备。MCP 对 AI 工具生态做的就是同样的事。
2.2 MCP 的核心概念
MCP 协议里有三个关键角色:
| 角色 | 职责 | 在本文的对应 |
|---|---|---|
| MCP Host | AI 应用本体(如 Claude Desktop) | 调用方 |
| MCP Server | 暴露工具/资源的服务端 | AgentX 作为 Server |
| MCP Client | Host 内部连接 Server 的连接器 | AgentX 作为 Client |
而 MCP 协议规定的核心交互方法也很简洁:
| 方法 | 作用 |
|---|---|
list_tools |
客户端问:"你有哪些工具?" |
call_tool |
客户端说:"帮我执行 X 工具,参数是 Y" |
get_tool |
客户端问:"X 工具的详细规格是什么?" |
ping |
健康检查 |
2.3 业界横向对比
| 方案 | 标准化程度 | 生态 | AgentX 评估 |
|---|---|---|---|
| 各家私有插件格式 | ❌ 各自为政 | 封闭 | 对接成本爆炸 |
| OpenAI Function Calling | ⚠️ 事实标准但绑定 OpenAI | 中 | 与具体模型耦合 |
| LangChain Tools | ⚠️ 框架内标准 | 框架内 | 出了框架不通用 |
| MCP | ✅ 开放协议,多家支持 | 快速增长 | AgentX 选择 |
2025 年,MCP 已经被 Anthropic、OpenAI、Google、众多 IDE 和 Agent 框架支持,成为事实上的"AI 工具互操作标准"。对一个想长期演进的企业级 Agent 平台来说,支持 MCP 不是可选项,是必选项。
2.4 AgentX 的双向定位
大多数教程只讲 MCP 的一个方向。AgentX 的设计是双向的:
arduino
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 方向一:AgentX 作为 MCP Server(被外部 AI 调用) │
│ │
│ Claude / Cursor ──MCP──> McpController ──> McpToolServer │
│ │ │
│ 暴露 @Tool + McpTool 双源能力 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 方向二:AgentX 作为 MCP Client(调用外部工具) │
│ │
│ AgentX Agent ──MCP──> 外部 Python/Node MCP Server │
│ (GitHub 工具 / 网页搜索 / ...) │
│ │
└─────────────────────────────────────────────────────────────────┘
一套协议,两个方向,AgentX 既是生态的"供给方"也是"消费方"。下面分别拆解。
三、设计思路:四条让 MCP 落地的核心原则
原则一:双源合并,对外只暴露一个统一接口
AgentX 内部有两种工具:
| 工具类型 | 定义方式 | 适合场景 |
|---|---|---|
@Tool 注解工具 |
Spring Bean 方法加 @Tool |
简单业务工具,框架自动生成 Schema |
McpTool 接口工具 |
实现 McpTool 接口 |
复杂工具,需要自定义 JSON Schema |
关键设计 :对外的 MCP Server 不暴露这两种类型的差异。无论工具是用注解还是接口定义的,外部 AI 看到的都是一份统一的工具清单。
scss
McpToolServer.listTools()
├─ 来源 1:registeredMcpTools(McpTool 接口实现)
└─ 来源 2:toolRegistry.getAllToolSpecifications()(@Tool 注解工具)
↓ 合并成一份清单 ↓
外部 AI 看到的统一 tools 列表
这样设计的好处:内部怎么演进都不影响外部契约 。你今天用 @Tool 写工具,明天改成 McpTool 接口,外部 Claude 完全无感。
原则二:协议层与执行层分离
AgentX 把 MCP 请求处理拆成三层:
javascript
McpController ← 协议网关(HTTP 入口、JSON-RPC 封装)
↓
McpRequestHandler ← 请求分发(路由方法、统计、埋点)
↓
McpToolServer ← 能力中心(工具注册、执行、Schema 转换)
为什么分三层? 职责单一:
McpController只管 HTTP 和 JSON 序列化McpRequestHandler只管"这个请求该交给谁"McpToolServer只管"工具怎么注册和执行"
换协议(比如以后从 HTTP 换成 WebSocket)只动 Controller,换工具来源只动 Server,互不干扰。
原则三:Schema 转换必须递归且类型完整
MCP 协议要求工具用标准 JSON Schema 描述参数。但 LangChain4j 内部用的是自己的 JsonObjectSchema / JsonStringSchema / JsonEnumSchema 等类型体系。
两者之间必须有一个递归转换器 ,把 LangChain4j 的 Schema 树完整翻译成标准 JSON Schema------少翻译一种类型,外部 AI 就可能拿到残缺的工具定义,导致调用失败。
这是整个 MCP 集成里最容易踩坑、也最考验细致程度的地方(详见第四节代码和第五节坑二)。
原则四:外部依赖必须可降级
AgentX 作为 MCP Client 去连接外部 Server 时,外部服务可能:
- 连不上(网络问题)
- 连接慢(拖累 AgentX 启动)
- 中途挂掉
原则:外部 MCP Server 不可用,绝不能拖垮 AgentX 本体。AgentX 用超时控制 + 失败过滤,确保即使所有外部工具都挂了,自己照样正常启动和运行。
四条原则讲完,看代码怎么落地。
四、代码解析:从协议入口到双向打通
4.1 工具契约:McpTool 接口
一切从"工具长什么样"开始。AgentX 定义了 McpTool 接口,对齐 MCP 官方协议字段:
java
public interface McpTool {
/** 工具唯一标识符(如 "query_database") */
String name();
/** 工具功能描述------LLM 靠这段话决定是否调用 */
String description();
/** 参数的 JSON Schema 描述(对齐 MCP 协议的 "inputSchema" 术语) */
Map<String, Object> inputSchema();
/** 执行工具的核心逻辑 */
Object execute(Map<String, Object> parameters) throws Exception;
// ── 以下为带默认实现的可选方法 ──
default String version() { return "1.0.0"; }
default String category() { return "general"; }
default boolean isEnabled() { return true; }
/** 参数校验:通过返回 null,失败返回错误描述 */
default String validate(Map<String, Object> parameters) { return null; }
}
为什么方法名是 name() 而不是 getName()?
这是有意为之的"Record 风格"命名------对齐 MCP 协议的字段名(协议里就叫 name、description、inputSchema)。命名和协议一致,读代码时能直接对应协议文档,减少心智负担。
一个真实的 McpTool 实现长这样:
java
@Component
public class SystemInsightTool implements McpTool {
@Override
public String name() {
return "get_system_insight"; // 外部 AI 调用时填这个名字
}
@Override
public String description() {
// 工具的"自白"------大模型看到这段才知道何时该用它
return "获取 AgentX 系统的运行状态、当前服务器负载或最新的公司内部公告。";
}
@Override
public Map<String, Object> inputSchema() {
// 💡 核心:用标准 JSON Schema 定义参数
return Map.of(
"type", "object",
"properties", Map.of(
"query_type", Map.of(
"type", "string",
"description", "查询类型:'status'(系统状态) 或 'announcement'(公告)",
"enum", List.of("status", "announcement")
)
),
"required", List.of("query_type")
);
}
@Override
public Object execute(Map<String, Object> parameters) throws Exception {
String queryType = String.valueOf(parameters.get("query_type"));
return switch (queryType) {
case "status" -> Map.of(
"status", "Healthy", "uptime", "24h 15m", "memory_usage", "45%"
);
case "announcement" -> "📢 AgentX 框架今日已成功集成 MCP 协议!";
default -> "抱歉,不支持的查询类型:" + queryType;
};
}
@Override
public String validate(Map<String, Object> parameters) {
if (!parameters.containsKey("query_type")) {
return "缺少必填参数: query_type";
}
return null;
}
}
注意 inputSchema() 返回的就是标准 JSON Schema ------这种工具不需要额外转换,可以直接给外部 AI。而 @Tool 注解工具就没这么简单了(见 4.3)。
4.2 能力中心:McpToolServer 双源合并
McpToolServer 是整个 MCP Server 的核心------它把两种来源的工具合并成统一清单:
java
@Slf4j
@Component
@RequiredArgsConstructor
public class McpToolServer {
private final McpServerProperties serverProperties;
/** McpTool 接口实现的工具(线程安全) */
private final Map<String, McpTool> registeredMcpTools = new ConcurrentHashMap<>();
/** Spring 自动注入所有 McpTool 接口实现 */
private final List<McpTool> mcpTools;
/** 桥接 ToolRegistry,暴露所有 @Tool 注解工具 */
private final ToolRegistry toolRegistry;
private final AtomicBoolean serverRunning = new AtomicBoolean(false);
/**
* 获取所有可用工具列表(符合 MCP 协议 list_tools 规范)
* 合并两个来源:McpTool 接口实现 + @Tool 注解工具
*/
public List<Map<String, Object>> listTools() {
List<Map<String, Object>> tools = new ArrayList<>();
// 来源 1:McpTool 接口实现(inputSchema 已是标准格式,直接用)
for (McpTool tool : registeredMcpTools.values()) {
tools.add(Map.of(
"name", tool.name(),
"description", tool.description(),
"inputSchema", tool.inputSchema()
));
}
// 来源 2:@Tool 注解工具(需要把 LangChain4j Schema 转成标准 JSON Schema)
for (ToolSpecification spec : toolRegistry.getAllToolSpecifications()) {
Map<String, Object> inputSchema = convertToMap(spec.parameters());
tools.add(Map.of(
"name", spec.name(),
"description", spec.description() != null ? spec.description() : "",
"inputSchema", inputSchema
));
}
return tools;
}
}
为什么两个来源要分开处理?
因为它们的 Schema 格式不同:
McpTool.inputSchema()返回的已经是标准 JSON Schema(开发者手写的 Map)@Tool工具的 Schema 是 LangChain4j 的JsonObjectSchema对象,必须转换
这个差异正是下一节 convertToMap 要解决的。
统一执行入口 也体现了双源设计:
java
public McpResponse executeTool(String toolName, Map<String, Object> parameters) {
if (!serverRunning.get()) {
return McpResponse.error("MCP 服务器未启动或已停机");
}
// 1. 优先查 McpTool 接口实现
McpTool tool = registeredMcpTools.get(toolName);
if (tool != null) {
return executeMcpTool(tool, toolName, parameters);
}
// 2. 未命中则回退到 ToolRegistry(@Tool 注解工具)
return executeRegistryTool(toolName, parameters);
}
外部 AI 调用时只给一个 toolName,不需要关心它是哪种类型的工具------Server 内部自己路由。这就是"原则一:双源合并"的落地。
4.3 最容易踩坑的地方:Schema 递归转换
这是整个 MCP 集成的"硬骨头"。LangChain4j 的 @Tool 工具,参数 Schema 是一棵由各种 JsonSchemaElement 子类组成的树。要把它翻译成标准 JSON Schema,必须递归遍历每一种类型:
java
/** 把 LangChain4j 的 JsonObjectSchema 转为标准 JSON Schema Map */
private Map<String, Object> convertToMap(JsonObjectSchema schema) {
if (schema == null) {
return Map.of("type", "object", "properties", Map.of());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("type", "object");
// 递归转换每个 property
Map<String, Object> props = new LinkedHashMap<>();
if (schema.properties() != null) {
for (Map.Entry<String, JsonSchemaElement> entry : schema.properties().entrySet()) {
props.put(entry.getKey(), convertSchemaElement(entry.getValue()));
}
}
result.put("properties", props);
// required 字段
if (schema.required() != null && !schema.required().isEmpty()) {
result.put("required", new ArrayList<>(schema.required()));
}
return result;
}
/** 递归转换单个 Schema 元素------关键是 case 要覆盖全 */
private Object convertSchemaElement(JsonSchemaElement element) {
if (element == null) return Map.of("type", "null");
return switch (element) {
case JsonObjectSchema obj -> convertToMap(obj); // 嵌套对象,递归
case JsonArraySchema arr -> { // 数组,递归 items
Map<String, Object> m = new LinkedHashMap<>();
m.put("type", "array");
if (arr.items() != null) m.put("items", convertSchemaElement(arr.items()));
yield m;
}
case JsonStringSchema str -> mapWithDesc("string", str.description());
case JsonIntegerSchema i -> mapWithDesc("integer", i.description());
case JsonNumberSchema num -> mapWithDesc("number", num.description());
case JsonBooleanSchema b -> mapWithDesc("boolean", b.description());
case JsonEnumSchema en -> { // 枚举,带 enum 值
Map<String, Object> m = new LinkedHashMap<>();
m.put("type", "string");
if (en.enumValues() != null) m.put("enum", new ArrayList<>(en.enumValues()));
yield m;
}
default -> Map.of("type", "object");
};
}
为什么必须用 Java 21 的 switch 模式匹配?
JsonSchemaElement 有近 10 个子类型(String/Integer/Number/Boolean/Enum/Array/Object/Reference/Raw/Null)。用传统的 if (x instanceof A) ... else if (x instanceof B) 写下来又长又容易漏。Java 21 的 switch 模式匹配 + 密封类型,编译器能帮你检查是否覆盖全------漏一种类型,外部 AI 就拿到残缺 Schema。
这块代码看着平平无奇,实际是 MCP 集成里最容易出 bug 的地方。第五节坑二会专门讲漏类型的后果。
4.4 请求分发:McpRequestHandler 的 JSON-RPC 路由
外部 AI 的请求进来后,由 McpRequestHandler 按方法名分发:
java
@Slf4j
@Component
@RequiredArgsConstructor
public class McpRequestHandler {
private final McpToolServer mcpToolServer;
private final ObservationRegistry observationRegistry;
/** 请求频率统计 */
private final Map<String, RequestCounter> statsMap = new ConcurrentHashMap<>();
public McpToolServer.McpResponse handleRequest(McpRequest request) {
if (request == null) return McpToolServer.McpResponse.error("空请求");
String reqId = StringUtils.hasText(request.getRequestId())
? request.getRequestId() : UUID.randomUUID().toString();
recordStats(request); // 统计:哪个工具最受欢迎
// 🎥 开启监控:Jaeger 里能看到这次外部调用的耗时(呼应专栏⑦可观测)
return Observation.createNotStarted("mcp.handle", observationRegistry)
.contextualName("mcp-" + request.getMethod().toLowerCase())
.lowCardinalityKeyValue("method", request.getMethod())
.observe(() -> {
// Java 21 增强 switch 路由
return switch (request.getMethod().toUpperCase()) {
case "EXECUTE" -> handleExecute(request);
case "LIST_TOOLS" -> handleListTools();
case "GET_TOOL" -> handleGetTool(request);
case "PING" -> handlePing(reqId);
default -> McpToolServer.McpResponse.error("不支持的方法: " + request.getMethod());
};
});
}
}
两个值得注意的设计:
-
统计 + 埋点内建 :每次外部调用都记一笔统计(
statsMap),并用Observation包裹(接入专栏⑦的可观测体系)。这样在 Jaeger 里能看到"谁在什么时候调了哪个工具、耗时多少"------MCP 是对外暴露的攻击面,可观测性尤其重要。 -
LongAdder做计数器:
java
private static class RequestCounter {
private final LongAdder counter = new LongAdder();
public void increment() { counter.increment(); }
public long getCount() { return counter.sum(); }
}
高并发下 LongAdder 比 AtomicLong 性能更好------它内部分散热点,多线程各加各的分段,读取时再求和。MCP Server 可能被高频调用,这个细节决定了统计不会成为瓶颈。
4.5 协议网关:McpController
最外层的 HTTP 入口,把 MCP 协议暴露成 REST 接口:
java
@Slf4j
@RestController
@RequestMapping("/api/v1/mcp")
@RequiredArgsConstructor
public class McpController {
private final McpRequestHandler mcpRequestHandler;
private final McpToolServer mcpToolServer;
private final ObservationRegistry observationRegistry;
/** 【核心入口】执行工具调用(JSON-RPC 风格) */
@PostMapping("/rpc")
public ResponseEntity<McpToolServer.McpResponse> handleMcpRequest(
@RequestBody McpRequestHandler.McpRequest request) {
return Observation.createNotStarted("mcp.api.rpc", observationRegistry)
.observe(() -> {
McpToolServer.McpResponse response = mcpRequestHandler.handleRequest(request);
return response.success()
? ResponseEntity.ok(response)
: ResponseEntity.badRequest().body(response);
});
}
/** 【发现入口】列出所有工具 */
@GetMapping("/tools")
public ResponseEntity<Map<String, Object>> listTools() {
var discoveryRequest = McpRequestHandler.McpRequest.builder()
.method("LIST_TOOLS").build();
// ...
}
/** 【健康检查】Ping */
@GetMapping("/ping")
public ResponseEntity<McpToolServer.McpResponse> ping() { /* ... */ }
/** 【管理入口】查看服务器状态 */
@GetMapping("/status")
public ResponseEntity<McpToolServer.ServerStatus> getStatus() {
return ResponseEntity.ok(mcpToolServer.getStatus());
}
}
外部 AI 这样调用 AgentX 的工具:
bash
# 1. 先问 AgentX 有哪些工具
curl http://localhost:8080/api/v1/mcp/tools
# 2. 调用某个工具
curl -X POST http://localhost:8080/api/v1/mcp/rpc \
-H "Content-Type: application/json" \
-d '{
"method": "EXECUTE",
"toolName": "get_system_insight",
"parameters": {"query_type": "status"}
}'
至此,方向一(AgentX 作为 MCP Server) 完整打通。
4.6 反向打通:AgentX 作为 MCP Client
光被别人调还不够,AgentX 还要能调用外部工具生态 。这部分在 LangChainConfig 里:
java
/**
* 【外挂装备】外部 MCP 技能服务器连接池
* 连接用 Python / Node.js 写的"技能店"(GitHub 工具、网页搜索插件等)
*/
@Bean
public List<McpClient> mcpClients(McpClientProperties props) {
// 没开启就返回空清单
if (!props.enabled() || props.servers() == null) {
return Collections.emptyList();
}
return props.servers().stream().map(server -> {
log.info("[MCP-Client] 🔗 挂载外部能力: {} -> {}", server.name(), server.baseUrl());
try {
// 用 Streamable HTTP 模式连接远端 MCP Server(SSE 协议)
McpTransport transport = StreamableHttpMcpTransport.builder()
.url(server.baseUrl())
.build();
// 💡 关键:用 CompletableFuture 限制连接等待时间
// 防止远端不可用拖慢 AgentX 整体启动
int connectTimeout = parseInt(server.timeout(), 10);
CompletableFuture<McpClient> future = CompletableFuture.supplyAsync(() ->
(McpClient) DefaultMcpClient.builder()
.transport(transport)
.build()
);
return future.get(connectTimeout, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.warn("[MCP-Client] 连接超时,跳过: {} -> {}", server.name(), server.baseUrl());
return null;
} catch (Exception e) {
log.error("[MCP-Client] 挂载失败: {} -> {}", server.name(), server.baseUrl());
return null;
}
}).filter(Objects::nonNull).toList(); // 过滤掉挂载失败的
}
配置长这样:
yaml
mcp:
client:
enabled: true
servers:
- name: "python-monitor"
baseUrl: http://127.0.0.1:8000/sse
timeout: 30
这段代码体现了"原则四:外部依赖必须可降级":
| 防御措施 | 作用 |
|---|---|
CompletableFuture.get(timeout) |
外部 Server 连不上时,最多等 N 秒就放弃 |
catch TimeoutException → return null |
超时不抛异常,返回 null |
.filter(Objects::nonNull) |
把挂载失败的过滤掉,剩下的正常用 |
结果:即使配置了 5 个外部 Server,其中 3 个挂了,AgentX 照样用剩下 2 个正常启动。外部生态的不稳定,绝不传导到 AgentX 本体。
至此,双向打通完成------AgentX 既是 Server 又是 Client。
五、问题解决:三个 MCP 集成的实战大坑
坑一:Bean 初始化顺序导致工具数量为 0
现象
McpToolServer 启动日志显示"已挂载能力数量: 0",但实际项目里明明有十几个 @Tool 工具。重启几次,有时又是对的。
原因
McpToolServer 和 ToolRegistry 都在启动时初始化,但初始化时机不同:
| 组件 | 初始化时机 |
|---|---|
McpToolServer.init() |
@PostConstruct(Bean 创建时,早) |
ToolRegistry 扫描 @Tool |
ContextRefreshedEvent(容器刷新完,晚) |
如果在 @PostConstruct 里就去读 toolRegistry.getToolsCount(),那时 @Tool 工具还没扫描进来 ,自然是 0。而两者都监听 ContextRefreshedEvent 时,执行顺序不确定。
解决
不在初始化时"快照"工具数量,而是在每次 listTools() / getStatus() 时动态读取:
java
@PostConstruct
public void init() {
// ✅ 只注册 McpTool 接口实现(这些是 Bean,此刻已就绪)
if (mcpTools != null) {
mcpTools.forEach(this::registerTool);
}
// ❌ 不要在这里读 toolRegistry------@Tool 工具还没注册进来
}
// ✅ 工具数量在调用时动态计算,永远是最新的
private int getActiveToolCount() {
return registeredMcpTools.size() + toolRegistry.getToolsCount();
}
教训 :Spring 里凡是依赖"另一个组件已完成初始化"的逻辑,都不要在 @PostConstruct 里做 。要么用 @EventListener(ContextRefreshedEvent) 并接受顺序不确定,要么改成调用时动态读取。后者更稳。
坑二:Schema 漏转一种类型,外部 AI 调用全失败
现象
McpTool 接口实现的工具,外部 Claude 调用正常。但 @Tool 注解的工具,外部 AI 要么看不到参数,要么传参后报"参数格式错误"。
原因
convertSchemaElement 的 switch 漏了某个类型分支。比如最初版本忘了处理 JsonEnumSchema:
java
// ❌ 漏了 enum 类型
return switch (element) {
case JsonStringSchema str -> Map.of("type", "string");
case JsonIntegerSchema i -> Map.of("type", "integer");
// 漏了 JsonEnumSchema!
default -> Map.of("type", "object"); // enum 掉进 default,变成 object
};
结果:一个本该是 {"type": "string", "enum": ["A", "B"]} 的参数,被转成了 {"type": "object"}。外部 AI 看到这个残缺 Schema,要么不知道该传什么,要么传了个对象上来------调用必然失败。
解决
switch 必须覆盖所有 JsonSchemaElement 子类型,每种类型完整翻译它的特有字段(enum 的 enumValues、array 的 items、object 的嵌套 properties):
java
case JsonEnumSchema en -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("type", "string");
if (en.enumValues() != null) {
m.put("enum", new ArrayList<>(en.enumValues())); // ✅ 别丢 enum 值
}
yield m;
}
怎么避免漏? 善用 Java 21 的密封类型 + switch 穷尽检查。如果 JsonSchemaElement 是 sealed 的,不写 default 分支时编译器会强制你覆盖所有子类型------漏一个直接编译报错,把运行时 bug 提前到编译期。
教训 :协议转换层是 AI 互操作的生命线 。Schema 转换务必类型完整、递归彻底。一个偷懒的 default -> Map.of("type", "object") 能让整个工具生态对外失效。
坑三:一个外部 MCP Server 挂掉,整个应用启动失败
现象
配置了一个外部 Python MCP Server 做监控插件。某天那台 Python 服务宕机了,结果整个 AgentX 启动卡住,最后超时崩溃------连不相关的对话功能都用不了。
原因
最初的写法是同步阻塞连接:
java
// ❌ 危险写法:同步连接,远端挂了就一直等
McpClient client = DefaultMcpClient.builder()
.transport(StreamableHttpMcpTransport.builder().url(server.baseUrl()).build())
.build(); // 远端不可用时,这里可能阻塞到 TCP 超时(几十秒甚至更久)
Spring 启动是串行的------一个 Bean 卡住,整个容器起不来。外部服务的故障,直接变成了 AgentX 自己的故障。
解决
用 CompletableFuture + 超时 + 失败过滤三件套(见 4.6 代码):
java
// ✅ 带超时的异步连接
CompletableFuture<McpClient> future = CompletableFuture.supplyAsync(() ->
DefaultMcpClient.builder().transport(transport).build()
);
try {
return future.get(connectTimeout, TimeUnit.SECONDS); // 最多等 N 秒
} catch (TimeoutException e) {
return null; // 超时就放弃这一个,不影响其他
}
// 最后 .filter(Objects::nonNull) 过滤掉失败的
| 防御层 | 作用 |
|---|---|
CompletableFuture 异步 |
连接动作不阻塞主启动线程 |
get(timeout) |
设置最长等待时间 |
catch → return null |
单个失败不抛异常 |
filter(nonNull) |
失败的剔除,成功的保留 |
教训 :任何外部依赖都要假设它会挂。尤其是 MCP 这种"挂载第三方能力"的场景------你引入的外部 Server 越多,整体可用性的乘积就越低。必须用超时和降级把每个外部依赖隔离成"可选增强",而不是"启动硬依赖"。
六、总结:一张表 + 五条经验
设计决策回顾
| 设计决策 | 解决什么问题 |
|---|---|
| 双向定位(Server + Client) | AgentX 既供给生态也消费生态,不做信息孤岛 |
| 双源合并(@Tool + McpTool) | 对外暴露统一清单,内部演进不影响外部契约 |
| 协议三层分离(Controller/Handler/Server) | 职责单一,换协议/换工具源互不干扰 |
| Schema 递归转换 | LangChain4j Schema → 标准 JSON Schema,类型完整 |
| Java 21 switch 模式匹配 | 编译期检查 Schema 类型覆盖,漏类型直接报错 |
| LongAdder 统计 | 高并发下统计不成瓶颈 |
| CompletableFuture 超时 + 失败过滤 | 外部 Server 不可用不拖垮本体 |
| 内建 Observation 埋点 | MCP 是对外攻击面,可观测性尤其重要 |
五条核心经验
- MCP 是 AI 工具生态的 USB-C ------ 把 N×M 的对接复杂度降到 N+M,支持它是企业级 Agent 平台的必选项
- 双向打通比单向更有价值 ------ 既要能被 Claude/Cursor 调用,也要能挂载外部工具生态
- 协议转换层是生命线 ------ Schema 转换务必类型完整、递归彻底,漏一种类型整个工具对外失效
- Spring 初始化顺序是隐形坑 ------ 依赖"其他组件已就绪"的逻辑不要放
@PostConstruct,改成调用时动态读取 - 外部依赖必须可降级 ------ 用超时 + 失败过滤,把每个外部 MCP Server 隔离成"可选增强"而非"启动硬依赖"
演进路线建议
如果你要给自己的 Java 项目加 MCP 能力,建议这样推进:
- 第一阶段 :实现
McpTool接口 + 一个McpController,让外部能调用你的工具(Server 方向) - 第二阶段 :桥接已有的
@Tool工具,做 Schema 递归转换,双源合并暴露 - 第三阶段 :加
McpClient,挂载外部 MCP 生态(Client 方向),注意超时降级 - 第四阶段:接入可观测(专栏⑦),给每次 MCP 调用埋点,统计工具调用频率
- 第五阶段:对接 Claude Desktop / Cursor 实测,验证 Schema 兼容性
七、写在最后
MCP 是 2024 年底才出现的新协议,但它的意义可能被低估了------它正在做的,是 AI 时代的"接口标准化"。就像 HTTP 统一了 Web、USB 统一了外设,MCP 在统一 AI 与工具的连接方式。
对 Java 程序员来说,这是个绝佳的切入点:MCP 的本质是协议适配 + 服务治理 + 依赖隔离------这些全是我们做了十几年企业级后端的看家本领。你不需要懂多少深度学习,就能把一个企业级 MCP Server 做得又稳又专业。AgentX 这套双向 MCP 实现就是证明:从 JSON-RPC 路由到 Schema 转换,从超时降级到可观测埋点,每一块都是扎实的 Java 工程。
AgentX 专栏到这里已经发布 9 篇 ,从技术选型一路讲到 MCP 全生态互通,把一个企业级 Agent 平台的核心模块基本讲透了:选型 → 架构 → 工具 → RAG → 记忆 → 可观测 → 工作流 → MCP(本文)。
如果你也在做 Agent 项目,或者准备转型 AI 工程方向:
- 代码全公开 --- 公众号回复「MCP」获取本文完整代码包(含可运行的 Server + Client Demo)
- 专栏持续更新 --- 每篇都基于真实开源项目源码,不水
- 欢迎交流 --- 评论区或公众号私信都行,踩过的坑越分享越值钱
💬 互动话题:你的项目接入 MCP 了吗?打算把哪些工具暴露给 Claude/Cursor?或者你最想挂载哪个外部 MCP 生态?评论区聊聊。
关注公众号 【SuniaCoder-AI全栈架构实战】 ,回复「MCP 」获取本文完整代码包,回复「工作流 」获取工作流引擎代码,回复「可观测」获取观测配置代码。
关于作者 & 联系方式
汪旭 / Sunia --- Java 全栈开发者,AI 应用工程化实践者
专注企业级 AI 落地,擅长极限资源优化,有 RAG、Agent、知识图谱方向的完整实战经验。
| 平台 | 地址 / 说明 |
|---|---|
| CSDN | SuniaCoder-AI|13.5 万+ 阅读,RAG/Agent 系列持续更新 |
| 微信公众号 | 搜索【SuniaCoder-AI全栈架构实战】|关注回复「MCP」获取本文完整代码包 |
| 掘金 | SuniaCoder-AI |
| 知乎 | SuniaCoder-AI |
| 合作咨询 | 提供企业私有化大模型部署与定制开发(基础部署 / 企业定制 / 年度维保)欢迎私信洽谈 |
如果内容对你有帮助,点赞 + 收藏 + 关注是最大的支持,也能让更多需要的人看到这篇文章。
AgentX 专栏导航
| 篇 | 标题 | 核心内容 |
|---|---|---|
| ① | 一个 Java 开发者的 Agent 实践之路(前言) | 专栏总览 / 选题思路 |
| ② | 没有 GPU、只有 3 台低配云服务器,我如何选出 AgentX 的技术栈 | LangChain4j / Ollama / Milvus / Redis 选型 |
| ③ | AgentX 架构设计全解析:一个请求是如何从 HTTP 走到 LLM 再回来的 | 六层架构 / SSE 流式 / 虚拟线程 / TraceId |
| ④ | 工具系统深度实现:从 @Tool 注解到 MCP 协议,构建企业级 Agent 工具体系 | ToolRegistry / McpToolServer / @Tool |
| ⑤ | RAG 进阶:用 Milvus + bge-m3 构建比 ES 更懂语义的企业知识库 | 向量检索 / bge-m3 / MilvusV2 |
| ⑥ | 记忆系统:用 Redis + Milvus 给 AI 配上短期 + 长期双层记忆 | ChatMemoryStore / 语义召回 / 多轮上下文 |
| ⑦ | 全链路可观测:用 OpenTelemetry + Jaeger 让每次 AI 对话都可追踪可复盘 | OTel / Jaeger / SpanExporter / TraceId |
| ⑧ | 工作流引擎:AgentWorkflow 怎么把工具、记忆、流程串成一条流水线 | AgentWorkflow / LangGraph / 虚拟线程 / SSE |
| ⑨ | MCP 协议双向打通:让 AgentX 既能被 Claude 调用,又能调度全球工具生态(本文) | MCP / JSON-RPC / 双源合并 / Schema 转换 |
| ⑩ | 即将发布:生产部署------3 台 2C4G 云服务器跑起企业级 Agent 的完整方案 | Docker Compose / 三节点部署 / 资源调优 |
↑ 上一篇:工作流引擎:AgentWorkflow 怎么把工具、记忆、流程串成一条流水线|AgentX 专栏⑧
↓ 下一篇:生产部署------3 台 2C4G 云服务器跑起企业级 Agent 的完整方案(即将发布)
Tags :#AgentX #MCP #ModelContextProtocol #LangChain4j #JSON-RPC #AI工具生态 #Claude #SpringBoot3 #Java21 #Agent互操作