Schreib deinen ersten MCP server mit Java und Spring Boot
1. Übersicht
Model Context Protocol (MCP) ist ein Kommunikationsstandard, der AI-Assistenten (Claude, Copilot, u.a.) ermöglicht, Tools aufzurufen und Ressourcen aus deinem System strukturiert zu lesen. Diese Anleitung zeigt dir, wie du einen MCP server in Java 21 schreibst, ein Tool zum Nachschlagen von Bestellungen (lookup_order) exponierst und es mit einem MCP client über stdio transport testest. Am Ende wirst du einen funktionierenden MCP server gebaut haben, Tests für Tool-Handler schreiben können und kennst häufige Anfängerfehler.
2. MCP ist was und wann brauchst du einen server
MCP definiert drei Hauptkonzepte, die ein Server exponieren kann:
- Tool: eine Funktion, die ein AI-Modell aufrufen kann, mit einem klaren Input-Schema (ähnlich wie Function Calling).
- Resource: statische oder dynamische Daten, die ein Client auslesen kann (Dateien, Config, Datenbankeinträge …).
- Prompt: vordefinierte Prompt-Templates, die der Client wiederverwenden kann.
Du brauchst einen MCP server, wenn ein AI-Assistant direkt mit deinen internen Systemen arbeiten soll – z. B. Bestellungen nachschlagen, Tickets erstellen, Logs abfragen – ohne Daten manuell zu kopieren und einzufügen.
Hinweis: Wenn du nur brauchst, dass ein Modell auf Basis von statischen Texten antwortet, ist RAG oder ein normaler Prompt deutlich einfacher als einen ganzen MCP server zu bauen.
3. Projekt vorbereiten
3.1. Maven-Abhängigkeiten
Das MCP Java SDK stellt bereits Klassen zum Bau eines Servers und Transport bereit. Füge diese Abhängigkeit zu pom.xml hinzu:
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.8.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
Jackson wird zum Serialisieren/Deserialisieren von JSON-RPC-Messages verwendet, die MCP intern nutzt.
3.2. Einfaches Domain-Modell
Bevor du ein Tool schreibst, brauchst du Daten, die das Tool zurückgeben kann. Verwende ein simuliertes Order-Objekt:
public record Order(String id, String status, double total) {
}
@Service
public class OrderService {
private final Map<String, Order> orders = Map.of(
"ORD-001", new Order("ORD-001", "SHIPPED", 450_000),
"ORD-002", new Order("ORD-002", "PENDING", 120_000)
);
public Optional<Order> findById(String orderId) {
return Optional.ofNullable(orders.get(orderId));
}
}
OrderService ist nur eine In-Memory-Map zu Demonstrationszwecken – in einem echten Projekt würdest du ein Repository oder eine API aufrufen.
4. Definiere das erste Tool: lookup_order
Jedes Tool in MCP braucht einen Namen, eine Beschreibung (das Modell nutzt diese, um zu entscheiden, ob es das Tool aufrufen soll), und ein Input-Schema in Form von JSON Schema.
public final class OrderTools {
public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
"lookup_order",
"Nachschlagen des Status und der Gesamtsumme einer Bestellung nach ihrer Ordernummer",
"""
{
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "Bestellnummer, z. B. ORD-001"
}
},
"required": ["orderId"]
}
"""
);
}
"lookup_order"ist der Name des Tools; der Client verwendet genau diese Zeichenkette zum Aufrufen.- Die zweite Beschreibung ist sehr wichtig: das Modell nutzt sie, um zu entscheiden, wann es dieses Tool aufrufen sollte statt eines anderen.
- Das Schema erzwingt, dass
orderIdein erforderliches Feld vom Typstringist.
Schreib dann einen Handler, der das Tool ausführt – akzeptiert Parameter, ruft OrderService auf und gibt CallToolResult zurück:
@Component
public class OrderToolHandler {
private final OrderService orderService;
public OrderToolHandler(OrderService orderService) {
this.orderService = orderService;
}
public McpSchema.CallToolResult handle(Map<String, Object> arguments) {
String orderId = (String) arguments.get("orderId");
return orderService.findById(orderId)
.map(order -> success(order))
.orElseGet(() -> error("Bestellung nicht gefunden: " + orderId));
}
private McpSchema.CallToolResult success(Order order) {
String text = "Bestellung %s: Status %s, Gesamtbetrag %.0f €"
.formatted(order.id(), order.status(), order.total());
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(text)), false);
}
private McpSchema.CallToolResult error(String message) {
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(message)), true);
}
}
Der zweite Parameter von CallToolResult ist isError. Wenn true, weiß der Client, dass das Tool fehlgeschlagen ist, und kann das dem Modell mitteilen, um einen anderen Weg zu versuchen, statt das Ergebnis als gültig anzusehen.
5. MCP server mit stdio transport initialisieren
Der gebräuchlichste Transport, wenn du MCP server schreibst, ist stdio: der Server liest Anfragen aus stdin und schreibt Antworten auf stdout. Claude Desktop startet auch lokale Server auf diese Weise.
@Component
public class McpOrderServer implements CommandLineRunner {
private final OrderToolHandler toolHandler;
public McpOrderServer(OrderToolHandler toolHandler) {
this.toolHandler = toolHandler;
}
@Override
public void run(String... args) {
McpServerTransportProvider transportProvider =
new StdioServerTransportProvider(new ObjectMapper());
McpServerFeatures.SyncToolSpecification lookupOrderSpec =
new McpServerFeatures.SyncToolSpecification(
OrderTools.LOOKUP_ORDER,
(exchange, arguments) -> toolHandler.handle(arguments)
);
McpSyncServer server = McpServer.sync(transportProvider)
.serverInfo("nonla-order-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder()
.tools(true)
.build())
.tools(lookupOrderSpec)
.build();
server.start();
}
}
StdioServerTransportProviderverwandeltstdin/stdoutdes Prozesses in einen JSON-RPC-Kommunikationskanal.serverInfo(...)deklariert Name und Version – der Client zeigt diese Informationen an, wenn er verbundene Server auflistet.capabilities().tools(true)teilt dem Client mit, dass dieser Server Tools exponiert; der Client sendettools/list, um eine Liste zu erhalten.server.start()blockiert den Prozess und wartet auf Anfragen vom Client über stdin.
Hinweis: Da stdio transport den Prozess blockiert, solltest du in einer normalen Spring Boot Web App (mit
@SpringBootApplicationund Web-Server) den Web-Server deaktivieren (spring.main.web-application-type=noneinapplication.properties), um Port-Konflikte und gemischte Logs zu vermeiden.
spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=
Banner und Log-Ausgaben müssen komplett deaktiviert werden – MCP nutzt stdout als Datenkanal, jede Log-Zeile würde die JSON-RPC-Messages zerstören.
6. Weitere Resource: statische Daten exponieren
Neben Tools kannst du Ressourcen exponieren, die der Client direkt lesen kann (ohne über das Modell zu gehen):
McpSchema.Resource policyResource = new McpSchema.Resource(
"file:///policies/return-policy.md",
"Umtauschrichtlinie",
"Inhalt der Umtauschrichtlinie",
"text/markdown",
null
);
McpServerFeatures.SyncResourceSpecification policySpec =
new McpServerFeatures.SyncResourceSpecification(
policyResource,
(exchange, request) -> new McpSchema.ReadResourceResult(
List.of(new McpSchema.TextResourceContents(
policyResource.uri(),
"text/markdown",
"# Umtauschrichtlinie\nKunden können Artikel innerhalb von 7 Tagen umtauschen."
))
)
);
Füge .resources(policySpec) zum Builder in Schritt 5 hinzu und der Server exponiert diese Resource bereits.
7. Mit Claude Desktop testen
Erstelle ein JAR und konfiguriere Claude Desktop (oder einen anderen MCP client), um auf deinen Server zu zeigen:
{
"mcpServers": {
"nonla-order-server": {
"command": "java",
"args": ["-jar", "/pfad/zu/order-mcp-server.jar"]
}
}
}
Starten den Client neu, öffne ein neues Gespräch und frag: “In welchem Status ist Bestellung ORD-001?”. Das Modell entscheidet selbst, lookup_order mit orderId = "ORD-001" aufzurufen, der Server gibt Text zurück und das Modell nutzt dieses Ergebnis, um dir zu antworten.
8. Tests für Tool-Handler schreiben
Die wichtigste Logik – Parametrierung und Rückgabe von CallToolResult – ist vollständig testbar wie normaler Java-Code, ohne echten Transport.
class OrderToolHandlerTest {
private final OrderService orderService = new OrderService();
private final OrderToolHandler handler = new OrderToolHandler(orderService);
@Test
void shouldReturnOrderInfo_whenOrderExists() {
var result = handler.handle(Map.of("orderId", "ORD-001"));
assertThat(result.isError()).isFalse();
assertThat(result.content())
.hasSize(1)
.first()
.isInstanceOfSatisfying(McpSchema.TextContent.class,
content -> assertThat(content.text()).contains("SHIPPED"));
}
@Test
void shouldReturnError_whenOrderNotFound() {
var result = handler.handle(Map.of("orderId", "ORD-999"));
assertThat(result.isError()).isTrue();
}
}
Der erste Test prüft eine gültige Bestellung: isError() muss false sein und der Textinhalt muss den Status SHIPPED enthalten. Der zweite Test stellt sicher, dass isError() true zurückgibt, wenn die Bestellung nicht gefunden wird – das ist ein wichtiges Signal für Client/Modell, um anders vorzugehen.
✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound
9. Häufige Fehler
- Log-Output vermischt mit stdout: Bei Verwendung von stdio transport und vergessener Deaktivierung des Banners/der Log-Ausgabe erhält der Client beschädigte JSON-RPC-Messages, Parse-Fehler sind schwer zu debuggen. Überprüfe immer, dass
stdoutnur MCP-Messages enthält, indem du die Ausgabe beim manuellen Testen in eine Datei umleitest. - Tool-Beschreibung zu kurz: Wenn die Beschreibung nur “Bestellung nachschlagen” sagt, weiß das Modell oft nicht, wann es das Tool aufrufen sollte. Schreib eine Beschreibung mit klarer Ein- und Ausgabe.
- Input-Schema fehlt
required: Das Modell kann das Tool ohne Pflichtfelder aufrufen, der Handler bekommtNullPointerExceptionbeim Casting. Validiere Input-Parameter immer im Handler, vertraue nicht blindlings auf das Schema. - Async server (
McpServer.async) ohne richtiges Awaiting: Race Conditions sind leicht möglich, wenn mehrere Tool-Anfragen kurz hintereinander kommen. Wenn du nicht mit Reactive vertraut bist, fang mitMcpServer.syncan wie in dieser Anleitung.
Wann du keinen eigenen MCP server schreiben solltest: Wenn dein System bereits eine stabile REST API hat und nur ein Client direkt aufrufen muss, ist eine zusätzliche MCP-Schicht redundant. MCP lohnt sich nur, wenn mehrere verschiedene AI-Clients (Claude Desktop, IDE-Plugins, selbstgeschriebene Agenten) dieselben Tools nutzen sollen, ohne dass jeder einzeln integriert werden muss.
10. Fazit
Du hast gerade einen vollständigen MCP server in Java gebaut: Tools mit klarem Schema deklariert, Handler für die Logik geschrieben, zusätzliche Ressourcen exponiert und Handler unabhängig vom Transport getestet. Der nächste natürliche Schritt ist, den SSE transport zu versuchen, um den Server über HTTP statt stdio zu laufen, oder Prompt-Templates für häufig wiederkehrende Aufgaben in deiner Domain hinzuzufügen.