用 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 模板。