How MCP tool, resource, and prompt differ

1. Overview

When writing an MCP server, you have three ways to expose your capabilities to clients: tool, resource, and prompt. All three are declared on the server side and all three appear in the list that the client can read. Because of this, many people just pick randomly, or turn everything into a tool.

This post distinguishes these three primitives by one clear criterion: who decides when to use it. You’ll set up all three using Spring AI for the same business domain: orders. You’ll also write tests for each type. At the end is a table to help you pick the right primitive for each situation.

2. Three primitives and who controls them

The MCP specification ties each primitive to a “controlling subject”:

Primitive Who controls Nature JSON-RPC method
Tool Model (LLM decides to call it) Action, may have side effects tools/list, tools/call
Resource Application (host/client chooses context) Read-only data, identified by URI resources/list, resources/read, resources/templates/list
Prompt User (user actively chooses) Parameterized conversation template prompts/list, prompts/get

Think of it simply:

  • Tool is like a function that the LLM is allowed to call, e.g., cancel an order or send an email.
  • Resource is like a file that the application attaches to context, e.g., order details A100.
  • Prompt is like a slash command that the user presses, e.g., /complaint_reply.

The rest of this post will clarify each type with code.

3. Project setup

You need Spring Boot 3.x and Spring AI 1.1 or later. From version 1.1, Spring AI supports @McpTool, @McpResource, and @McpPrompt annotations.

<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>

Next is minimal configuration for the 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

By default, Spring AI automatically scans for beans with MCP annotations and registers them with the server. You don’t need any additional configuration.

The shared business domain for this entire post consists of a record and an in-memory 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: action decided by the model

A tool is something the LLM chooses to call while reasoning. The client sends the list of tools with descriptions to the model. The model reads the description and decides whether to call it, and with what arguments. Because of this, tools are suited for actions, especially actions that change state.

@Component
public class OrderTools {

    private final OrderRepository repository;

    public OrderTools(OrderRepository repository) {
        this.repository = repository;
    }

    @McpTool(name = "cancel_order",
             description = "Cancel an undelivered order. Returns the cancellation result as text.")
    public String cancelOrder(
            @McpToolParam(description = "Order ID, e.g., A100", required = true) String orderId) {

        Order order = repository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));

        if (order.status() == OrderStatus.SHIPPED) {
            return "Order " + orderId + " has been shipped and cannot be canceled.";
        }
        repository.save(order.withStatus(OrderStatus.CANCELLED));
        return "Order " + orderId + " has been canceled";
    }
}

A few important points in the code above:

  • @McpTool registers the method as a tool named cancel_order. The model will use the description to decide when to call this tool, so you need to write a specific description.
  • @McpToolParam generates JSON Schema for the parameter. The model knows orderId is a required string and can see an example value.
  • Any exception thrown will be returned to the client as an error result (isError: true). The model can read that error and possibly adjust the parameters and call again.

When the model decides to call a tool, the client sends this request:

{"jsonrpc":"2.0","id":7,"method":"tools/call",
 "params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}

The server replies:

{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Order A100 has been canceled"}],"isError":false}}

Note: Because tools have side effects, they come with user confirmation on the host side. Most clients have this mechanism. Nevertheless, the server still needs to check permissions and state itself. Don’t assume the model always calls correctly.

5. Resource: data chosen by the application

A resource is read-only data, each resource identified by a URI. The model doesn’t call resources the way it calls tools. Instead, the host application decides which resources go into context. The host can let the user pick from a list, auto-attach by rule, or subscribe for updates.

@Component
public class OrderResources {

    private final OrderRepository repository;

    public OrderResources(OrderRepository repository) {
        this.repository = repository;
    }

    @McpResource(uri = "order://{orderId}",
                 name = "order-detail",
                 description = "Details of an order in JSON format",
                 mimeType = "application/json")
    public String orderDetail(String orderId) {
        Order order = repository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
        return """
            {"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
            .formatted(order.id(), order.customerName(), order.total(), order.status());
    }
}

How the code above works:

  • uri = "order://{orderId}" is a resource template. The client will see this template via resources/templates/list.
  • The variable {orderId} in the URI is bound to the method parameter with the same name. Spring Boot enables the -parameters compiler flag, so this binding by name works without additional setup.
  • mimeType tells the client the content is JSON, so the client can display or process it appropriately.

This is a request to read a resource and its response:

--> {"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\"}"}]}}

You might wonder: why not just use a get_order tool? A get_order tool is still valid. But if the data is something the application wants to load up front, or the user wants to “pin” to the conversation, then a resource makes more sense for three reasons:

  • Resources don’t cost the model a reasoning step just to decide whether to call them.
  • Resources have stable URIs, so the host can cache them and show them in the UI.
  • Resources support subscribe to receive notifications when data changes.

Note: Not every client fully supports resources. Some clients only use tools. If that data must reach the model in every client, consider providing an additional read tool as well.

6. Prompt: template chosen by the user

A prompt is a parameterized conversation template that the user actively triggers. In clients with a UI, prompts often appear as slash commands or menu items. The server returns a list of pre-filled messages. The client inserts that list into the conversation and sends it to the model.

@Component
public class OrderPrompts {

    @McpPrompt(name = "complaint_reply",
               description = "Draft a reply to a customer complaint about an order")
    public GetPromptResult complaintReply(
            @McpArg(name = "orderId", description = "ID of the order in complaint", required = true) String orderId,
            @McpArg(name = "tone", description = "Tone: formal | friendly") String tone) {

        String style = (tone == null || tone.isBlank()) ? "formal" : tone;
        String text = """
            Please read resource order://%s, then draft a reply to the customer's complaint.
            Tone: %s. If the order hasn't shipped, suggest cancellation using the cancel_order tool.
            """.formatted(orderId, style);

        return new GetPromptResult("Reply to complaint about order " + orderId,
            List.of(new PromptMessage(Role.USER, new TextContent(text))));
    }
}

Explanation of the code:

  • @McpPrompt registers the prompt complaint_reply. @McpArg declares the parameters that the client will ask the user for before calling prompts/get.
  • The classes GetPromptResult, PromptMessage, Role, and TextContent are in io.modelcontextprotocol.spec.McpSchema.
  • The prompt content references both resource and tool. This is how the three primitives work together: the user chooses a prompt, the application loads a resource, and the model decides whether to call a tool.

The response when calling prompts/get:

--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"Reply to complaint about order A100",
     "messages":[{"role":"user","content":{"type":"text",
     "text":"Please read resource order://A100, then draft a reply to the customer's complaint. Tone: formal. ..."}}]}}

Note: The model does not call prompts on its own. If you want the LLM to trigger a workflow independently, you need a tool, not a prompt.

7. Testing main behavior

Methods with MCP annotations are still plain Java, so you can unit test them directly without starting the 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("Order A100 has been canceled");
        assertThat(repository.findById("A100")).get()
            .extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
    }

    @Test
    void cancelOrder_refuses_whenOrderIsShipped() {
        assertThat(tools.cancelOrder("A101")).contains("cannot be canceled");
        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("Tone: formal");
    }
}

These four tests check the right “contract” of each primitive:

  • Tool changes state when valid and refuses when not.
  • Resource has no side effects: after reading, the order state stays the same.
  • Prompt fills in parameters correctly and applies default values when the user doesn’t provide tone.

If you want end-to-end testing, you can use the McpClient from the MCP Java SDK to call tools/list and resources/read over the real transport.

8. Choosing the right primitive

You can ask yourself three questions:

  1. Does it change state, or does the model need to decide when to call it? If yes, use tool.
  2. Is it reference data, can it be identified by a URI, and will it be read multiple times? If yes, use resource.
  3. Is it a workflow that the user wants to trigger repeatedly with a few parameters? If yes, use prompt.

Common mistakes:

  • Turning everything into tools. The longer the tool list, the more the model tends to pick wrong, and the more tokens go to descriptions.
  • Resources with side effects, e.g., reading order://A100 marks it as “viewed”. A client can read a resource anytime, even to show a preview, so resources must be safe to read repeatedly.
  • Resources returning huge data. All the content goes into the model’s context window. Break it up by URI template or paginate.
  • Vague descriptions, e.g., just “process order”. For tools, the description is all the model has to decide whether to call it.
  • Putting business logic into prompts. A prompt is just text sent to the model. Required rules, like “never cancel a shipped order”, must be in the tool code.

9. Conclusion

Tool, resource, and prompt differ mainly in who controls them. The model calls tools. The application chooses resources. The user triggers prompts. When you pick the right primitive, your server is cleaner, the model makes fewer mistakes, and the client experience is more natural.

With Spring AI 1.1, all three types take just one annotation on a bean method: @McpTool, @McpResource, and @McpPrompt. Because they’re still plain Java methods, you can easily unit test the core behavior: tools must check state, resources must be read-only, and prompts must fill parameters correctly.

mcpspring-aiaillmjava