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.
