Les différences entre tool, resource et prompt en MCP
1. Aperçu
Quand vous écrivez un serveur MCP, vous avez trois façons d’offrir vos capacités au client : tool, resource et prompt. Tous les trois sont déclarés côté serveur et apparaissent dans la liste que le client lit. C’est pourquoi nombreux les choisissent au hasard, ou transforment tout en tool.
Cet article distingue ces trois primitives selon un critère clair : qui décide quand les utiliser. Vous implanterez tous les trois avec Spring AI pour un même domaine métier : les commandes. Vous écrirez aussi des tests pour chaque type. À la fin se trouve un tableau pour vous aider à choisir la bonne primitive pour chaque situation.
2. Les trois primitives et leurs contrôleurs
La spec MCP associe chaque primitive à un « sujet de contrôle » :
| Primitive | Qui contrôle | Nature | Méthode JSON-RPC |
|---|---|---|---|
| Tool | Model (le LLM décide d’appeler) | Action, peut avoir des side effects | tools/list, tools/call |
| Resource | Application (l’hôte/client choisit le contexte) | Données en lecture seule, identifiées par URI | resources/list, resources/read, resources/templates/list |
| Prompt | Utilisateur (l’utilisateur choisit activement) | Template de conversation avec paramètres | prompts/list, prompts/get |
Comprenez simplement comme suit :
- Un tool est comme une fonction que le LLM est autorisé à appeler, par exemple annuler une commande ou envoyer un email.
- Une ressource est comme un fichier que l’application joint au contexte, par exemple les détails de la commande
A100. - Un prompt est comme une slash command que l’utilisateur active, par exemple
/complaint_reply.
Le reste de cet article clarifiera chaque type avec du code.
3. Préparation du projet
Vous avez besoin de Spring Boot 3.x et Spring AI 1.1 ou ultérieur. À partir de la version 1.1, Spring AI prend en charge les annotations @McpTool, @McpResource et @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>
Voici la configuration minimale pour le serveur :
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
Par défaut, Spring AI scanne automatiquement les beans avec les annotations MCP et les enregistre auprès du serveur. Vous n’avez rien d’autre à déclarer.
Le domaine métier partagé pour tout cet article comprend un record et un repository en mémoire :
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 : actions décidées par le modèle
Un tool est quelque chose que le LLM choisit d’appeler pendant son raisonnement. Le client envoie au modèle la liste des tools avec leurs descriptions. Le modèle lit les descriptions et décide s’il doit appeler, et avec quels paramètres. C’est pourquoi les tools conviennent aux actions, en particulier celles qui changent l’état.
@Component
public class OrderTools {
private final OrderRepository repository;
public OrderTools(OrderRepository repository) {
this.repository = repository;
}
@McpTool(name = "cancel_order",
description = "Annuler une commande non livrée. Retourne le résultat de l'annulation en texte.")
public String cancelOrder(
@McpToolParam(description = "Identifiant de la commande, par exemple A100", required = true) String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Commande non trouvée : " + orderId));
if (order.status() == OrderStatus.SHIPPED) {
return "La commande " + orderId + " a été livrée, impossible d'annuler.";
}
repository.save(order.withStatus(OrderStatus.CANCELLED));
return "Commande " + orderId + " annulée.";
}
}
Quelques points à noter dans le code ci-dessus :
@McpToolenregistre la méthode comme un tool nommécancel_order. Le modèle s’appuiera sur ladescriptionpour décider quand appeler ce tool, vous devez donc écrire une description précise.@McpToolParamgénère un JSON Schema pour le paramètre. Le modèle sait queorderIdest une chaîne obligatoire et peut voir un exemple de valeur.- Les exceptions levées sont renvoyées au client sous forme de résultat d’erreur (
isError: true). Le modèle peut lire cette erreur et retenter avec des paramètres corrigés.
Quand le modèle décide d’appeler le tool, le client envoie la requête suivante :
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}
Le serveur retourne :
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Commande A100 annulée."}],"isError":false}}
Note : Les tools avec side effects doivent s’accompagner d’une étape de confirmation côté client. La plupart des clients ont ce mécanisme. Néanmoins, le serveur doit toujours vérifier les permissions et l’état. Ne supposez pas que le modèle appelle toujours correctement.
5. Resource : données sélectionnées par l’application
Une ressource est des données en lecture seule, chaque ressource est identifiée par un URI. Le modèle n’appelle pas une ressource comme il appelle un tool. À la place, l’application hôte décide quelles ressources sont mises en contexte. L’hôte peut laisser l’utilisateur choisir dans une liste, les joindre automatiquement selon ses propres règles, ou s’abonner à des mises à jour.
@Component
public class OrderResources {
private final OrderRepository repository;
public OrderResources(OrderRepository repository) {
this.repository = repository;
}
@McpResource(uri = "order://{orderId}",
name = "order-detail",
description = "Détails d'une commande au format JSON",
mimeType = "application/json")
public String orderDetail(String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Commande non trouvée : " + orderId));
return """
{"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
.formatted(order.id(), order.customerName(), order.total(), order.status());
}
}
Voici comment fonctionne le code ci-dessus :
uri = "order://{orderId}"est un resource template. Le client verra ce template viaresources/templates/list.- La variable
{orderId}dans l’URI est liée au paramètre du même nom dans la méthode. Spring Boot active le flag-parametersà la compilation, donc la liaison par nom fonctionne sans configuration supplémentaire. mimeTypeindique au client que le contenu renvoyé est du JSON, pour que le client l’affiche ou le traite correctement.
Voici la requête de lecture d’une ressource et sa réponse :
--> {"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\"}"}]}}
Vous vous demandez peut-être : pourquoi ne pas simplement utiliser un tool get_order ? Un tool get_order reste valide. Mais si les données sont celles que l’application veut précharger, ou que l’utilisateur veut « épingler » à la conversation, alors une ressource est plus appropriée pour trois raisons :
- Une ressource ne coûte pas un cycle de raisonnement du modèle juste pour décider de l’appeler.
- Une ressource a un URI stable, donc l’hôte peut la mettre en cache et l’afficher dans l’interface utilisateur.
- Une ressource supporte
subscribepour recevoir des notifications quand les données changent.
Note : Tous les clients ne prennent pas complètement en charge les ressources. Certains clients utilisent uniquement les tools. Si ces données doivent atteindre le modèle dans tous les clients, envisagez de fournir aussi un tool de lecture correspondant.
6. Prompt : templates sélectionnés par l’utilisateur
Un prompt est un template de conversation avec paramètres, activé manuellement par l’utilisateur. Dans les clients avec interface, les prompts s’affichent généralement sous forme de slash command ou d’une entrée dans un menu. Le serveur retourne une liste de messages préremplis. Le client insère cette liste dans la conversation puis l’envoie au modèle.
@Component
public class OrderPrompts {
@McpPrompt(name = "complaint_reply",
description = "Rédiger une réponse à une réclamation client sur une commande")
public GetPromptResult complaintReply(
@McpArg(name = "orderId", description = "Identifiant de la commande réclamée", required = true) String orderId,
@McpArg(name = "tone", description = "Ton : courtois | amical") String tone) {
String style = (tone == null || tone.isBlank()) ? "courtois" : tone;
String text = """
Lisez la ressource order://%s, puis rédigez une réponse à la réclamation du client.
Ton : %s. Si la commande n'a pas été livrée, proposez l'annulation via le tool cancel_order.
""".formatted(orderId, style);
return new GetPromptResult("Répondre à la réclamation pour commande " + orderId,
List.of(new PromptMessage(Role.USER, new TextContent(text))));
}
}
Explication du code :
@McpPromptenregistre le promptcomplaint_reply.@McpArgdéclare les paramètres que le client demandera à l’utilisateur avant d’appelerprompts/get.- Les classes
GetPromptResult,PromptMessage,RoleetTextContentse trouvent dansio.modelcontextprotocol.spec.McpSchema. - Le contenu du prompt fait référence à la fois à la ressource et au tool. C’est ainsi que les trois primitives fonctionnent ensemble : l’utilisateur sélectionne le prompt, l’application charge la ressource, le modèle décide d’appeler le tool.
Voici le résultat retourné lors de l’appel de prompts/get :
--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"Répondre à la réclamation pour commande A100",
"messages":[{"role":"user","content":{"type":"text",
"text":"Lisez la ressource order://A100, puis rédigez une réponse ... Ton : courtois. ..."}}]}}
Note : Le modèle n’appelle pas le prompt de lui-même. Si vous voulez que le LLM active automatiquement un processus, vous avez besoin d’un tool, pas d’un prompt.
7. Test du comportement principal
Les méthodes avec des annotations MCP restent du Java ordinaire, vous pouvez donc faire des tests unitaires directs sans avoir besoin de démarrer le transport 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("Commande A100 annulée.");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
}
@Test
void cancelOrder_refuses_whenOrderIsShipped() {
assertThat(tools.cancelOrder("A101")).contains("impossible d'annuler");
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("Ton : courtois");
}
}
Ces quatre tests vérifient le « contrat » de chaque primitive :
- Un tool change l’état quand c’est valide et refuse quand ce ne l’est pas.
- Une ressource n’a pas de side effects : après sa lecture, l’état de la commande reste inchangé.
- Un prompt remplit correctement les paramètres et applique des valeurs par défaut quand l’utilisateur ne fournit pas
tone.
Si vous voulez un test end-to-end, vous pouvez utiliser McpClient du MCP Java SDK pour appeler tools/list et resources/read via le transport réel.
8. Quelle primitive choisir ?
Vous pouvez vous poser ces trois questions :
- Cela change-t-il l’état, ou le modèle a-t-il besoin de décider lui-même quand l’appeler ? Si oui, utilisez un tool.
- Est-ce une donnée de référence, identifiable par URI et lue plusieurs fois ? Si oui, utilisez une ressource.
- Est-ce un processus que l’utilisateur veut activer à plusieurs reprises avec quelques paramètres ? Si oui, utilisez un prompt.
Les erreurs courantes :
- Transformer tout en tools. Plus la liste de tools est longue, plus le modèle risque de se tromper et plus cela coûte de tokens pour les descriptions.
- Une ressource avec side effects, par exemple lire
order://A100marque la commande comme « vue ». Le client peut lire une ressource à tout moment, y compris pour afficher un aperçu, donc la ressource doit être sûre à la relecture. - Une ressource retourne trop de données. Tout le contenu ira dans la fenêtre de contexte du modèle. Divisez selon le template URI ou paginé.
- Description vague, par exemple juste « traiter la commande ». Avec un tool, la description est la seule chose sur laquelle le modèle s’appuie pour décider d’appeler ou non.
- Passer la logique métier dans le prompt. Un prompt n’est que du texte envoyé au modèle. Les règles obligatoires, comme ne pas pouvoir annuler une commande livrée, doivent être dans le code du tool.
9. Conclusion
Tool, ressource et prompt diffèrent principalement par qui les contrôle. Le modèle appelle un tool. L’application sélectionne une ressource. L’utilisateur active un prompt. En choisissant la bonne primitive, votre serveur est plus simple, le modèle se trompe moins, et l’expérience dans le client est plus naturelle.
Avec Spring AI 1.1, les trois types ne nécessitent qu’une seule annotation sur la méthode du bean : @McpTool, @McpResource et @McpPrompt. Puisque ce sont toujours des méthodes Java ordinaires, vous pouvez tester facilement le comportement essentiel : un tool doit vérifier l’état, une ressource doit être en lecture seule, et un prompt doit remplir les paramètres correctement.