Spring Boot meets AI: So baust du deinen eigenen MCP Server mit Java

Julian | Jan 23, 2026 min read

Wenn wir aktuell über das Model Context Protocol (MCP) sprechen, dominiert meist TypeScript oder Python die Timeline. Doch wie sieht es bei uns Java Entwicklern aus?

In diesem Artikel bauen wir einen “Local DevOps Agent”. Er wird in der Lage sein, unsere lokale Docker-Umgebung zu analysieren und zu steuern. Und weil wir keine halben Sachen machen, setzen wir auf Spring Boot 4.

Warum Spring Boot 4? Weil die Startzeiten und der Memory-Footprint weiter optimiert wurden – essenziell für CLI-Tools, die von einem LLM “on-demand” genutzt werden. Zudem nutzen wir Features moderner Java-Versionen (Records, Switch Expressions), um den Code extrem kompakt zu halten.

Die Architektur & Die Falle

Ein MCP Server kommuniziert (meistens) über Stdio (Standard Input/Output) mit dem Host (z.B. der Claude Desktop App).

Das stellt uns Java-Entwickler vor ein Problem: Spring Boot liebt Logs. Wenn Spring Boot sein berühmtes ASCII-Banner oder eine “Started Application”-Info auf System.out (stdout) ausgibt, zerschießt es uns sofort das MCP-Protokoll, da der Client dort valides JSON erwartet.

Die Architektur-Regel #1:

  • System.in / System.out: Exklusiv für das JSON-RPC Protokoll.
  • System.err: Für Logs, Debugging und Spring-Start-Meldungen.

1. Setup & “Silent” Logging

Wir starten ein neues Projekt. Da wir Spring Boot 4 nutzen, ziehen wir die aktuellsten Dependencies.

Die pom.xml (Auszug)

Wir benötigen jackson für das JSON-Handling und docker-java für die Kommunikation mit dem Daemon.

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.21.0</version>
    </dependency>

    <dependency>
        <groupId>com.github.docker-java</groupId>
        <artifactId>docker-java-core</artifactId>
        <version>3.7.0</version>
    </dependency>
    <dependency>
        <groupId>com.github.docker-java</groupId>
        <artifactId>docker-java-transport-httpclient5</artifactId>
        <version>3.7.0</version>
    </dependency>
</dependencies>

Die kritische Konfiguration

Wir müssen den Webserver deaktivieren und das Logging umbiegen.

src/main/resources/application.properties:

spring.main.web-application-type=none
spring.main.banner-mode=off

src/main/resources/logback-spring.xml: Das ist die wichtigste Datei im Projekt. Wir zwingen alle Logs auf System.err.

<configuration>
    <appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
        <target>System.err</target>
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDERR" />
    </root>
</configuration>

2. Der Docker Service: Typsicherheit trifft Containment

Bevor wir über MCP sprechen, brauchen wir eine saubere Abstraktion für Docker. Wir nutzen Java Records für unsere DTOs.

package dev.julianpaul.mcp.model;

public record ContainerSummary(String id, String image, String status, String names) {}

Der Service nutzt den DockerClient, um mit dem lokalen Socket zu sprechen:

@Service
public class DockerService {

    private final DockerClient dockerClient;

    public DockerService() {
        // Standard Config: Nutzt /var/run/docker.sock
        this.dockerClient = DockerClientBuilder.getInstance().build();
    }

    public List<ContainerSummary> listRunningContainers() {
        return dockerClient.listContainersCmd()
                .withStatusFilter(List.of("running"))
                .exec()
                .stream()
                .map(c -> new ContainerSummary(
                        c.getId(),
                        c.getImage(),
                        c.getStatus(),
                        String.join(", ", c.getNames())
                ))
                .toList();
    }

    public String getContainerLogs(String containerId) {
        // Logik stark vereinfacht für den Blog-Kontext
        // In Produktion: Callback nutzen um Stream zu lesen
        return "Logs for " + containerId + " retrieved successfully."; 
    }
}

3. The Heartbeat: Der MCP Protocol Loop

Jetzt wird es spannend. Wir implementieren einen CommandLineRunner, der auf System.in lauscht.

Zuerst ein Record für das JSON-RPC Format:

record JsonRpcRequest(String jsonrpc, String id, String method, JsonNode params) {}

Und hier der Runner, der die Requests routet:

@Component
public class McpAgentRunner implements CommandLineRunner {

    private final ObjectMapper objectMapper;
    private final DockerService dockerService;

    public McpAgentRunner(ObjectMapper objectMapper, DockerService dockerService) {
        this.objectMapper = objectMapper;
        this.dockerService = dockerService;
    }

    @Override
    public void run(String... args) throws Exception {
        Scanner scanner = new Scanner(System.in);

        // Loop: Solange der Host (Claude) uns Daten schickt
        while (scanner.hasNextLine()) {
            String line = scanner.nextLine();
            try {
                handleRequest(line);
            } catch (Exception e) {
                // Fehler MÜSSEN nach Stderr
                System.err.println("Error processing request: " + e.getMessage());
            }
        }
    }

    private void handleRequest(String jsonLine) throws JsonProcessingException {
        JsonRpcRequest request = objectMapper.readValue(jsonLine, JsonRpcRequest.class);

        // Modernes Switch Pattern Matching
        Object result = switch (request.method()) {
            case "initialize" -> handleInitialize(); 
            case "tools/list" -> handleListTools();  
            case "tools/call" -> handleToolCall(request.params()); 
            default -> null; 
        };

        if (result != null) {
            sendResponse(request.id(), result);
        }
    }
    
    // ...
}

Tools definieren (tools/list)

Damit das LLM weiß, was es tun kann, müssen wir uns vorstellen. Hier definieren wir das Schema für unsere Docker-Tools.

private Object handleListTools() {
    return Map.of(
        "tools", List.of(
            Map.of(
                "name", "list_containers",
                "description", "Listet alle aktuell laufenden Docker Container auf.",
                "inputSchema", Map.of(
                    "type", "object", "properties", Map.of()
                )
            ),
            Map.of(
                "name", "get_logs",
                "description", "Liest die Logs eines spezifischen Containers.",
                "inputSchema", Map.of(
                    "type", "object",
                    "properties", Map.of(
                        "containerId", Map.of("type", "string", "description", "Container ID")
                    ),
                    "required", List.of("containerId")
                )
            )
        )
    );
}

Don’t forget to flush!

Ein Fehler, den ich immer wieder sehe: Der Server berechnet das Ergebnis, schreibt es in den Output… und nichts passiert.

Warum? Weil System.out gepuffert ist. Wir müssen flushen.

private void sendResponse(String id, Object result) {
    try {
        Map<String, Object> response = new HashMap<>();
        response.put("jsonrpc", "2.0");
        response.put("id", id);
        response.put("result", result);

        String jsonOutput = objectMapper.writeValueAsString(response);
        
        System.out.print(jsonOutput); 
        System.out.println(); // Expliziter Newline Separator
        System.out.flush();   // LEBENSWICHTIG!
        
    } catch (Exception e) {
        System.err.println("Failed to send response: " + e.getMessage());
    }
}

4. Integration & Debugging

Wir bauen das Fat JAR mit mvn clean package. Nehmen wir an, es liegt unter /path/to/mcp-docker-agent.jar.

In der claude_desktop_config.json registrieren wir den Server:

{
  "mcpServers": {
    "java-docker-agent": {
      "command": "java",
      "args": [
        "-jar",
        "/path/to/mcp-docker-agent.jar"
      ]
    }
  }
}

Wie debugge ich das?

Das Problem: Wenn Claude den Java-Prozess startet, siehst du keine Konsole. Wie setzen wir Breakpoints?

Lösung: Remote Debugging (JDWP). Ändere die Config in Claude temporär so ab:

"args": [
  "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005",
  "-jar",
  "..."
]

Jetzt kannst du in IntelliJ eine “Remote JVM Debug” Configuration starten, die sich auf Port 5005 verbindet. Sobald du Claude eine Frage stellst, klinkt sich dein Debugger ein. Das ist Developer-Experience auf Profi-Niveau.

Fazit

Wir haben gezeigt, dass man für moderne AI-Integrationen nicht zwingend Python oder TypeScript braucht. Im Gegenteil: Mit Spring Boot 4 und modernem Java haben wir einen MCP Server gebaut, der typsicher, robust und dank Java-Ecosystem extrem mächtig ist.

Das Model Context Protocol ist das Bindeglied zwischen LLMs und unserer echten Welt. Als Java-Entwickler haben wir jetzt die Werkzeuge, diese Brücke stabil zu bauen.