当 AI Agent 需要调用你五年前写的 Spring MVC 接口,你该怎么办?
一、背景:一个真实的痛点
相信很多团队都有这样的困境:
- 公司有一套运行多年的 Java REST 服务(Spring MVC / JAX-RS / 甚至裸 Servlet)
- 业务逻辑稳定,不想也不能大动
- 但老板说:"我们的系统要接入 AI Agent,让大模型能调用这些接口"
- 于是你打开 MCP(Model Context Protocol)的文档,一脸懵:这玩意儿怎么跟我的老项目对接?
MCP 是 Anthropic 提出的开放协议,定义了 LLM 与外部工具之间的标准通信方式。它不关心你的后端是 Java、Python 还是 COBOL------它只关心你能不能暴露一组符合规范的 Tool 。 所以核心问题变成了:如何在不重写旧系统的前提下,把 REST 接口"翻译"成 MCP Tool?
二、MCP 核心概念速览
在动手之前,花 2 分钟理解三个关键概念:
| 概念 | 说明 | 类比 |
|---|---|---|
| Tool | LLM 可调用的函数,有名称、描述、参数 Schema | 一个 REST API 端点 |
| Transport | 通信方式:stdio(本地进程)或 SSE/HTTP(远程) | HTTP vs gRPC |
| JSON-RPC | MCP 底层通信协议 | REST 底层的 HTTP |
一个 MCP Tool 的 JSON Schema 长这样:
json
{
"name": "queryOrder",
"description": "根据订单号查询订单详情,包含状态、金额、商品列表",
"inputSchema": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "订单编号,格式 ORD-2024-XXXXX"
}
},
"required": ["orderId"]
}
}
LLM 看到这个描述后,就知道什么时候该调它、怎么传参 。这就是 MCP 与传统 API 网关的本质区别:接口的语义描述是面向 AI 的,不是面向前端开发者的。
三、整体架构
文本
┌─────────────┐ MCP Protocol ┌──────────────────┐ HTTP ┌─────────────────┐
│ LLM / │ ◄──────────────────────► │ MCP Server │ ◄────────────► │ 旧 Java REST │
│ AI Agent │ (stdio / SSE / HTTP) │ (适配层) │ (REST调用) │ 服务 (不动) │
└─────────────┘ └──────────────────┘ └─────────────────┘
关键原则:旧系统零改动,所有适配逻辑收敛在 MCP Server 层。
四、方案选型
方案对比
| 方案 | 适用场景 | 优点 缺点 |
|--------------------------|---------------------------|----------------------------|----------------------------|
| Spring AI MCP Server | 已有 Spring Boot 项目,JDK 17+ | 注解驱动,生态好,与 Spring 无缝集成 | 要求 JDK 17+,Spring Boot 3.x |
| MCP Java SDK(官方) | 非 Spring 项目或需轻量部署 | 无框架绑定,灵活 需手动注册 Tool、管理生命周期 |
| Python/TS 代理 | 旧系统 JDK 8/11 无法升级 | 零侵入,生态最成熟 | 多一层网络跳转,双语言维护 |
怎么选?
文本
旧系统能升级 JDK 17 + Spring Boot 3?
├── 是 → Spring AI MCP(首选)
└── 否 → 旧系统能加一个独立 Java 模块?
├── 是 → MCP Java SDK 独立进程
└── 否 → Python/TS 代理(最稳妥)
下面分别给出实现。
五、方案一:Spring AI MCP Server(推荐)
5.1 添加依赖
xml
<dependencies>
<!-- MCP Server 核心 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- 用于调用旧REST接口的HTTP客户端 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>
5.2 配置
yaml
# application.yml
spring:
ai:
mcp:
server:
name: legacy-order-mcp
version: 1.0.0
type: SYNC
# stdio: 本地CLI场景
# sse: 远程部署,供多个Agent调用
transport: sse
sse-port: 8090
5.3 编写 Tool(核心代码)
java
@Service
public class LegacyOrderTools {
private final WebClient webClient;
public LegacyOrderTools(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("http://legacy-order-service:8080")
.defaultHeader("Authorization", "Bearer " + getInternalToken())
.build();
}
/**
* 查询订单 ------ 对应旧接口 GET /api/v1/orders/{id}
*/
@Tool(description = "根据订单号查询订单详情。返回订单状态、总金额、" +
"商品列表。适用于用户询问订单进度、物流状态等场景。")
public String queryOrder(
@ToolParam(description = "订单编号,格式如 ORD-2024-001234")
String orderId) {
try {
OrderDTO order = webClient.get()
.uri("/api/v1/orders/{id}", orderId)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, resp -> {
if (resp.statusCode().value() == 404) {
return Mono.error(new OrderNotFoundException(orderId));
}
return Mono.error(new RuntimeException("查询失败"));
})
.bodyToMono(OrderDTO.class)
.block(Duration.ofSeconds(10));
// 关键:裁剪响应,只返回LLM需要的字段
return formatOrderSummary(order);
} catch (OrderNotFoundException e) {
return "未找到订单号 " + orderId + ",请确认订单号是否正确。";
} catch (Exception e) {
return "查询订单时系统繁忙,请稍后重试。";
}
}
/**
* 取消订单 ------ 对应旧接口 POST /api/v1/orders/{id}/cancel
*/
@Tool(description = "取消指定订单。仅支持状态为'待付款'或'待发货'的订单。" +
"此操作不可逆,调用前请与用户确认。")
public String cancelOrder(
@ToolParam(description = "要取消的订单编号") String orderId,
@ToolParam(description = "取消原因,如:不想要了、买错了") String reason) {
try {
CancelRequest req = new CancelRequest(reason);
CancelResult result = webClient.post()
.uri("/api/v1/orders/{id}/cancel", orderId)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.retrieve()
.bodyToMono(CancelResult.class)
.block(Duration.ofSeconds(15));
return result.isSuccess()
? "订单 " + orderId + " 已成功取消。"
: "取消失败:" + result.getMessage();
} catch (Exception e) {
return "取消订单操作失败,请联系客服处理。";
}
}
private String formatOrderSummary(OrderDTO order) {
StringBuilder sb = new StringBuilder();
sb.append("订单号: ").append(order.getId()).append("\n");
sb.append("状态: ").append(order.getStatusText()).append("\n");
sb.append("总金额: ¥").append(order.getTotalAmount()).append("\n");
sb.append("下单时间: ").append(order.getCreateTime()).append("\n");
sb.append("商品:\n");
for (OrderItem item : order.getItems()) {
sb.append(" - ").append(item.getName())
.append(" x").append(item.getQty())
.append(" ¥").append(item.getPrice()).append("\n");
}
return sb.toString();
}
}
5.4 注册 Tool Provider
java
@Configuration
public class McpToolConfig {
@Bean
public ToolCallbackProvider orderToolProvider(LegacyOrderTools tools) {
return MethodToolCallbackProvider.builder()
.toolObjects(tools)
.build();
}
}
启动后,MCP Server 自动在 8090 端口暴露 SSE 端点,任何 MCP Client(Claude Desktop、Cursor、自研 Agent)都能发现并调用这些 Tool。
六、方案二:MCP Java SDK(轻量/非 Spring)
适用于 JDK 11 或不想引入 Spring Boot 的场景。
xml
<dependency>
<groupId>io.modelcontextprotocol</groupId>
<artifactId>mcp</artifactId>
<version>0.10.0</version>
</dependency>
java
public class LegacyApiMcpServer {
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final String BASE_URL = "http://legacy:8080";
public static void main(String[] args) {
// 创建 stdio 传输的 MCP Server
var transport = new StdioServerTransport();
var server = McpServer.sync(transport)
.serverInfo("legacy-api-mcp", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.build();
// 注册 Tool
server.addTool(new McpServerFeatures.SyncToolSpecification(
new Tool("query_user",
"根据用户ID查询用户基本信息,包括姓名、手机号、注册时间",
buildQueryUserSchema()),
(exchange, arguments) -> {
String userId = (String) arguments.get("userId");
String result = callLegacyApi("/api/v1/users/" + userId);
return new CallToolResult(List.of(new TextContent(result)), false);
}
));
System.err.println("MCP Server started on stdio");
}
private static String callLegacyApi(String path) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + path))
.header("Authorization", "Bearer internal-token")
.GET()
.build();
HttpResponse<String> response = HTTP.send(request,
HttpResponse.BodyHandlers.ofString());
return simplifyResponse(response.body());
} catch (Exception e) {
return "调用失败: " + e.getMessage();
}
}
private static String simplifyResponse(String json) {
// 解析JSON,只提取关键字段,避免Token浪费
// 实际项目中可用 Jackson / Gson
return json; // 示意
}
}
七、方案三:Python 代理(旧系统完全不动)
当旧系统是 JDK 8 的 WAR 包、部署在 Tomcat 上、没人敢碰时:
python
from mcp.server.fastmcp import FastMCP
import httpx
mcp = FastMCP("legacy-java-proxy")
LEGACY_BASE = "http://legacy-java:8080/api/v1"
@mcp.tool()
async def query_order(order_id: str) -> str:
"""根据订单号查询订单详情。订单号格式如 ORD-2024-001234。
返回订单状态、金额和商品清单。"""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{LEGACY_BASE}/orders/{order_id}",
headers={"Authorization": "Bearer internal-token"}
)
if resp.status_code == 404:
return f"订单 {order_id} 不存在,请核实订单号。"
data = resp.json()
return (
f"订单: {data['orderId']}\n"
f"状态: {data['statusText']}\n"
f"金额: ¥{data['totalAmount']}\n"
f"商品: {', '.join(i['name'] for i in data['items'])}"
)
@mcp.tool()
async def search_products(keyword: str, category: str = "") -> str:
"""搜索商品。keyword为必填搜索关键词,category可选(如:电子、服装、食品)。"""
params = {"keyword": keyword}
if category:
params["category"] = category
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{LEGACY_BASE}/products/search", params=params)
data = resp.json()
if not data:
return f"未找到与'{keyword}'相关的商品。"
lines = [f"找到{len(data)}件商品:"]
for p in data[:5]: # 最多返回5条,控制Token
lines.append(f" - {p['name']} ¥{p['price']} ({p['category']})")
return "\n".join(lines)
if __name__ == "__main__":
mcp.run(transport="stdio")
八、关键设计要点(踩坑总结)
8.1 Tool 描述是给 AI 看的,不是给开发者看的
❌ 错误:
java
@Tool(description = "调用订单查询接口")
✅ 正确:
java
@Tool(description = "根据订单号查询订单详情。当用户询问'我的订单到哪了'、" +
"'订单什么时候发货'时使用此工具。返回物流状态和预计到达时间。" +
"订单号格式:ORD-YYYY-NNNNNN")
8.2 响应裁剪:别把整个 JSON 扔给 LLM
旧接口可能返回 200 个字段(含前端渲染用的 cssClass、trackingParams)。必须过滤:
java
// 只提取 LLM 推理需要的字段
return String.format("订单%s,状态:%s,金额:%.2f元",
order.getId(), order.getStatusText(), order.getAmount());
8.3 错误信息用自然语言
java
// ❌ 不要这样
throw new RuntimeException("{\"code\":50023,\"msg\":\"ORD_NOT_EXIST\"}");
// ✅ 要这样
return "订单号 ORD-2024-999999 不存在。请检查是否有拼写错误,或联系人工客服。";
8.4 写操作加防护
java
@Tool(description = "删除用户账户。【危险操作】此操作不可逆," +
"必须在用户明确确认后才能调用。")
public String deleteUser(String userId) {
// 实际生产中可加二次确认机制
}
8.5 超时与重试
旧系统可能响应慢,务必设置超时:
java
webClient.get()
.uri(...)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(10)) // 10秒超时
.onErrorResume(TimeoutException.class, e ->
Mono.just("查询超时,旧系统响应较慢,请稍后再试。"));
九、测试与调试
9.1 MCP Inspector(可视化调试)
bash
npx @modelcontextprotocol/inspector
打开浏览器界面,可以:
- 查看注册的 Tool 列表
- 手动输入参数调用 Tool
- 查看原始 JSON-RPC 请求/响应
9.2 接入 Claude Desktop 测试
编辑 claude_desktop_config.json:
json
{
"mcpServers": {
"legacy-order": {
"command": "java",
"args": ["-jar", "legacy-order-mcp.jar"],
"env": {
"LEGACY_API_TOKEN": "your-token"
}
}
}
}
然后在对话中测试:"帮我查一下订单 ORD-2024-001234 的物流状态"。
### 9.3 单元测试
```java
@Test
void testQueryOrderTool() {
// Mock 旧接口返回
when(webClient.get()).thenReturn(mockOrderResponse());
String result = tools.queryOrder("ORD-2024-001234");
assertThat(result).contains("已发货");
assertThat(result).contains("¥299.00");
assertThat(result).doesNotContain("cssClass"); // 确认冗余字段已过滤
}
十、生产部署建议
| 关注点 | 建议 |
|---|---|
| 认证 | 旧接口 Token 通过环境变量注入,不硬编码 |
| 限流 | MCP 层加 RateLimiter,防止 LLM 循环调用打垮旧系统 |
| 日志 | 记录每次 Tool 调用的入参、耗时、响应摘要 |
| 监控 | 暴露 Prometheus metrics:调用次数、错误率、P99延迟 |
| 灰度 | 先只暴露只读接口(GET),验证稳定后再开放写操作 |
| 版本管理 | Tool 的 description 变更需走 Review,因为直接影响 LLM 行为 |
十一、总结
把旧 REST 接口封装成 MCP 服务,本质上是在做API 语义化翻译:
- 不改旧系统,在中间加一层薄适配
- Tool 描述面向 AI,写清楚"什么时候用、怎么用、返回什么"
- 响应做裁剪,只给 LLM 它需要的信息
- 错误用自然语言,别抛 JSON 错误码
- 写操作加防护,读操作先上线
技术栈选择上:能上 Spring AI 就上 Spring AI(开发体验最好);上不了就用 Python 代理(最稳妥)。不要为了"纯 Java"而硬凑,MCP 是协议层的事,跟语言无关。 旧系统不是包袱,它是你 MCP 服务的坚实后端。你只需要给它穿上一件"AI 能看懂的外套"。