MCP tool, resource và prompt khác nhau thế nào
1. Tổng quan
Khi viết một MCP server, bạn có ba cách để đưa khả năng của mình cho client: tool, resource và prompt. Cả ba đều được khai báo phía server và đều xuất hiện trong danh sách mà client đọc được. Vì vậy nhiều người chọn bừa, hoặc biến mọi thứ thành tool.
Bài này phân biệt ba primitive đó theo một tiêu chí rõ ràng: ai quyết định khi nào dùng nó. Bạn sẽ cài cả ba bằng Spring AI cho cùng một miền nghiệp vụ là đơn hàng. Bạn cũng sẽ viết test cho từng loại. Cuối bài là một bảng giúp bạn chọn đúng primitive cho từng tình huống.
2. Ba primitive và người điều khiển
Đặc tả MCP gắn mỗi primitive với một “chủ thể điều khiển”:
| Primitive | Ai điều khiển | Bản chất | Method JSON-RPC |
|---|---|---|---|
| Tool | Model (LLM tự quyết định gọi) | Hành động, có thể có side effect | tools/list, tools/call |
| Resource | Application (host/client chọn ngữ cảnh) | Dữ liệu chỉ đọc, định danh bằng URI | resources/list, resources/read, resources/templates/list |
| Prompt | User (người dùng chủ động chọn) | Template hội thoại có tham số | prompts/list, prompts/get |
Hiểu đơn giản như sau:
- Tool giống một hàm mà LLM được phép gọi, ví dụ hủy đơn hoặc gửi email.
- Resource giống một file mà ứng dụng đính kèm vào ngữ cảnh, ví dụ chi tiết đơn
A100. - Prompt giống một slash command mà người dùng bấm, ví dụ
/complaint_reply.
Phần còn lại của bài sẽ làm rõ từng loại bằng code.
3. Chuẩn bị project
Bạn cần Spring Boot 3.x và Spring AI 1.1 trở lên. Từ bản 1.1, Spring AI hỗ trợ các annotation @McpTool, @McpResource và @McpPrompt.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Tiếp theo là cấu hình tối thiểu cho server:
spring.ai.mcp.server.name=order-mcp-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=SYNC
spring.ai.mcp.server.protocol=STREAMABLE
Mặc định, Spring AI tự quét các bean có annotation MCP và đăng ký chúng với server. Bạn không cần khai báo thêm gì.
Miền nghiệp vụ dùng chung cho cả bài gồm một record và một repository in-memory:
public enum OrderStatus { NEW, SHIPPED, CANCELLED }
public record Order(String id, String customerName, BigDecimal total, OrderStatus status) {
public Order withStatus(OrderStatus newStatus) {
return new Order(id, customerName, total, newStatus);
}
}
@Repository
public class OrderRepository {
private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
"A100", new Order("A100", "Lan", new BigDecimal("250000"), OrderStatus.NEW),
"A101", new Order("A101", "Minh", new BigDecimal("990000"), OrderStatus.SHIPPED)));
public Optional<Order> findById(String id) {
return Optional.ofNullable(orders.get(id));
}
public Order save(Order order) {
orders.put(order.id(), order);
return order;
}
}
4. Tool: hành động do model quyết định
Tool là thứ LLM tự chọn gọi trong lúc suy luận. Client gửi danh sách tool kèm description cho model. Model đọc description và quyết định có gọi hay không, gọi với tham số nào. Vì thế tool phù hợp với các hành động, nhất là hành động làm thay đổi trạng thái.
@Component
public class OrderTools {
private final OrderRepository repository;
public OrderTools(OrderRepository repository) {
this.repository = repository;
}
@McpTool(name = "cancel_order",
description = "Hủy một đơn hàng chưa giao. Trả về kết quả hủy dạng văn bản.")
public String cancelOrder(
@McpToolParam(description = "Mã đơn hàng, ví dụ A100", required = true) String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Không tìm thấy đơn " + orderId));
if (order.status() == OrderStatus.SHIPPED) {
return "Đơn " + orderId + " đã giao, không thể hủy.";
}
repository.save(order.withStatus(OrderStatus.CANCELLED));
return "Đã hủy đơn " + orderId;
}
}
Một vài điểm cần chú ý trong đoạn code trên:
@McpToolđăng ký method thành tool có têncancel_order. Model sẽ dựa vàodescriptionđể quyết định khi nào gọi tool này, nên bạn cần viết description cụ thể.@McpToolParamsinh ra JSON Schema cho tham số. Model biếtorderIdlà chuỗi bắt buộc và thấy được ví dụ giá trị.- Exception ném ra sẽ được trả về client dưới dạng kết quả lỗi (
isError: true). Model đọc được lỗi đó và có thể tự sửa tham số rồi gọi lại.
Khi model quyết định gọi tool, client gửi request sau:
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}
Server trả về:
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Đã hủy đơn A100"}],"isError":false}}
Lưu ý: Tool có side effect nên đi kèm bước xác nhận của người dùng ở phía host. Phần lớn client có cơ chế này. Tuy vậy, server vẫn phải tự kiểm tra quyền và trạng thái. Đừng giả định model luôn gọi đúng.
5. Resource: dữ liệu do ứng dụng chọn
Resource là dữ liệu chỉ đọc, mỗi resource được định danh bằng một URI. Model không gọi resource giống như gọi tool. Thay vào đó, ứng dụng host quyết định resource nào được đưa vào ngữ cảnh. Host có thể để người dùng chọn từ danh sách, tự động đính kèm theo quy tắc riêng, hoặc subscribe để nhận cập nhật.
@Component
public class OrderResources {
private final OrderRepository repository;
public OrderResources(OrderRepository repository) {
this.repository = repository;
}
@McpResource(uri = "order://{orderId}",
name = "order-detail",
description = "Chi tiết một đơn hàng dạng JSON",
mimeType = "application/json")
public String orderDetail(String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Không tìm thấy đơn " + orderId));
return """
{"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
.formatted(order.id(), order.customerName(), order.total(), order.status());
}
}
Cách đoạn code trên hoạt động:
uri = "order://{orderId}"là một resource template. Client sẽ thấy template này quaresources/templates/list.- Biến
{orderId}trong URI được bind vào tham số cùng tên của method. Spring Boot bật cờ-parameterskhi compile, nên việc bind theo tên hoạt động mà không cần cấu hình thêm. mimeTypecho client biết nội dung trả về là JSON, để client hiển thị hoặc xử lý cho phù hợp.
Đây là request đọc resource và kết quả trả về:
--> {"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"order://A101"}}
<-- {"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"order://A101",
"mimeType":"application/json",
"text":"{\"id\":\"A101\",\"customer\":\"Minh\",\"total\":990000,\"status\":\"SHIPPED\"}"}]}}
Bạn có thể thắc mắc: tại sao không dùng một tool get_order cho xong? Tool get_order vẫn hợp lệ. Nhưng nếu dữ liệu là thứ ứng dụng muốn nạp sẵn, hoặc người dùng muốn “ghim” vào cuộc hội thoại, thì resource hợp lý hơn vì ba lý do:
- Resource không tốn một vòng suy luận của model chỉ để quyết định có gọi hay không.
- Resource có URI ổn định, nên host có thể cache và hiển thị trong UI.
- Resource hỗ trợ
subscribeđể nhận thông báo khi dữ liệu thay đổi.
Lưu ý: Không phải client nào cũng hỗ trợ resource đầy đủ. Một số client chỉ dùng tool. Nếu dữ liệu đó bắt buộc phải tới được model trong mọi client, hãy cân nhắc cung cấp thêm một tool đọc tương ứng.
6. Prompt: template do người dùng chọn
Prompt là template hội thoại có tham số, được người dùng chủ động kích hoạt. Trong các client có giao diện, prompt thường hiện ra dưới dạng slash command hoặc một mục trong menu. Server trả về danh sách message đã điền sẵn. Client chèn danh sách đó vào cuộc hội thoại rồi gửi cho model.
@Component
public class OrderPrompts {
@McpPrompt(name = "complaint_reply",
description = "Soạn thư trả lời khiếu nại của khách về một đơn hàng")
public GetPromptResult complaintReply(
@McpArg(name = "orderId", description = "Mã đơn bị khiếu nại", required = true) String orderId,
@McpArg(name = "tone", description = "Giọng văn: lịch sự | thân thiện") String tone) {
String style = (tone == null || tone.isBlank()) ? "lịch sự" : tone;
String text = """
Hãy đọc resource order://%s, sau đó soạn thư trả lời khiếu nại của khách.
Giọng văn: %s. Nếu đơn chưa giao, đề xuất hủy bằng tool cancel_order.
""".formatted(orderId, style);
return new GetPromptResult("Trả lời khiếu nại đơn " + orderId,
List.of(new PromptMessage(Role.USER, new TextContent(text))));
}
}
Giải thích đoạn code:
@McpPromptđăng ký promptcomplaint_reply.@McpArgkhai báo các tham số mà client sẽ hỏi người dùng trước khi gọiprompts/get.- Các lớp
GetPromptResult,PromptMessage,RolevàTextContentnằm trongio.modelcontextprotocol.spec.McpSchema. - Nội dung prompt nhắc tới cả resource lẫn tool. Đây là cách ba primitive phối hợp với nhau: người dùng chọn prompt, ứng dụng nạp resource, còn model quyết định có gọi tool hay không.
Kết quả trả về khi gọi prompts/get:
--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"Trả lời khiếu nại đơn A100",
"messages":[{"role":"user","content":{"type":"text",
"text":"Hãy đọc resource order://A100, sau đó soạn thư ... Giọng văn: lịch sự. ..."}}]}}
Lưu ý: Model không tự gọi prompt. Nếu bạn muốn LLM tự kích hoạt một quy trình, bạn đang cần tool chứ không phải prompt.
7. Kiểm thử hành vi chính
Các method có annotation MCP vẫn là Java thuần, nên bạn có thể unit test trực tiếp mà không cần khởi động MCP transport.
class OrderMcpFeaturesTest {
private final OrderRepository repository = new OrderRepository();
private final OrderTools tools = new OrderTools(repository);
private final OrderResources resources = new OrderResources(repository);
private final OrderPrompts prompts = new OrderPrompts();
@Test
void cancelOrder_changesStatus_whenOrderIsNew() {
String result = tools.cancelOrder("A100");
assertThat(result).isEqualTo("Đã hủy đơn A100");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
}
@Test
void cancelOrder_refuses_whenOrderIsShipped() {
assertThat(tools.cancelOrder("A101")).contains("không thể hủy");
assertThat(repository.findById("A101")).get()
.extracting(Order::status).isEqualTo(OrderStatus.SHIPPED);
}
@Test
void orderDetail_isReadOnly() {
String json = resources.orderDetail("A100");
assertThat(json).contains("\"status\":\"NEW\"");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.NEW);
}
@Test
void complaintReply_usesDefaultTone() {
GetPromptResult result = prompts.complaintReply("A100", null);
assertThat(result.messages()).hasSize(1);
assertThat(((TextContent) result.messages().get(0).content()).text())
.contains("order://A100")
.contains("Giọng văn: lịch sự");
}
}
Bốn test này kiểm tra đúng “hợp đồng” của từng primitive:
- Tool thay đổi trạng thái khi hợp lệ và từ chối khi không hợp lệ.
- Resource không có side effect: sau khi đọc, trạng thái đơn vẫn giữ nguyên.
- Prompt điền đúng tham số và áp dụng giá trị mặc định khi người dùng không truyền
tone.
Nếu muốn kiểm thử end-to-end, bạn có thể dùng McpClient của MCP Java SDK để gọi tools/list và resources/read qua transport thật.
8. Chọn primitive nào?
Bạn có thể tự hỏi ba câu sau:
- Có làm thay đổi trạng thái, hoặc cần model tự quyết định thời điểm gọi không? Nếu có, dùng tool.
- Có phải dữ liệu tham chiếu, có thể định danh bằng URI và đọc nhiều lần không? Nếu có, dùng resource.
- Có phải một quy trình mà người dùng muốn kích hoạt lặp lại với vài tham số không? Nếu có, dùng prompt.
Những lỗi hay gặp:
- Biến mọi thứ thành tool. Danh sách tool càng dài, model càng dễ chọn sai và càng tốn token cho phần mô tả.
- Resource có side effect, ví dụ đọc
order://A100lại đánh dấu đơn là “đã xem”. Client được phép đọc resource bất cứ lúc nào, kể cả để hiển thị preview, nên resource phải an toàn khi đọc lặp lại. - Resource trả về dữ liệu quá lớn. Toàn bộ nội dung sẽ đi vào context window của model. Hãy chia nhỏ theo URI template hoặc phân trang.
- Description mơ hồ, ví dụ chỉ ghi “xử lý đơn”. Với tool, description chính là thứ duy nhất model dựa vào để quyết định có gọi hay không.
- Nhét logic nghiệp vụ vào prompt. Prompt chỉ là văn bản gửi cho model. Các quy tắc bắt buộc, như không được hủy đơn đã giao, phải nằm trong code của tool.
9. Kết luận
Tool, resource và prompt khác nhau chủ yếu ở người điều khiển. Model gọi tool. Ứng dụng chọn resource. Người dùng kích hoạt prompt. Khi chọn đúng primitive, server của bạn gọn hơn, model ít gọi nhầm hơn, và trải nghiệm trong client cũng tự nhiên hơn.
Với Spring AI 1.1, cả ba loại chỉ cần một annotation trên method của bean: @McpTool, @McpResource và @McpPrompt. Vì chúng vẫn là method Java thuần, bạn có thể unit test hành vi cốt lõi một cách dễ dàng: tool phải kiểm tra trạng thái, resource phải chỉ đọc, còn prompt phải điền đúng tham số.