Viết MCP server đầu tiên bằng Java và Spring Boot

1. Tổng quan

Model Context Protocol (MCP) là chuẩn giao tiếp giúp các AI assistant (Claude, Copilot, v.v.) gọi được tool, đọc resource từ hệ thống của bạn một cách có cấu trúc. Bài này hướng dẫn bạn viết một MCP server bằng Java 21, expose một tool tra cứu đơn hàng (lookup_order), và kết nối thử với một MCP client qua stdio transport. Đọc xong, bạn dựng được MCP server chạy thật, viết test cho tool handler, và biết những lỗi thường gặp khi mới bắt đầu.

2. MCP là gì và khi nào cần viết server

MCP định nghĩa ba khái niệm chính mà server có thể expose:

  • Tool: hàm mà AI model có thể gọi, có input schema rõ ràng (giống function calling).
  • Resource: dữ liệu tĩnh hoặc động mà client có thể đọc (file, config, bản ghi DB…).
  • Prompt: template prompt dựng sẵn cho client tái sử dụng.

Bạn cần viết MCP server khi muốn AI assistant thao tác trực tiếp lên hệ thống nội bộ — ví dụ tra đơn hàng, tạo ticket, query log — mà không phải copy-paste dữ liệu thủ công vào chat.

Lưu ý: Nếu bạn chỉ cần model trả lời dựa trên văn bản tĩnh, dùng RAG hoặc prompt thông thường sẽ đơn giản hơn nhiều so với dựng cả một MCP server.

3. Chuẩn bị dự án

3.1. Dependency Maven

MCP Java SDK cung cấp sẵn các lớp để dựng server và transport. Thêm dependency sau vào 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 dùng để serialize/deserialize message JSON-RPC mà MCP dùng bên trong.

3.2. Domain model đơn giản

Trước khi viết tool, cần có dữ liệu để tool trả về. Dùng một Order giả lập:

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 chỉ là một map in-memory để demo — trong dự án thật, bạn sẽ gọi repository hoặc API.

4. Định nghĩa tool đầu tiên: lookup_order

Mỗi tool trong MCP cần một tên, mô tả (model dùng mô tả này để quyết định có nên gọi tool không), và input schema dạng JSON Schema.

public final class OrderTools {

    public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
        "lookup_order",
        "Tra cứu trạng thái và tổng tiền của một đơn hàng theo mã orderId",
        """
        {
          "type": "object",
          "properties": {
            "orderId": {
              "type": "string",
              "description": "Mã đơn hàng, ví dụ ORD-001"
            }
          },
          "required": ["orderId"]
        }
        """
    );
}
  • "lookup_order" là tên tool, client sẽ dùng chính xác chuỗi này để gọi.
  • Mô tả câu thứ hai rất quan trọng: model dựa vào đây để biết khi nào nên gọi tool này thay vì tool khác.
  • Schema bắt orderId là field bắt buộc, kiểu string.

Tiếp theo, viết handler thực thi tool — nhận tham số, gọi OrderService, và trả về 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("Không tìm thấy đơn hàng: " + orderId));
    }

    private McpSchema.CallToolResult success(Order order) {
        String text = "Đơn %s: trạng thái %s, tổng %.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);
    }
}

Tham số thứ hai của CallToolResult là isError. Khi true, client biết tool thất bại và có thể báo lại cho model để thử cách khác, thay vì coi đó là kết quả hợp lệ.

5. Khởi tạo MCP server với stdio transport

Transport phổ biến nhất khi mới tập viết MCP server là stdio: server đọc request từ stdin, viết response ra stdout. Đây cũng là cách Claude Desktop khởi chạy server local.

@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 biến stdin/stdout của process thành kênh giao tiếp JSON-RPC.
  • serverInfo(...) khai báo tên và version — client sẽ hiển thị thông tin này khi liệt kê server đã kết nối.
  • capabilities().tools(true) báo cho client biết server này có expose tool, client sẽ gửi tools/list để lấy danh sách.
  • server.start() block tiến trình, chờ request từ client qua stdin.

Lưu ý: Vì stdio transport giữ tiến trình sống bằng cách block, nếu chạy trong Spring Boot web app bình thường (có @SpringBootApplication với web server), bạn nên tắt web server (spring.main.web-application-type=none trong application.properties) để tránh xung đột port và log lẫn vào stdout.

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

Banner và log console phải tắt hẳn — MCP dùng stdout làm kênh truyền dữ liệu, bất kỳ dòng log nào lẫn vào đó sẽ phá JSON-RPC message.

6. Thêm resource: expose dữ liệu tĩnh

Ngoài tool, bạn có thể expose resource để client đọc trực tiếp (không cần gọi qua model):

McpSchema.Resource policyResource = new McpSchema.Resource(
    "file:///policies/return-policy.md",
    "Chính sách đổi trả",
    "Nội dung chính sách đổi trả hàng",
    "text/markdown",
    null
);

McpServerFeatures.SyncResourceSpecification policySpec =
    new McpServerFeatures.SyncResourceSpecification(
        policyResource,
        (exchange, request) -> new McpSchema.ReadResourceResult(
            List.of(new McpSchema.TextResourceContents(
                policyResource.uri(),
                "text/markdown",
                "# Chính sách đổi trả\nKhách được đổi trả trong 7 ngày."
            ))
        )
    );

Thêm .resources(policySpec) vào builder ở bước 5 là server đã expose thêm resource này.

7. Chạy thử với Claude Desktop

Build jar rồi cấu hình Claude Desktop (hoặc client MCP khác) trỏ tới server của bạn:

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

Khởi động lại client, mở cuộc hội thoại mới và hỏi: “Đơn hàng ORD-001 đang ở trạng thái nào?”. Model sẽ tự quyết định gọi lookup_order với orderId = "ORD-001", server trả về text, model dùng kết quả đó để trả lời bạn.

8. Viết test cho tool handler

Phần logic quan trọng nhất — xử lý tham số và trả CallToolResult — hoàn toàn test được như code Java thông thường, không cần transport thật.

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();
    }
}

Test đầu tiên kiểm tra đơn hợp lệ: isError() phải false và nội dung text chứa trạng thái SHIPPED. Test thứ hai đảm bảo khi không tìm thấy đơn, isError() trả true — đây là tín hiệu quan trọng để client/model biết cần xử lý khác đi.

✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound

9. Lỗi hay gặp

  • Log lẫn vào stdout: dùng stdio transport mà quên tắt banner/log console sẽ làm client nhận JSON-RPC message bị hỏng, lỗi parse rất khó debug. Luôn kiểm tra stdout chỉ chứa message MCP bằng cách redirect ra file khi test thủ công.
  • Mô tả tool quá sơ sài: nếu description chỉ ghi “lookup order”, model nhiều khi không biết khi nào nên gọi. Viết description rõ input/output mong đợi.
  • Input schema thiếu required: model có thể gọi tool thiếu field bắt buộc, handler bị NullPointerException khi cast tham số. Luôn validate tham số đầu vào trong handler, không tin tưởng hoàn toàn vào schema.
  • Dùng async server (McpServer.async) nhưng không await đúng cách: dễ bị race condition khi nhiều request tool đến gần nhau. Nếu chưa quen reactive, bắt đầu với McpServer.sync như trong bài này.

Khi nào không nên viết MCP server riêng: nếu hệ thống của bạn đã có REST API ổn định và chỉ cần một client gọi thẳng, viết thêm lớp MCP là dư thừa. MCP chỉ đáng đầu tư khi bạn muốn nhiều AI client khác nhau (Claude Desktop, IDE plugin, agent tự viết) đều có thể dùng chung tool mà không phải tích hợp riêng lẻ từng bên.

10. Kết luận

Bạn vừa dựng một MCP server Java hoàn chỉnh: khai báo tool với schema rõ ràng, viết handler xử lý logic, expose thêm resource, và test handler độc lập với transport. Bước tiếp theo tự nhiên là thử transport SSE để chạy server qua HTTP thay vì stdio, hoặc thêm prompt template cho các tác vụ lặp lại thường gặp trong domain của bạn.

mcpjavaspring-bootaitool-calling