Diferencia entre tool, resource y prompt en MCP

1. Descripción general

Al escribir un servidor MCP, tienes tres formas de proporcionar tus capacidades al cliente: tool, resource y prompt. Los tres se declaran en el servidor y todos aparecen en la lista que el cliente puede leer. Por lo tanto, muchas personas eligen al azar o convierten todo en tool.

Este artículo distingue los tres primitivos según un criterio claro: quién decide cuándo usarlos. Implementarás los tres con Spring AI para el mismo dominio empresarial: órdenes. También escribirás pruebas para cada tipo. Al final hay una tabla que te ayuda a elegir el primitivo correcto para cada situación.

2. Los tres primitivos y quién los controla

La especificación MCP vincula cada primitivo a un “sujeto que controla”:

Primitivo Quién controla Naturaleza Método JSON-RPC
Tool Modelo (el LLM decide por sí solo llamarlo) Acción, puede tener efecto secundario tools/list, tools/call
Resource Aplicación (host/cliente elige el contexto) Datos de solo lectura, identificados por URI resources/list, resources/read, resources/templates/list
Prompt Usuario (el usuario elige activamente) Plantilla de diálogo con parámetros prompts/list, prompts/get

Simplemente entiéndelo así:

  • Tool es como una función que el LLM puede llamar, por ejemplo cancelar una orden o enviar un correo.
  • Resource es como un archivo que la aplicación adjunta al contexto, por ejemplo los detalles de la orden A100.
  • Prompt es como un comando de barra que el usuario activa, por ejemplo /complaint_reply.

El resto del artículo aclarará cada tipo con código.

3. Preparar el proyecto

Necesitas Spring Boot 3.x y Spring AI 1.1 o superior. A partir de la versión 1.1, Spring AI soporta las anotaciones @McpTool, @McpResource y @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>

A continuación está la configuración mínima para el servidor:

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

Por defecto, Spring AI escanea automáticamente los beans con anotaciones MCP y los registra en el servidor. No necesitas declarar nada más.

El dominio empresarial compartido en todo el artículo comprende un record y un repositorio en memoria:

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: acciones decididas por el modelo

Tool es lo que el LLM elige llamar por sí solo durante el razonamiento. El cliente envía la lista de tools con descripciones al modelo. El modelo lee las descripciones y decide si llamarlas o no, y con qué parámetros. Por lo tanto, las tools son adecuadas para acciones, especialmente las que cambian el estado.

@Component
public class OrderTools {

    private final OrderRepository repository;

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

    @McpTool(name = "cancel_order",
             description = "Cancelar una orden que no ha sido enviada. Retorna el resultado de la cancelación en formato de texto.")
    public String cancelOrder(
            @McpToolParam(description = "ID de la orden, por ejemplo A100", required = true) String orderId) {

        Order order = repository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("No se encontró la orden " + orderId));

        if (order.status() == OrderStatus.SHIPPED) {
            return "La orden " + orderId + " ya ha sido enviada, no se puede cancelar.";
        }
        repository.save(order.withStatus(OrderStatus.CANCELLED));
        return "Orden " + orderId + " cancelada";
    }
}

Algunos puntos a tener en cuenta en el código anterior:

  • @McpTool registra el método como una tool llamada cancel_order. El modelo se basará en la description para decidir cuándo llamar a esta tool, así que debes escribir una descripción específica.
  • @McpToolParam genera un JSON Schema para el parámetro. El modelo sabe que orderId es una cadena obligatoria y ve un valor de ejemplo.
  • Las excepciones lanzadas se devuelven al cliente como un resultado de error (isError: true). El modelo puede leer el error y reintentar ajustando los parámetros.

Cuando el modelo decide llamar a la tool, el cliente envía la siguiente solicitud:

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

El servidor retorna:

{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Orden A100 cancelada"}],"isError":false}}

Nota: Las tools con efectos secundarios deben ir acompañadas de un paso de confirmación del usuario en el lado del host. La mayoría de clientes tienen este mecanismo. Sin embargo, el servidor aún debe verificar permisos y estado. No asumar que el modelo siempre llama correctamente.

5. Resource: datos elegidos por la aplicación

Resource es datos de solo lectura, cada recurso se identifica por un URI. El modelo no llama a los resources como lo hace con las tools. En su lugar, la aplicación host decide qué recursos se incluyen en el contexto. El host puede permitir que el usuario elija de una lista, adjuntar automáticamente según reglas propias, o suscribirse para recibir actualizaciones.

@Component
public class OrderResources {

    private final OrderRepository repository;

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

    @McpResource(uri = "order://{orderId}",
                 name = "order-detail",
                 description = "Detalles de una orden en formato JSON",
                 mimeType = "application/json")
    public String orderDetail(String orderId) {
        Order order = repository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("No se encontró la orden " + orderId));
        return """
            {"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
            .formatted(order.id(), order.customerName(), order.total(), order.status());
    }
}

Cómo funciona el código anterior:

  • uri = "order://{orderId}" es una plantilla de recurso. El cliente verá esta plantilla a través de resources/templates/list.
  • La variable {orderId} en el URI se vincula al parámetro del mismo nombre del método. Spring Boot activa la bandera -parameters durante la compilación, por lo que la vinculación por nombre funciona sin configuración adicional.
  • mimeType indica al cliente que el contenido devuelto es JSON, para que el cliente lo muestre o procese adecuadamente.

Esta es la solicitud de lectura de un resource y su respuesta:

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

Puedes preguntarte: ¿por qué no usar simplemente una tool get_order? Una tool get_order aún sería válida. Pero si los datos son algo que la aplicación quiere cargar previamente, o el usuario quiere “fijar” en la conversación, entonces un resource es más apropiado por tres razones:

  • El resource no cuesta una ronda de razonamiento del modelo solo para decidir si llamarlo o no.
  • El resource tiene un URI estable, por lo que el host puede cachearlo y mostrarlo en la UI.
  • El resource soporta subscribe para recibir notificaciones cuando los datos cambian.

Nota: No todos los clientes soportan recursos completamente. Algunos clientes solo usan tools. Si esos datos deben llegar obligatoriamente al modelo en cualquier cliente, considera proporcionar también una tool de lectura correspondiente.

6. Prompt: plantilla elegida por el usuario

Prompt es una plantilla de diálogo con parámetros, activada activamente por el usuario. En los clientes con interfaz gráfica, los prompts suelen aparecer como comandos de barra o un elemento en un menú. El servidor devuelve una lista de mensajes ya completados. El cliente inserta esa lista en la conversación y la envía al modelo.

@Component
public class OrderPrompts {

    @McpPrompt(name = "complaint_reply",
               description = "Redactar una respuesta a la queja de un cliente sobre una orden")
    public GetPromptResult complaintReply(
            @McpArg(name = "orderId", description = "ID de la orden con queja", required = true) String orderId,
            @McpArg(name = "tone", description = "Tono: formal | amigable") String tone) {

        String style = (tone == null || tone.isBlank()) ? "formal" : tone;
        String text = """
            Lee el resource order://%s, luego redacta una respuesta a la queja del cliente.
            Tono: %s. Si la orden aún no se ha enviado, sugiere cancelarla usando la tool cancel_order.
            """.formatted(orderId, style);

        return new GetPromptResult("Responder a la queja sobre la orden " + orderId,
            List.of(new PromptMessage(Role.USER, new TextContent(text))));
    }
}

Explicación del código:

  • @McpPrompt registra el prompt complaint_reply. @McpArg declara los parámetros que el cliente preguntará al usuario antes de llamar a prompts/get.
  • Las clases GetPromptResult, PromptMessage, Role y TextContent están en io.modelcontextprotocol.spec.McpSchema.
  • El contenido del prompt menciona tanto el resource como la tool. Así es cómo los tres primitivos trabajan juntos: el usuario elige el prompt, la aplicación carga el resource, y el modelo decide si llamar a la tool o no.

El resultado devuelto cuando se llama a prompts/get:

--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"Responder a la queja sobre la orden A100",
     "messages":[{"role":"user","content":{"type":"text",
     "text":"Lee el resource order://A100, luego redacta una respuesta a la queja del cliente. Tono: formal. ..."}}]}}

Nota: El modelo no llama a los prompts por sí solo. Si quieres que el LLM active automáticamente un procedimiento, necesitas una tool, no un prompt.

7. Probar el comportamiento principal

Los métodos con anotaciones MCP siguen siendo Java puro, así que puedes hacer unit tests directamente sin necesidad de iniciar el transporte 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("Orden A100 cancelada");
        assertThat(repository.findById("A100")).get()
            .extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
    }

    @Test
    void cancelOrder_refuses_whenOrderIsShipped() {
        assertThat(tools.cancelOrder("A101")).contains("no se puede cancelar");
        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("Tono: formal");
    }
}

Estas cuatro pruebas verifican el “contrato” correcto de cada primitivo:

  • Tool cambia el estado cuando es válido y rechaza cuando no lo es.
  • Resource no tiene efectos secundarios: después de leer, el estado de la orden permanece igual.
  • Prompt completa correctamente los parámetros y aplica el valor por defecto cuando el usuario no proporciona tone.

Si quieres hacer pruebas end-to-end, puedes usar McpClient del MCP Java SDK para llamar a tools/list y resources/read a través de un transporte real.

8. ¿Cuál primitivo elegir?

Puedes hacerte estas tres preguntas:

  1. ¿Cambia el estado o necesita que el modelo decida por sí solo cuándo llamarlo? Si es sí, usa tool.
  2. ¿Es datos de referencia que se pueden identificar por URI y leer varias veces? Si es sí, usa resource.
  3. ¿Es un procedimiento que el usuario quiere activar repetidamente con algunos parámetros? Si es sí, usa prompt.

Errores comunes:

  • Convertir todo en tool. Cuanto más larga es la lista de tools, más fácil es que el modelo elija equivocadamente y más tokens gasta en las descripciones.
  • Resource con efectos secundarios, por ejemplo, leer order://A100 marca la orden como “visto”. El cliente puede leer un resource en cualquier momento, incluso para mostrar una vista previa, así que debe ser seguro leerlo repetidamente.
  • Resource devuelve datos demasiado grandes. Todo el contenido entra en la ventana de contexto del modelo. Divídelo según URI template o usa paginación.
  • Descripción vaga, por ejemplo solo “procesar orden”. Con tools, la descripción es la única cosa en la que el modelo se basa para decidir si llamarla o no.
  • Meter lógica empresarial en el prompt. El prompt es solo texto enviado al modelo. Las reglas obligatorias, como no cancelar una orden ya enviada, deben estar en el código de la tool.

9. Conclusión

Tool, resource y prompt se diferencian principalmente en quién los controla. El modelo llama a las tools. La aplicación elige los resources. El usuario activa los prompts. Cuando eliges el primitivo correcto, tu servidor es más limpio, el modelo comete menos errores, y la experiencia en el cliente es más natural.

Con Spring AI 1.1, los tres tipos solo necesitan una anotación en el método del bean: @McpTool, @McpResource y @McpPrompt. Como siguen siendo métodos Java puro, puedes hacer unit tests del comportamiento central fácilmente: la tool debe verificar el estado, el resource debe ser de solo lectura, y el prompt debe completar correctamente los parámetros.

mcpspring-aiaillmjava