Como ferramentas, recursos e prompts do MCP diferem
1. Visão geral
Ao escrever um servidor MCP, você tem três formas de oferecer suas capacidades ao cliente: tool, resource e prompt. Todos os três são declarados no lado do servidor e aparecem na lista que o cliente lê. Por isso muitas pessoas escolhem aleatoriamente ou transformam tudo em tool.
Este artigo diferencia os três primitivos com base em um critério claro: quem decide quando usá-los. Você vai implementar todos os três usando Spring AI para o mesmo domínio de negócio: pedidos. Você também vai escrever testes para cada tipo. Ao final do artigo há uma tabela para ajudá-lo a escolher o primitivo certo para cada situação.
2. Os três primitivos e quem os controla
A especificação MCP associa cada primitivo a um “agente controlador”:
| Primitivo | Quem controla | Natureza | Method JSON-RPC |
|---|---|---|---|
| Tool | Model (a LLM decide por conta própria chamar) | Ação, pode ter efeito colateral | tools/list, tools/call |
| Resource | Aplicação (host/cliente escolhe o contexto) | Dados somente leitura, identificados por URI | resources/list, resources/read, resources/templates/list |
| Prompt | Usuário (usuário escolhe ativamente) | Template de diálogo com parâmetros | prompts/list, prompts/get |
Pense de forma simples assim:
- Tool é como uma função que a LLM tem permissão de chamar, por exemplo cancelar um pedido ou enviar um e-mail.
- Resource é como um arquivo que a aplicação anexa ao contexto, por exemplo os detalhes do pedido
A100. - Prompt é como um slash command que o usuário clica, por exemplo
/complaint_reply.
O resto do artigo vai esclarecer cada tipo com código.
3. Preparando o projeto
Você precisa do Spring Boot 3.x e Spring AI 1.1 ou superior. A partir da versão 1.1, o Spring AI oferece suporte às anotações @McpTool, @McpResource e @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>
Em seguida, a configuração mínima para o 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 padrão, o Spring AI varre automaticamente beans com anotações MCP e os registra com o servidor. Você não precisa declarar nada adicional.
O domínio de negócio compartilhado por todo o artigo consiste de um record e um repositório em memória:
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: ação que o model decide
Tool é algo que a LLM escolhe chamar durante o raciocínio. O cliente envia a lista de tools com a descrição ao model. O model lê a descrição e decide se vai chamar ou não, com quais parâmetros. Por isso tools são apropriadas para ações, especialmente ações que mudam o estado.
@Component
public class OrderTools {
private final OrderRepository repository;
public OrderTools(OrderRepository repository) {
this.repository = repository;
}
@McpTool(name = "cancel_order",
description = "Cancelar um pedido não entregue. Retorna o resultado em texto.")
public String cancelOrder(
@McpToolParam(description = "ID do pedido, por exemplo A100", required = true) String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Pedido não encontrado: " + orderId));
if (order.status() == OrderStatus.SHIPPED) {
return "Pedido " + orderId + " já foi entregue, não pode cancelar.";
}
repository.save(order.withStatus(OrderStatus.CANCELLED));
return "Pedido " + orderId + " cancelado.";
}
}
Alguns pontos importantes no código acima:
@McpToolregistra o método como um tool com o nomecancel_order. O model vai basear-se nadescriptionpara decidir quando chamar esse tool, então você precisa escrever uma descrição específica.@McpToolParamgera um JSON Schema para o parâmetro. O model sabe queorderIdé uma string obrigatória e vê um exemplo de valor.- Exceções lançadas são retornadas ao cliente como resultado de erro (
isError: true). O model lê o erro e pode corrigir o parâmetro e chamar novamente.
Quando o model decide chamar a tool, o cliente envia a seguinte requisição:
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}
O servidor retorna:
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Pedido A100 cancelado."}],"isError":false}}
Nota: Como tools têm efeito colateral, geralmente vêm acompanhadas de um passo de confirmação do usuário no lado do host. A maioria dos clientes tem esse mecanismo. Ainda assim, o servidor deve sempre verificar permissões e estado. Não assuma que o model sempre chama corretamente.
5. Resource: dados que a aplicação escolhe
Resource são dados somente leitura, cada resource é identificado por uma URI. O model não chama um resource assim como chama uma tool. Em vez disso, a aplicação host decide qual resource vai ser inserido no contexto. O host pode deixar o usuário escolher de uma lista, anexar automaticamente de acordo com suas próprias regras, ou se inscrever para receber atualizações.
@Component
public class OrderResources {
private final OrderRepository repository;
public OrderResources(OrderRepository repository) {
this.repository = repository;
}
@McpResource(uri = "order://{orderId}",
name = "order-detail",
description = "Detalhes de um pedido em formato JSON",
mimeType = "application/json")
public String orderDetail(String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Pedido não encontrado: " + orderId));
return """
{"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
.formatted(order.id(), order.customerName(), order.total(), order.status());
}
}
Como o código acima funciona:
uri = "order://{orderId}"é um template de resource. O cliente vai ver esse template através deresources/templates/list.- A variável
{orderId}na URI é vinculada ao parâmetro de mesmo nome do método. O Spring Boot habilita a flag-parametersna compilação, então a vinculação por nome funciona sem configuração adicional. mimeTypeinforma ao cliente que o conteúdo é JSON, para que ele possa exibir ou processar adequadamente.
Aqui está a requisição de leitura de um resource e a resposta:
--> {"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\"}"}]}}
Você pode se perguntar: por que não usar uma tool get_order para terminar logo? Uma tool get_order ainda é válida. Mas se os dados são algo que a aplicação quer pré-carregar, ou o usuário quer “fixar” na conversa, então resource faz mais sentido por três razões:
- Resource não custa uma volta de raciocínio do model apenas para decidir se chama ou não.
- Resource tem uma URI estável, então o host pode fazer cache e exibir na UI.
- Resource oferece suporte a
subscribepara receber notificações quando os dados mudam.
Nota: Nem todo cliente oferece suporte completo a resources. Alguns clientes usam apenas tools. Se esses dados devem chegar ao model em qualquer cliente, considere oferecer também uma tool de leitura correspondente.
6. Prompt: template que o usuário escolhe
Prompt é um template de diálogo com parâmetros, ativado ativamente pelo usuário. Em clientes com interface gráfica, prompts costumam aparecer como slash commands ou um item em um menu. O servidor retorna uma lista de mensagens pré-preenchidas. O cliente insere essa lista na conversa e envia para o model.
@Component
public class OrderPrompts {
@McpPrompt(name = "complaint_reply",
description = "Redigir uma resposta a uma reclamação do cliente sobre um pedido")
public GetPromptResult complaintReply(
@McpArg(name = "orderId", description = "ID do pedido reclamado", required = true) String orderId,
@McpArg(name = "tone", description = "Tom: formal | amigável") String tone) {
String style = (tone == null || tone.isBlank()) ? "formal" : tone;
String text = """
Leia o resource order://%s, depois redija uma resposta a uma reclamação do cliente.
Tom: %s. Se o pedido ainda não foi entregue, sugira cancelar usando a tool cancel_order.
""".formatted(orderId, style);
return new GetPromptResult("Responder reclamação do pedido " + orderId,
List.of(new PromptMessage(Role.USER, new TextContent(text))));
}
}
Explicação do código:
@McpPromptregistra o promptcomplaint_reply.@McpArgdeclara os parâmetros que o cliente vai pedir ao usuário antes de chamarprompts/get.- As classes
GetPromptResult,PromptMessage,RoleeTextContentestão emio.modelcontextprotocol.spec.McpSchema. - O conteúdo do prompt faz referência tanto a resources quanto a tools. Assim é como os três primitivos trabalham juntos: o usuário escolhe o prompt, a aplicação carrega um resource, e o model decide se chama a tool ou não.
Resultado retornado ao chamar prompts/get:
--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"Responder reclamação do pedido A100",
"messages":[{"role":"user","content":{"type":"text",
"text":"Leia o resource order://A100, depois redija uma resposta a uma reclamação ... Tom: formal. ..."}}]}}
Nota: O model não chama um prompt por conta própria. Se você quer que a LLM ative um processo automaticamente, você precisa de uma tool, não de um prompt.
7. Testando o comportamento principal
Métodos com anotações MCP continuam sendo Java puro, então você pode fazer unit tests diretamente sem precisar inicializar o 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("Pedido A100 cancelado.");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
}
@Test
void cancelOrder_refuses_whenOrderIsShipped() {
assertThat(tools.cancelOrder("A101")).contains("não pode 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("Tom: formal");
}
}
Esses quatro testes verificam o “contrato” correto de cada primitivo:
- Tool muda o estado quando válido e recusa quando inválido.
- Resource não tem efeito colateral: após ler, o estado do pedido permanece o mesmo.
- Prompt preenche corretamente os parâmetros e aplica o valor padrão quando o usuário não fornece um
tone.
Se quiser fazer testes end-to-end, você pode usar McpClient do MCP Java SDK para chamar tools/list e resources/read através de um transporte real.
8. Escolhendo qual primitivo usar
Você pode se fazer essas três perguntas:
- Vai mudar o estado ou o model precisa decidir por conta própria quando chamar? Se sim, use tool.
- São dados de referência que podem ser identificados por URI e lidos múltiplas vezes? Se sim, use resource.
- É um processo que o usuário quer ativar repetidamente com alguns parâmetros? Se sim, use prompt.
Erros comuns:
- Transformar tudo em tool. Quanto mais longa a lista de tools, mais o model pode escolher errado e mais tokens gastos na descrição.
- Resource com efeito colateral, por exemplo ler
order://A100marca o pedido como “visualizado”. O cliente pode ler um resource a qualquer momento, até para pré-visualizar, então resource deve ser seguro para ler repetidamente. - Resource retorna dados muito grandes. Todo o conteúdo vai para a janela de contexto do model. Divida por template de URI ou use paginação.
- Descrição vaga, por exemplo só escrever “processar pedido”. Com tools, a descrição é a única coisa em que o model se baseia para decidir se chama ou não.
- Colocar lógica de negócio em prompts. Prompts são apenas texto enviado ao model. Regras obrigatórias, como “não cancelar pedidos já entregues”, devem estar no código da tool.
9. Conclusão
Tool, resource e prompt diferem principalmente em quem os controla. O model chama tools. A aplicação escolhe resources. O usuário ativa prompts. Ao escolher o primitivo correto, seu servidor fica mais limpo, o model erra menos ao chamar, e a experiência no cliente fica mais natural.
Com Spring AI 1.1, todos os três tipos precisam apenas de uma anotação no método de um bean: @McpTool, @McpResource e @McpPrompt. Como continuam sendo métodos Java puros, você pode fazer testes unitários do comportamento essencial facilmente: a tool deve verificar o estado, o resource deve ser somente leitura, e o prompt deve preencher os parâmetros corretamente.