旧 REST 接口封装成 MCP 服务:完整实战指南

当 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 语义化翻译

  1. 不改旧系统,在中间加一层薄适配
  2. Tool 描述面向 AI,写清楚"什么时候用、怎么用、返回什么"
  3. 响应做裁剪,只给 LLM 它需要的信息
  4. 错误用自然语言,别抛 JSON 错误码
  5. 写操作加防护,读操作先上线

技术栈选择上:能上 Spring AI 就上 Spring AI(开发体验最好);上不了就用 Python 代理(最稳妥)。不要为了"纯 Java"而硬凑,MCP 是协议层的事,跟语言无关。 旧系统不是包袱,它是你 MCP 服务的坚实后端。你只需要给它穿上一件"AI 能看懂的外套"。

相关推荐
2301_786756601 小时前
不做分子仿真、不布局自动化实验室,垂直科研智能平台同样跑通 AI for Science 可持续变现
运维·人工智能·自动化
大数据点灯人1 小时前
【大模型】深度解答:OOV 与 Tokenizer 词汇表共用问题
人工智能·深度学习·ai·大模型·transformer
Zane19941 小时前
daemon 线程说没就没?一文讲透 threading 的适用场景与线程安全
后端·python
TMT星球1 小时前
快手Q2总收入355亿元:月活近8亿,核心商业收入同比增长7.4%
大数据·人工智能
凤山老林1 小时前
从 RestTemplate 到 HttpClient 5:Spring Boot HTTP 客户端性能调优与连接池治理
spring boot·后端·http
霸道流氓气质1 小时前
ima.copilot-AI知识库-完整使用手册与最新动态
人工智能·copilot
Csvn1 小时前
📊 SQL 入门 Day 20:锁机制
后端·sql
牧羊人.3331 小时前
计算机视觉基础 第 9 章|实战:银行卡号识别
图像处理·人工智能·opencv·计算机视觉·图搜索算法
CRMEB1 小时前
商城大促活动策划全流程:从定目标到复盘四阶段
java·开发语言·人工智能·ai·开源·php