Das Model Context Protocol (MCP) ist der De-facto-Standard geworden, um einem Large Language Model Zugriff auf eigene Daten und Funktionen zu geben. Statt ein LLM fest in die eigene Anwendung zu verdrahten, stellt man ihm Tools bereit – kleine, klar beschriebene Funktionen mit JSON-Schema –, und das Modell entscheidet selbst, welches es wann aufruft. Claude Desktop, IDEs, Agenten-Frameworks: Sie alle sprechen MCP.
Mit Spring AI 2.0 lässt sich ein solcher Server erstaunlich einfach bauen. Dieser Beitrag zeigt das an einem kleinen, reaktiven Beispiel: einem Produktkatalog mit zwei Tools – Suche und Detail. Am Ende steht ein HTTP-Endpunkt, den jeder MCP-fähige Client nutzen kann.
Voraussetzungen
Spring AI 2.0 setzt Spring Boot 4.1 (Spring Framework 7) voraus – die Versionen sind fest aneinander gekoppelt, das lohnt einen kurzen Blick, bevor man loslegt. Als Transport nutzen wir Streamable-HTTP (SSE ist in 2.0 abgekündigt) und, weil unsere App reaktiv sein soll, den WebFlux-Server.
Die Abhängigkeit:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webflux</artifactId>
</dependency>
</dependencies>
Konfiguration
Der Starter bringt eine Auto-Konfiguration mit; wir müssen nur den Transport und den Server-Typ festlegen:
spring:
ai:
mcp:
server:
protocol: STREAMABLE # Streamable-HTTP statt des abgekündigten SSE
type: ASYNC # reaktiv – dazu gleich mehr
name: product-catalog
version: 1.0.0
streamable-http:
mcp-endpoint: /mcp # hier lauscht der Server
Damit ist der Endpunkt /mcp scharf. Fehlen nur noch die Tools.
Ein Tool ist eine annotierte Methode
Ein Tool definiert man mit @McpTool an einer Methode eines Spring-Beans; die Parameter beschreibt
man mit @McpToolParam. Spring AI generiert daraus automatisch das JSON-Schema, das der Client zu
sehen bekommt.
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Component
class CatalogTools {
private final ProductRepository products; // ein reaktives Repository (R2DBC, WebClient, …)
CatalogTools(ProductRepository products) {
this.products = products;
}
@McpTool(
name = "search_products",
description = "Search the product catalog by free text. Returns matching products (id, name, price).",
annotations = @McpTool.McpAnnotations(readOnlyHint = true))
public Flux<ProductSummary> searchProducts(
@McpToolParam(description = "Free-text query, e.g. \"wireless keyboard\"", required = true) String query,
@McpToolParam(description = "Max number of results; optional (default 20)", required = false) Integer limit) {
int max = (limit == null || limit <= 0) ? 20 : limit;
return products.search(query).take(max).map(ProductSummary::of);
}
@McpTool(
name = "product_details",
description = "Full details for a single product id (description, stock, price).",
annotations = @McpTool.McpAnnotations(readOnlyHint = true))
public Mono<ProductDetails> productDetails(
@McpToolParam(description = "The product id, e.g. \"P-1234\"", required = true) String id) {
return products.findById(id).map(ProductDetails::of);
}
}
Zwei Dinge sind hier wichtig – und beide sind die typischen Knackpunkte.
Thema #1: reaktiv heißt reaktiv
Wir haben type: ASYNC konfiguriert. Der ASYNC-Server registriert ausschließlich
Tool-Methoden, die Mono oder Flux zurückgeben. Eine Methode mit synchronem Rückgabetyp wird
kommentarlos verworfen – es bleibt nur eine Warnung im Log, und das Tool taucht in tools/list
schlicht nicht auf. (Umgekehrt gilt dasselbe: Ein SYNC-Server ignoriert reaktive Methoden.)
Wer also in einer WebFlux-Anwendung ein Tool vermisst, prüft als Erstes den Rückgabetyp. Die Regel:
type: ASYNC + jedes Tool gibt Mono/Flux zurück. Das passt ohnehin zum reaktiven Stack – man
muss sich nur bewusst sein, dass der Rückgabetyp hier Teil des Kontrakts ist.
Alternative: nicht-reaktiv geht genauso
Nicht jede Anwendung ist reaktiv – und das muss sie auch nicht sein. Denselben MCP-Server gibt es für Spring Web MVC, das nicht-reaktive Gegenstück zu WebFlux. Man tauscht nur drei Dinge:
- die Abhängigkeit
spring-ai-starter-mcp-server-webmvcstatt-webflux, type: SYNCstattASYNC,- und die Tools geben einfache Werte zurück (
List<ProductSummary>,ProductDetails) stattFlux/Mono.
@McpTool(name = "product_details", description = "…",
annotations = @McpTool.McpAnnotations(readOnlyHint = true))
public ProductDetails productDetails(
@McpToolParam(description = "The product id, e.g. \"P-1234\"", required = true) String id) {
return products.findById(id); // synchron/blockierend – im SYNC-Server völlig in Ordnung
}
Die Regel aus dem vorigen Abschnitt gilt dann spiegelverkehrt: Der SYNC-Server registriert nur
nicht-reaktive Methoden und ignoriert Mono/Flux. Auch die Security-Konfiguration wechselt vom
reaktiven SecurityWebFilterChain zum servlet-basierten SecurityFilterChain. Ansonsten bleibt
alles identisch: @McpTool, @McpToolParam, das -parameters-Flag, der /mcp-Endpunkt.
Kurz: Ob reaktiv oder nicht, ist eine Frage deines restlichen Stacks – nicht des MCP-Servers.
Thema #2: der Rückgabewert wird serialisiert – halte ihn schlicht
Die Rückgabetypen (ProductSummary, ProductDetails) sind schlichte Java-Records ohne jegliche
Serialisierungs-Annotationen:
public record ProductSummary(String id, String name, BigDecimal price) {
static ProductSummary of(Product p) {
return new ProductSummary(p.id(), p.name(), p.price());
}
}
Spring AI serialisiert Tool-Ergebnisse selbst zu JSON. Je weniger Framework-spezifische Annotationen daran hängen, desto robuster ist das Ergebnis – und desto sauberer bleibt dein öffentlicher Tool-Vertrag von den internen Domain-Objekten getrennt. Faustregel: eigener Record pro Tool-Antwort, gemappt aus dem Domänenmodell.
Das of(...) oben ist von Hand geschrieben – für ein paar Felder völlig in Ordnung. Genauso leicht
geht es aber mit MapStruct, und das schon bei kleinen Mappings: Es generiert den Mapping-Code zur
Compile-Zeit, deklarativ und typsicher, statt ihn von Hand auszuschreiben und bei jeder Feldänderung
nachzupflegen. Der Nutzen wächst mit der Komplexität – viele Felder, verschachtelte Objekte,
Sammlungen –, lohnend ist der Ansatz aber von Anfang an.
Ein zweiter, leicht zu übersehender Punkt betrifft die Parameternamen. Spring AI zieht sie per
Reflection aus der Methodensignatur. Ohne das Compiler-Flag -parameters heißen die Parameter im
generierten Schema arg0, arg1, … – und das LLM kann sie nicht sinnvoll befüllen:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
Eine Zeile – aber der Unterschied zwischen einem nutzbaren und einem nutzlosen Tool.
Sicherheit: ein neuer Pfad braucht seine eigene Chain
Wer Spring Security im Projekt hat, muss /mcp explizit abdecken. Seit Spring Security 7.1
(Teil von Boot 4.1) wird eine Anfrage, die auf keine SecurityWebFilterChain passt, mit 403
abgewiesen. Für einen offenen (oder
später token-geschützten) Endpunkt genügt:
@Configuration
class McpSecurityConfig {
@Bean
SecurityWebFilterChain mcpSecurity(ServerHttpSecurity http) {
return http
.securityMatcher(ServerWebExchangeMatchers.pathMatchers("/mcp/**"))
.csrf(ServerHttpSecurity.CsrfSpec::disable) // API-Endpunkt, kein Browser-Formular
.authorizeExchange(exchange -> exchange.anyExchange().permitAll())
.build();
}
}
Braucht der Endpunkt Authentifizierung, hängt man hier den passenden oauth2ResourceServer- oder
httpBasic-Block an. Wichtig ist nur: Der Pfad muss auf irgendeine Chain passen.
Ausprobieren
Nach dem Start liegt der Server unter http://localhost:8080/mcp. Zwei einfache Wege, ihn zu
prüfen:
- MCP Inspector (
npx @modelcontextprotocol/inspector): ein kleines Web-UI, das sich mit dem Server verbindet,tools/listanzeigt und einzelne Tools interaktiv aufruft – ideal beim Entwickeln. - Ein echter Client wie Claude Desktop: Streamable-HTTP-Server trägt man in dessen MCP-Konfiguration mit der Endpunkt-URL ein. Danach kann das Modell die Tools direkt aufrufen.
In tools/list sollten search_products und product_details mit ihren beschriebenen Parametern
erscheinen.
Fazit
Für einen funktionierenden MCP-Server braucht es nicht viel: eine Abhängigkeit, ein paar Zeilen Konfiguration, @McpTool an einer
Methode – und man hat einen funktionierenden MCP-Server. Die drei Dinge, die man kennen sollte,
sind der reaktive Rückgabetyp (ASYNC verlangt Mono/Flux), das -parameters-Flag für
sprechende Parameternamen und die eigene Security-Chain für /mcp.
Der interessantere Teil beginnt danach: gute Tool-Beschreibungen, schlanke Antwort-Schemata, sinnvolle Defaults. Denn am anderen Ende sitzt kein Mensch, der eine unklare Ausgabe schon richtig deutet, sondern ein Modell, das jedes Feld wörtlich nimmt.