MCP 中的 tool、resource 和 prompt 有什么区别
1. 总览
当编写 MCP 服务器时,你有三种方式向客户端提供功能:tool、resource 和 prompt。这三个都在服务器端声明,并且都会出现在客户端读取的列表中。因此许多人会随意选择,或者将所有东西都变成 tool。
本文根据一个清晰的标准来区分这三个 primitive:谁决定何时使用它。你将使用 Spring AI 为同一个业务领域(订单)实现这三个。你还将为每种类型编写测试。文章末尾有一个表格来帮助你为每种情况选择正确的 primitive。
2. 三个 primitive 及其控制者
MCP 规范将每个 primitive 与一个“控制者”关联:
| Primitive | 谁控制 | 本质 | JSON-RPC 方法 |
|---|---|---|---|
| Tool | 模型(LLM 自己决定调用) | 操作,可能有副作用 | tools/list、tools/call |
| Resource | 应用程序(主机/客户端选择上下文) | 只读数据,由 URI 标识 | resources/list、resources/read、resources/templates/list |
| Prompt | 用户(用户主动选择) | 带参数的对话模板 | prompts/list、prompts/get |
简单理解如下:
- Tool 就像 LLM 被允许调用的函数,例如取消订单或发送电子邮件。
- Resource 就像应用程序附加到上下文中的文件,例如订单详情
A100。 - Prompt 就像用户按下的斜杠命令,例如
/complaint_reply。
文章的其余部分将通过代码来阐明每种类型。
3. 准备项目
你需要 Spring Boot 3.x 和 Spring AI 1.1 或更高版本。从版本 1.1 开始,Spring AI 支持 @McpTool、@McpResource 和 @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>
接下来是服务器的最小配置:
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
默认情况下,Spring AI 会自动扫描具有 MCP 注解的 bean 并将它们注册到服务器。你不需要进行任何额外的声明。
文章中共用的业务领域包括一个 record 和一个内存中的 repository:
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:由模型决定的操作
Tool 是 LLM 在推理过程中自己选择调用的东西。客户端将工具列表及其描述发送给模型。模型读取描述并决定是否调用,以及用什么参数调用。因此 tool 适用于操作,尤其是改变状态的操作。
@Component
public class OrderTools {
private final OrderRepository repository;
public OrderTools(OrderRepository repository) {
this.repository = repository;
}
@McpTool(name = "cancel_order",
description = "取消未发货的订单。以文本形式返回取消结果。")
public String cancelOrder(
@McpToolParam(description = "订单号,例如 A100", required = true) String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("未找到订单 " + orderId));
if (order.status() == OrderStatus.SHIPPED) {
return "订单 " + orderId + " 已发货,无法取消。";
}
repository.save(order.withStatus(OrderStatus.CANCELLED));
return "已取消订单 " + orderId;
}
}
上面的代码有几个需要注意的点:
@McpTool将方法注册为名称为cancel_order的工具。模型将根据description决定何时调用此工具,因此你需要编写具体的描述。@McpToolParam为参数生成 JSON Schema。模型知道orderId是必需的字符串,并可以看到示例值。- 抛出的异常将作为错误结果(
isError: true)返回给客户端。模型可以读取该错误并自我纠正参数后重新调用。
当模型决定调用工具时,客户端发送以下请求:
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}
服务器返回:
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"已取消订单 A100"}],"isError":false}}
注意: Tool 有副作用,因此在主机端需要用户确认步骤。大多数客户端都有这种机制。不过,服务器仍然必须自己检查权限和状态。不要假设模型总是调用正确。
5. Resource:由应用程序选择的数据
Resource 是只读数据,每个 resource 由 URI 标识。模型不像调用工具那样调用 resource。相反,主机应用程序决定哪些 resource 被纳入上下文。主机可以让用户从列表中选择,根据自己的规则自动附加,或订阅以接收更新。
@Component
public class OrderResources {
private final OrderRepository repository;
public OrderResources(OrderRepository repository) {
this.repository = repository;
}
@McpResource(uri = "order://{orderId}",
name = "order-detail",
description = "订单详情(JSON 格式)",
mimeType = "application/json")
public String orderDetail(String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("未找到订单 " + orderId));
return """
{"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
.formatted(order.id(), order.customerName(), order.total(), order.status());
}
}
上面的代码如何工作:
uri = "order://{orderId}"是一个 resource 模板。客户端将通过resources/templates/list看到此模板。- URI 中的
{orderId}变量绑定到方法的同名参数。Spring Boot 在编译时启用-parameters标志,因此按名称绑定无需额外配置即可工作。 mimeType告诉客户端返回的内容是 JSON,以便客户端可以适当地显示或处理。
这是读取 resource 的请求和返回的结果:
--> {"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\"}"}]}}
你可能想知道:为什么不直接使用 tool get_order?Tool get_order 仍然是有效的。但是如果数据是应用程序想要预加载的内容,或用户想要“固定”到对话中,那么 resource 会更合理,原因有三个:
- Resource 不会浪费模型的推理轮次来决定是否调用。
- Resource 有稳定的 URI,因此主机可以缓存并在 UI 中显示。
- Resource 支持
subscribe以在数据更改时接收通知。
注意: 并非所有客户端都完全支持 resource。有些客户端只使用 tool。如果该数据必须在所有客户端中都能到达模型,请考虑额外提供相应的读取 tool。
6. Prompt:由用户选择的模板
Prompt 是带参数的对话模板,由用户主动激活。在有界面的客户端中,prompt 通常以斜杠命令或菜单中的项目形式出现。服务器返回预填充的消息列表。客户端将该列表插入对话中,然后发送给模型。
@Component
public class OrderPrompts {
@McpPrompt(name = "complaint_reply",
description = "撰写回复客户关于订单的投诉信")
public GetPromptResult complaintReply(
@McpArg(name = "orderId", description = "投诉的订单号", required = true) String orderId,
@McpArg(name = "tone", description = "语气:正式 | 友好") String tone) {
String style = (tone == null || tone.isBlank()) ? "正式" : tone;
String text = """
请读取资源 order://%s,然后撰写回复客户投诉的信件。
语气:%s。如果订单尚未发货,建议使用 tool cancel_order 取消。
""".formatted(orderId, style);
return new GetPromptResult("回复订单 " + orderId + " 的投诉",
List.of(new PromptMessage(Role.USER, new TextContent(text))));
}
}
代码解释:
@McpPrompt注册 promptcomplaint_reply。@McpArg声明客户端在调用prompts/get前会询问用户的参数。- 类
GetPromptResult、PromptMessage、Role和TextContent位于io.modelcontextprotocol.spec.McpSchema中。 - prompt 的内容同时提到 resource 和 tool。这就是三个 primitive 如何协作的方式:用户选择 prompt,应用程序加载 resource,模型决定是否调用 tool。
调用 prompts/get 时返回的结果:
--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"回复订单 A100 的投诉",
"messages":[{"role":"user","content":{"type":"text",
"text":"请读取资源 order://A100,然后撰写回复客户投诉的信件。\n语气:正式。如果订单尚未发货,建议使用 tool cancel_order 取消。"}}]}}
注意: 模型不会自己调用 prompt。如果你想让 LLM 自动激活一个流程,你需要的是 tool,而不是 prompt。
7. 测试核心行为
具有 MCP 注解的方法仍然是纯 Java,因此你可以直接进行单元测试,无需启动 MCP 传输。
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("已取消订单 A100");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
}
@Test
void cancelOrder_refuses_whenOrderIsShipped() {
assertThat(tools.cancelOrder("A101")).contains("无法取消");
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("语气:正式");
}
}
这四个测试检查每个 primitive 的“契约”:
- Tool 在有效时改变状态,在无效时拒绝。
- Resource 没有副作用:读取后,订单状态保持不变。
- Prompt 正确填充参数,当用户未提供
tone时应用默认值。
如果要进行端到端测试,你可以使用 MCP Java SDK 中的 McpClient 通过真实传输调用 tools/list 和 resources/read。
8. 选择哪个 primitive?
你可以问自己以下三个问题:
- 是否改变状态,或需要模型自己决定何时调用? 如果是,使用 tool。
- 是否是参考数据,可以用 URI 标识并多次读取? 如果是,使用 resource。
- 是否是用户想用几个参数反复激活的流程? 如果是,使用 prompt。
常见的错误:
- 将所有东西都变成 tool。 tool 列表越长,模型就越容易选错,消耗越多 token 在描述部分。
- Resource 有副作用,例如读取
order://A100会将订单标记为“已查看”。客户端可以随时读取 resource,包括显示预览,所以 resource 必须在重复读取时是安全的。 - Resource 返回过大的数据。 全部内容会进入模型的 context window。请按 URI 模板分割或分页。
- 描述模糊,例如只写“处理订单”。对于 tool,description 是模型决定是否调用的唯一基础。
- 将业务逻辑硬编码到 prompt 中。 Prompt 只是发送给模型的文本。强制性的规则,如不能取消已发货的订单,必须在 tool 的代码中。
9. 总结
Tool、resource 和 prompt 主要的区别在于控制者。模型调用 tool。应用程序选择 resource。用户激活 prompt。选择正确的 primitive 时,你的服务器会更简洁,模型误调用会更少,客户端体验也会更自然。
在 Spring AI 1.1 中,这三种类型只需要在 bean 的方法上加一个注解:@McpTool、@McpResource 和 @McpPrompt。由于它们仍然是纯 Java 方法,你可以轻松进行单元测试核心行为:tool 必须检查状态,resource 必须是只读的,prompt 必须正确填充参数。