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. Diese Tools bietet ein MCP-Server an; die Anwendung, in der das Modell läuft, bindet ihn als MCP-Client an. 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 unterstützt Spring Boot 4.0.x und 4.1.x. Für dieses Beispiel verwenden wir Spring Boot 4.1 (Spring Framework 7). Als Transport – so nennt MCP den Weg, auf dem Client und Server ihre Nachrichten austauschen – nutzen wir Streamable-HTTP: ein einzelner HTTP-Endpunkt, der Antworten bei Bedarf als Stream zurückschickt. Der ältere SSE-Transport (Server-Sent Events) ist in 2.0 abgekündigt. Und weil unsere App reaktiv sein soll, nehmen wir 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 einer Spring-Bean; 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);
}
}
Drei Dinge sind hier wichtig – und alle drei 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 – die Reactor-Typen für ein bzw. beliebig viele
asynchron gelieferte Ergebnisse. Eine Methode mit synchronem Rückgabetyp wird stillschweigend
verworfen – es bleibt nur eine Warnung im Log, und das Tool taucht in tools/list (der
MCP-Abfrage, mit der ein Client die verfügbaren Tools erfragt) schlicht nicht auf.
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. Wie derselbe Server
ohne WebFlux aussieht – und dass dort die Regel spiegelverkehrt gilt –, zeigt der Abschnitt zur
nicht-reaktiven Alternative weiter unten.
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 der öffentliche 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 –, der Ansatz lohnt sich aber von Anfang an.
Thema #3: Parameternamen brauchen -parameters
Leicht zu übersehen sind 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: MCP und die übrigen Pfade explizit absichern
Wer Spring Security im Projekt hat, muss /mcp in die Zugriffsregeln aufnehmen. Eine eigene
SecurityWebFilterChain ist dafür eine Möglichkeit. Spring Security verwendet die erste passende
Chain; passt keine, läuft die Anfrage ohne deren Sicherheitsfilter weiter — auch in 7.1.
Deshalb braucht es eine Auffang-Chain: eine Chain ohne eigenen securityMatcher, die als letzte
greift und alle übrigen Pfade abdeckt.
Für das lokale Beispiel lassen wir MCP offen und sperren alle anderen Pfade ausdrücklich:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
@Configuration
class McpSecurityConfig {
@Bean
@Order(1)
SecurityWebFilterChain mcpSecurity(ServerHttpSecurity http) {
return http
.securityMatcher(ServerWebExchangeMatchers.pathMatchers("/mcp", "/mcp/**"))
.csrf(ServerHttpSecurity.CsrfSpec::disable) // offener lokaler Demo-Endpunkt
.authorizeExchange(exchange -> exchange.anyExchange().permitAll())
.build();
}
@Bean
@Order(2)
SecurityWebFilterChain fallbackSecurity(ServerHttpSecurity http) {
return http
.authorizeExchange(exchange -> exchange.anyExchange().denyAll())
.build();
}
}
In einer bestehenden Anwendung übernimmt die Auffang-Chain die gewünschten Regeln für die übrigen Routen. Ihre Reihenfolge muss so gewählt sein, dass spezifische Chains zuerst geprüft werden; eine bereits vorhandene Auffang-Chain wird entsprechend angepasst. Die Spring-Security-Dokumentation zu mehreren Chains beschreibt diese Auswahl.
Soll MCP Authentifizierung verlangen, muss außerdem permitAll() durch authenticated() oder
eine passende Rollen- beziehungsweise Berechtigungsprüfung ersetzt werden. Nur httpBasic oder
oauth2ResourceServer hinzuzufügen lässt Anfragen ohne Zugangsdaten weiterhin zu.
Für JWT-Bearer-Tokens ersetzt man die Methode mcpSecurity beispielsweise durch:
@Bean
@Order(1)
SecurityWebFilterChain mcpSecurity(ServerHttpSecurity http) {
return http
.securityMatcher(ServerWebExchangeMatchers.pathMatchers("/mcp", "/mcp/**"))
.csrf(ServerHttpSecurity.CsrfSpec::disable) // ausschließlich Bearer-Token im Authorization-Header
.authorizeExchange(exchange -> exchange.anyExchange().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(
org.springframework.security.config.Customizer.withDefaults()))
.build();
}
Die Auffang-Chain bleibt bestehen. Diese Variante benötigt zusätzlich die
JWT-Resource-Server-Abhängigkeiten und einen konfigurierten Token-Aussteller oder ReactiveJwtDecoder.
Das Abschalten von CSRF setzt hier voraus, dass die Authentifizierung ausschließlich über explizit gesetzte Bearer-Token erfolgt; bei Cookie- oder Browser-Basic-Authentifizierung muss der CSRF-Schutz gesondert berücksichtigt werden.
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 Thema #1 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 des restlichen Stacks – nicht des MCP-Servers.
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 – fertig. Kennen sollte man die drei Knackpunkte –
den reaktiven Rückgabetyp (ASYNC verlangt Mono/Flux), schlichte Records als Tool-Antwort und
das -parameters-Flag für sprechende Parameternamen – und dazu explizite Security-Regeln für
/mcp samt Auffang-Chain.
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.