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 注册 prompt complaint_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?

你可以问自己以下三个问题:

  1. 是否改变状态,或需要模型自己决定何时调用? 如果是,使用 tool。
  2. 是否是参考数据,可以用 URI 标识并多次读取? 如果是,使用 resource。
  3. 是否是用户想用几个参数反复激活的流程? 如果是,使用 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 必须正确填充参数。

mcpspring-aiaillmjava