Zum Inhalt springen

Blog

Einen MCP-Server mit Spring AI bauen

22. Juli 2026 · Thilo-Alexander Ginkel · 7 Min. Lesezeit

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-webmvc statt -webflux,
  • type: SYNC statt ASYNC,
  • und die Tools geben einfache Werte zurück (List<ProductSummary>, ProductDetails) statt Flux/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/list anzeigt 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.

  • Spring AI
  • MCP
  • Spring Boot
  • Java
  • LLM

Wie können wir Sie unterstützen?