Zum Inhalt springen

Blog

Einen MCP-Server mit Spring AI bauen

22. Juli 2026 · Thilo-Alexander Ginkel · 6 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 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-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 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/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 – 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.

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

Wie können wir Sie unterstützen?