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. 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 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 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);
    }
}

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 stillschweigend 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-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 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 des 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 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.

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: 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 für die übrigen Pfade.

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.

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. Die drei Dinge, die man kennen sollte, sind der reaktive Rückgabetyp (ASYNC verlangt Mono/Flux), das -parameters-Flag für sprechende Parameternamen und 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?