用 Java 和 Spring Boot 编写第一个 MCP server

1. 概述

Model Context Protocol (MCP) 是一种通信标准,帮助 AI 助手(Claude、Copilot 等)以结构化的方式调用 tool、读取系统中的 resource。本文将指导你用 Java 21 编写一个 MCP server,暴露一个查询订单的 tool(lookup_order),并尝试通过 stdio transport 连接到 MCP 客户端。阅读完毕后,你将能够构建一个真正运行的 MCP server,为 tool handler 编写测试,并了解初学者常见的错误。

2. MCP 是什么,何时需要编写 server

MCP 定义了 server 可以暴露的三个主要概念:

  • Tool:AI 模型可以调用的函数,具有清晰的 input schema(类似于 function calling)。
  • Resource:客户端可以读取的静态或动态数据(文件、配置、数据库记录等)。
  • Prompt:为客户端预构建的可重用的 prompt 模板。

当你希望 AI 助手直接对内部系统进行操作时,你需要编写 MCP server — 例如查询订单、创建工单、查询日志 — 而无需手动复制粘贴数据到聊天中。

提示: 如果你只需要模型基于静态文本回答,使用 RAG 或常规 prompt 会比构建整个 MCP server 简单得多。

3. 准备项目

3.1. Maven 依赖

MCP Java SDK 提供了构建 server 和 transport 的类。将以下依赖添加到 pom.xml:

<dependency>
    <groupId>io.modelcontextprotocol.sdk</groupId>
    <artifactId>mcp</artifactId>
    <version>0.8.1</version>
</dependency>

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

Jackson 用于序列化/反序列化 MCP 内部使用的 JSON-RPC 消息。

3.2. 简单的 domain model

在编写 tool 之前,需要有数据供 tool 返回。使用一个模拟的 Order:

public record Order(String id, String status, double total) {
}
@Service
public class OrderService {

    private final Map<String, Order> orders = Map.of(
        "ORD-001", new Order("ORD-001", "SHIPPED", 450_000),
        "ORD-002", new Order("ORD-002", "PENDING", 120_000)
    );

    public Optional<Order> findById(String orderId) {
        return Optional.ofNullable(orders.get(orderId));
    }
}

OrderService 只是一个内存中的 map 用于演示 — 在真实项目中,你将调用 repository 或 API。

4. 定义第一个 tool:lookup_order

MCP 中的每个 tool 需要一个名称、描述(model 根据描述决定是否应该调用该 tool)和 input schema(JSON Schema 格式)。

public final class OrderTools {

    public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
        "lookup_order",
        "根据订单代码查询订单的状态和总金额",
        """
        {
          "type": "object",
          "properties": {
            "orderId": {
              "type": "string",
              "description": "订单代码,例如 ORD-001"
            }
          },
          "required": ["orderId"]
        }
        """
    );
}
  • "lookup_order" 是 tool 的名称,client 将使用这个确切的字符串来调用它。
  • 第二个描述非常重要:model 根据这个来判断何时应该调用此 tool 而不是其他 tool。
  • Schema 规定 orderId 是必需字段,类型为 string。

接下来,编写执行 tool 的 handler — 接收参数、调用 OrderService 并返回 CallToolResult:

@Component
public class OrderToolHandler {

    private final OrderService orderService;

    public OrderToolHandler(OrderService orderService) {
        this.orderService = orderService;
    }

    public McpSchema.CallToolResult handle(Map<String, Object> arguments) {
        String orderId = (String) arguments.get("orderId");

        return orderService.findById(orderId)
            .map(order -> success(order))
            .orElseGet(() -> error("找不到订单:" + orderId));
    }

    private McpSchema.CallToolResult success(Order order) {
        String text = "订单 %s:状态 %s,总金额 %.0f 元"
            .formatted(order.id(), order.status(), order.total());
        return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(text)), false);
    }

    private McpSchema.CallToolResult error(String message) {
        return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(message)), true);
    }
}

CallToolResult 的第二个参数是 isError。当为 true 时,client 知道 tool 失败,可以报告给 model 以尝试其他方法,而不是将其视为有效结果。

5. 使用 stdio transport 初始化 MCP server

编写 MCP server 时最常见的 transport 是 stdio:server 从 stdin 读取请求,向 stdout 写入响应。这也是 Claude Desktop 启动本地 server 的方式。

@Component
public class McpOrderServer implements CommandLineRunner {

    private final OrderToolHandler toolHandler;

    public McpOrderServer(OrderToolHandler toolHandler) {
        this.toolHandler = toolHandler;
    }

    @Override
    public void run(String... args) {
        McpServerTransportProvider transportProvider =
            new StdioServerTransportProvider(new ObjectMapper());

        McpServerFeatures.SyncToolSpecification lookupOrderSpec =
            new McpServerFeatures.SyncToolSpecification(
                OrderTools.LOOKUP_ORDER,
                (exchange, arguments) -> toolHandler.handle(arguments)
            );

        McpSyncServer server = McpServer.sync(transportProvider)
            .serverInfo("nonla-order-server", "1.0.0")
            .capabilities(McpSchema.ServerCapabilities.builder()
                .tools(true)
                .build())
            .tools(lookupOrderSpec)
            .build();

        server.start();
    }
}
  • StdioServerTransportProvider 将进程的 stdin/stdout 转换为 JSON-RPC 通信通道。
  • serverInfo(...) 声明名称和版本 — client 在列出已连接的 server 时将显示此信息。
  • capabilities().tools(true) 告诉 client 此 server 暴露了 tool,client 将发送 tools/list 来获取列表。
  • server.start() 阻塞进程,等待来自 client 的 stdin 请求。

注意: 由于 stdio transport 通过阻塞来保持进程活跃,如果在普通的 Spring Boot web app 中运行(带有 web server 的 @SpringBootApplication),你应该关闭 web server(在 application.properties 中使用 spring.main.web-application-type=none)以避免端口冲突和日志混入 stdout。

spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=

Banner 和 console 日志必须完全关闭 — MCP 使用 stdout 作为数据传输通道,任何混入其中的日志行都会破坏 JSON-RPC 消息。

6. 添加 resource:暴露静态数据

除了 tool,你还可以暴露 resource 供 client 直接读取(无需通过 model 调用):

McpSchema.Resource policyResource = new McpSchema.Resource(
    "file:///policies/return-policy.md",
    "退货政策",
    "退货政策内容",
    "text/markdown",
    null
);

McpServerFeatures.SyncResourceSpecification policySpec =
    new McpServerFeatures.SyncResourceSpecification(
        policyResource,
        (exchange, request) -> new McpSchema.ReadResourceResult(
            List.of(new McpSchema.TextResourceContents(
                policyResource.uri(),
                "text/markdown",
                "# 退货政策\n客户可在 7 天内退货。"
            ))
        )
    );

在第 5 步的 builder 中添加 .resources(policySpec) 即可让 server 暴露此 resource。

7. 使用 Claude Desktop 试运行

构建 jar 文件,然后配置 Claude Desktop(或其他 MCP client)指向你的 server:

{
  "mcpServers": {
    "nonla-order-server": {
      "command": "java",
      "args": ["-jar", "/path/to/order-mcp-server.jar"]
    }
  }
}

重启 client,打开新对话并提问:“ORD-001 订单现在处于什么状态?”。Model 将自动决定使用 orderId = "ORD-001" 调用 lookup_order,server 返回文本,model 使用该结果来回答你。

8. 为 tool handler 编写测试

最重要的逻辑部分 — 处理参数并返回 CallToolResult — 完全可以像普通 Java 代码一样进行测试,不需要实际的 transport。

class OrderToolHandlerTest {

    private final OrderService orderService = new OrderService();
    private final OrderToolHandler handler = new OrderToolHandler(orderService);

    @Test
    void shouldReturnOrderInfo_whenOrderExists() {
        var result = handler.handle(Map.of("orderId", "ORD-001"));

        assertThat(result.isError()).isFalse();
        assertThat(result.content())
            .hasSize(1)
            .first()
            .isInstanceOfSatisfying(McpSchema.TextContent.class,
                content -> assertThat(content.text()).contains("SHIPPED"));
    }

    @Test
    void shouldReturnError_whenOrderNotFound() {
        var result = handler.handle(Map.of("orderId", "ORD-999"));

        assertThat(result.isError()).isTrue();
    }
}

第一个测试检查有效订单:isError() 必须为 false,文本内容包含状态 SHIPPED。第二个测试确保当找不到订单时,isError() 返回 true — 这是重要信号,让 client/model 知道需要不同的处理方式。

✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound

9. 常见错误

  • 日志混入 stdout:使用 stdio transport 但忘记关闭 banner/log console 会导致 client 接收破坏的 JSON-RPC 消息,解析错误非常难以调试。始终通过在手动测试时重定向到文件来检查 stdout 是否仅包含 MCP 消息。
  • Tool 描述过于简洁:如果描述只写“查询订单”,model 通常不知道何时应该调用。写出清晰的输入/输出预期的描述。
  • Input schema 缺少 required:model 可能调用 tool 时缺少必需字段,handler 在转换参数时会出现 NullPointerException。始终在 handler 中验证输入参数,不要完全依赖 schema。
  • 使用 async server(McpServer.async)但没有正确 await:当多个 tool 请求接近时容易出现竞速条件。如果不熟悉 reactive,可以像本文一样从 McpServer.sync 开始。

何时不应该编写单独的 MCP server: 如果你的系统已经有稳定的 REST API,只需要一个 client 直接调用,那么添加 MCP 层就是多余的。MCP 只有在你希望多个不同的 AI client(Claude Desktop、IDE 插件、自定义 agent)都能使用相同的 tool,而无需为每一方进行单独集成时,才值得投资。

10. 总结

你刚刚构建了一个完整的 MCP server Java:用清晰的 schema 声明 tool、编写处理逻辑的 handler、暴露额外的 resource,以及独立于 transport 进行 handler 测试。下一步自然是尝试使用 SSE transport 通过 HTTP 运行 server 而不是 stdio,或为你的 domain 中常见的重复任务添加 prompt 模板。

mcpjavaspring-bootaitool-calling