MCP tool, resource und prompt – wie unterscheiden sie sich
1. Übersicht
Wenn du einen MCP-Server schreibst, hast du drei Wege, um deine Fähigkeiten dem Client bereitzustellen: tool, resource und prompt. Alle drei werden auf der Server-Seite deklariert und erscheinen in der Liste, die der Client ausliest. Deshalb wählen viele wahllos oder machen aus allem ein tool.
Dieser Artikel unterscheidet diese drei Primitives nach einem klaren Kriterium: wer entscheidet, wann es verwendet wird. Du wirst alle drei mit Spring AI für die gleiche Geschäftsdomäne implementieren: Bestellungen. Du wirst auch Tests für jeden Typ schreiben. Am Ende gibt es eine Tabelle, die dir hilft, das richtige Primitive für jede Situation zu wählen.
2. Die drei Primitives und ihre Controller
Die MCP-Spezifikation verbindet jedes Primitive mit einem „Kontrollelement”:
| Primitive | Wer kontrolliert | Wesen | JSON-RPC-Methode |
|---|---|---|---|
| Tool | Model (LLM entscheidet selbst zu rufen) | Aktion, kann Nebeneffekte haben | tools/list, tools/call |
| Resource | Anwendung (Host/Client wählt den Kontext) | Nur-Lese-Daten, identifiziert durch URI | resources/list, resources/read, resources/templates/list |
| Prompt | Benutzer (Benutzer wählt aktiv) | Dialog-Template mit Parametern | prompts/list, prompts/get |
Vereinfacht gesagt:
- Tool ist wie eine Funktion, die das LLM aufrufen darf – zum Beispiel eine Bestellung stornieren oder eine E-Mail senden.
- Resource ist wie eine Datei, die die Anwendung in den Kontext einfügt – zum Beispiel die Bestelldetails
A100. - Prompt ist wie ein Slash-Command, den der Benutzer ausführt – zum Beispiel
/complaint_reply.
Der Rest des Artikels wird jeden Typ anhand von Code verdeutlichen.
3. Projekt vorbereiten
Du brauchst Spring Boot 3.x und Spring AI 1.1 oder höher. Ab Version 1.1 unterstützt Spring AI die Annotationen @McpTool, @McpResource und @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>
Als nächstes die Mindestkonfiguration für den 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
Standardmäßig scannt Spring AI automatisch Beans mit MCP-Annotationen und registriert sie beim Server. Du brauchst keine zusätzliche Deklaration.
Die gemeinsame Geschäftsdomäne für den ganzen Artikel besteht aus einem Record und einem 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: Aktion, die das Model entscheidet
Tool ist das, das das LLM selbst wählt zu rufen während es denkt. Der Client sendet die Toolliste mit Beschreibung an das Model. Das Model liest die Beschreibung und entscheidet, ob es aufgerufen wird und mit welchen Parametern. Deshalb eignen sich Tools für Aktionen, besonders für Aktionen, die den Status ändern.
@Component
public class OrderTools {
private final OrderRepository repository;
public OrderTools(OrderRepository repository) {
this.repository = repository;
}
@McpTool(name = "cancel_order",
description = "Storniere eine noch nicht versandte Bestellung. Gib das Ergebnis als Text zurück.")
public String cancelOrder(
@McpToolParam(description = "Bestellnummer, z.B. A100", required = true) String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Bestellung " + orderId + " nicht gefunden"));
if (order.status() == OrderStatus.SHIPPED) {
return "Bestellung " + orderId + " wurde bereits versandt, kann nicht storniert werden.";
}
repository.save(order.withStatus(OrderStatus.CANCELLED));
return "Bestellung " + orderId + " storniert";
}
}
Beachte diese Punkte im obigen Code:
@McpToolregistriert die Methode als Tool mit dem Namencancel_order. Das Model wird diedescriptionlesen, um zu entscheiden, wann es aufgerufen wird, also schreibe eine aussagekräftige Beschreibung.@McpToolParamgeneriert ein JSON-Schema für den Parameter. Das Model weiß, dassorderIdeine erforderliche Zeichenkette ist und sieht ein Beispielwert.- Exceptions werden an den Client als Fehlerergebnis zurückgegeben (
isError: true). Das Model kann diese Fehler lesen und die Parameter ggf. korrigieren und erneut versuchen.
Wenn das Model entscheidet, das Tool aufzurufen, sendet der Client diese Anfrage:
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}
Der Server antwortet:
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Bestellung A100 storniert"}],"isError":false}}
Hinweis: Tools mit Nebeneffekten erfordern üblicherweise einen Bestätigungsschritt auf der Host-Seite. Die meisten Clients haben diesen Mechanismus. Der Server muss aber trotzdem seine Berechtigungen und seinen Status selbst überprüfen. Nimm nicht an, dass das Model immer richtig aufruft.
5. Resource: Daten, die die Anwendung wählt
Resource ist Nur-Lese-Daten, jede Resource wird durch einen URI identifiziert. Das Model ruft keine Resource wie ein Tool auf. Stattdessen entscheidet die Host-Anwendung, welche Resources in den Kontext eingefügt werden. Der Host kann den Benutzer aus einer Liste auswählen lassen, sie automatisch nach eigenen Regeln anhängen oder sie abonnieren, um Updates zu erhalten.
@Component
public class OrderResources {
private final OrderRepository repository;
public OrderResources(OrderRepository repository) {
this.repository = repository;
}
@McpResource(uri = "order://{orderId}",
name = "order-detail",
description = "Bestelldetails im JSON-Format",
mimeType = "application/json")
public String orderDetail(String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Bestellung " + orderId + " nicht gefunden"));
return """
{"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
.formatted(order.id(), order.customerName(), order.total(), order.status());
}
}
Wie der Code oben funktioniert:
uri = "order://{orderId}"ist ein Resource-Template. Der Client sieht dieses Template überresources/templates/list.- Die Variable
{orderId}im URI wird an den Parameter mit demselben Namen gebunden. Spring Boot aktiviert das-parameters-Flag beim Kompilieren, daher funktioniert das Binding nach Name ohne zusätzliche Konfiguration. mimeTypeteilt dem Client mit, dass der Inhalt JSON ist, damit der Client ihn angemessen anzeigen oder verarbeiten kann.
Hier ist die Anfrage zum Lesen einer Resource und die Antwort:
--> {"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\"}"}]}}
Du könntest dich fragen: Warum nicht einfach ein Tool get_order verwenden? Ein Tool get_order wäre gültig. Aber wenn die Daten etwas sind, das die Anwendung vorher laden möchte oder das der Benutzer ins Gespräch anheften möchte, ist eine Resource besser aus drei Gründen:
- Eine Resource kostet keine Reasoning-Runde des Models nur um zu entscheiden, ob es aufgerufen wird.
- Eine Resource hat einen stabilen URI, daher kann der Host sie cachen und in der Benutzeroberfläche anzeigen.
- Eine Resource unterstützt
subscribe, um Benachrichtigungen bei Datenänderungen zu erhalten.
Hinweis: Nicht alle Clients unterstützen Resources vollständig. Einige Clients verwenden nur Tools. Wenn diese Daten unbedingt in jedem Client für das Model verfügbar sein müssen, ziehe in Betracht, ein entsprechendes lesendes Tool zusätzlich bereitzustellen.
6. Prompt: Template, das der Benutzer wählt
Ein Prompt ist ein Dialog-Template mit Parametern, das der Benutzer aktiv auslöst. In Clients mit einer Benutzeroberfläche erscheint ein Prompt normalerweise als Slash-Befehl oder als Menüeintrag. Der Server gibt eine Liste von vorausgefüllten Nachrichten zurück. Der Client fügt diese Liste ins Gespräch ein und sendet sie dann an das Model.
@Component
public class OrderPrompts {
@McpPrompt(name = "complaint_reply",
description = "Verfasse eine Antwort auf eine Beschwerde eines Kunden zu einer Bestellung")
public GetPromptResult complaintReply(
@McpArg(name = "orderId", description = "Bestellnummer der Beschwerde", required = true) String orderId,
@McpArg(name = "tone", description = "Ton: förmlich | freundlich") String tone) {
String style = (tone == null || tone.isBlank()) ? "förmlich" : tone;
String text = """
Lese die Resource order://%s, schreibe dann eine Antwort auf die Beschwerde des Kunden.
Ton: %s. Wenn die Bestellung noch nicht versandt wurde, schlag vor, sie mit dem Tool cancel_order zu stornieren.
""".formatted(orderId, style);
return new GetPromptResult("Antwort auf Beschwerde für Bestellung " + orderId,
List.of(new PromptMessage(Role.USER, new TextContent(text))));
}
}
Erklärung des Codes:
@McpPromptregistriert den Promptcomplaint_reply.@McpArgdeklariert die Parameter, die der Client den Benutzer fragen wird, bevorprompts/getaufgerufen wird.- Die Klassen
GetPromptResult,PromptMessage,RoleundTextContentbefinden sich inio.modelcontextprotocol.spec.McpSchema. - Der Prompt-Inhalt verweist auf sowohl Resource als auch Tool. So arbeiten die drei Primitives zusammen: Der Benutzer wählt den Prompt, die Anwendung lädt die Resource, das Model entscheidet, ob das Tool aufgerufen wird.
Das Ergebnis, wenn prompts/get aufgerufen wird:
--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"Antwort auf Beschwerde für Bestellung A100",
"messages":[{"role":"user","content":{"type":"text",
"text":"Lese die Resource order://A100, schreibe dann eine Antwort ... Ton: förmlich. ..."}}]}}
Hinweis: Das Model ruft den Prompt nicht selbst auf. Wenn du möchtest, dass das LLM selbst einen Prozess auslöst, brauchst du ein Tool, kein Prompt.
7. Die wichtigsten Verhaltensweisen testen
Methoden mit MCP-Annotationen sind immer noch reines Java, daher kannst du sie direkt mit Unit-Tests testen, ohne den MCP-Transport zu starten.
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("Bestellung A100 storniert");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
}
@Test
void cancelOrder_refuses_whenOrderIsShipped() {
assertThat(tools.cancelOrder("A101")).contains("kann nicht storniert werden");
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: förmlich");
}
}
Diese vier Tests überprüfen das „Vertrag” jedes Primitives:
- Tool ändert den Status, wenn es gültig ist, und lehnt ab, wenn es ungültig ist.
- Resource hat keine Nebeneffekte: Nach dem Lesen bleibt der Bestellstatus unverändert.
- Prompt füllt die Parameter richtig aus und wendet Standardwerte an, wenn der Benutzer
tonenicht übergibt.
Wenn du End-to-End-Tests möchtest, kannst du McpClient aus dem MCP Java SDK verwenden, um tools/list und resources/read über den echten Transport aufzurufen.
8. Welches Primitive solltest du wählen?
Du kannst dir diese drei Fragen stellen:
- Ändert es den Status oder muss das Model selbst entscheiden, wann es aufgerufen wird? Falls ja, verwende tool.
- Sind es Referenzdaten, die durch einen URI identifiziert und mehrfach gelesen werden können? Falls ja, verwende resource.
- Ist es ein Prozess, den der Benutzer wiederholt mit verschiedenen Parametern aktivieren möchte? Falls ja, verwende prompt.
Häufige Fehler:
- Alles in ein Tool verwandeln. Je länger die Toolliste ist, desto leichter macht das Model Fehler und desto mehr Token kostet die Beschreibung.
- Resource mit Nebeneffekten, z.B. das Lesen von
order://A100markiert die Bestellung als “angesehen”. Der Client darf eine Resource jederzeit lesen, auch nur für eine Vorschau, daher muss die Resource sicher bei wiederholtem Lesen sein. - Resource gibt zu große Daten zurück. Der gesamte Inhalt geht in das Context-Fenster des Models. Teile nach URI-Template auf oder paginiere.
- Vage Beschreibung, z.B. nur “Bestellung verarbeiten”. Für Tools ist die Beschreibung das einzige, worauf sich das Model verlässt, um zu entscheiden, ob es aufgerufen wird.
- Geschäftslogik ins Prompt stecken. Ein Prompt ist nur Text, der an das Model gesendet wird. Zwingend notwendige Regeln, wie das Verbot, versandte Bestellungen zu stornieren, müssen im Tool-Code liegen.
9. Fazit
Tool, Resource und Prompt unterscheiden sich hauptsächlich darin, wer sie kontrolliert. Das Model ruft das Tool auf. Die Anwendung wählt die Resource. Der Benutzer aktiviert den Prompt. Wenn du das richtige Primitive wählst, wird dein Server sauberer, das Model macht weniger Fehler, und die Benutzerfreundlichkeit des Clients verbessert sich.
Mit Spring AI 1.1 brauchst du nur eine Annotation auf der Bean-Methode für alle drei Typen: @McpTool, @McpResource und @McpPrompt. Da sie immer noch reine Java-Methoden sind, kannst du das Kernverhalten leicht unit testen: Tools müssen den Status überprüfen, Resources müssen schreibgeschützt sein, und Prompts müssen die Parameter korrekt ausfüllen.