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

Julian | Jan 23, 2026 min read

When we currently talk about the Model Context Protocol (MCP), TypeScript or Python usually dominates the timeline. But what about us Java developers?

In this article we will build a “Local DevOps Agent”. He will be able to analyze and control our local Docker environment. And because we don’t do things by halves, we rely on Spring Boot 4.

Why Spring Boot 4? Because the start times and the memory footprint have been further optimized - essential for CLI tools that are used “on-demand” by an LLM. We also use features of modern Java versions (records, switch expressions) to keep the code extremely compact.

The Architecture & The Trap

An MCP server (mostly) communicates with the host (e.g. the Claude Desktop App) via Stdio (Standard Input/Output).

This presents us Java developers with a problem: Spring Boot loves logs. When Spring Boot prints its famous ASCII banner or a “Started Application” info on System.out (stdout), it immediately destroys the MCP protocol because the client expects valid JSON there.

The Architecture Rule #1:

  • System.in / System.out: Exclusive to the JSON-RPC protocol.
  • System.err: For logs, debugging and Spring startup messages.

1. Setup & “Silent” Logging

We are starting a new project. Since we use Spring Boot 4, we pull the most current dependencies.

The pom.xml (excerpt)

We need jackson for JSON handling and docker-java for communication with the 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>

The critical configuration

We have to deactivate the web server and change the logging.

src/main/resources/application.properties:

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

src/main/resources/logback-spring.xml: This is the most important file in the project. We force all logs to 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. The Docker Service: Type safety meets containment

Before we talk about MCP, we need a clean abstraction for Docker. We use Java Records for our DTOs.

package dev.julianpaul.mcp.model;

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

The service uses the DockerClient to talk to the local socket:

@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

Now it’s getting exciting. We implement a CommandLineRunner that listens on System.in.

First a record for the JSON-RPC format:

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

And here is the runner that routes the requests:

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

Define tools (tools/list)

In order for the LLM to know what it can do, we need to introduce ourselves. Here we define the schema for our 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!

A mistake I see again and again: The server calculates the result, writes it to the output… and nothing happens.

Why? Because System.out is buffered. We have to flush.

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

We build the Fat JAR with mvn clean package. Let’s assume it’s located at /path/to/mcp-docker-agent.jar.

In the claude_desktop_config.json we register the server:

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

How do I debug this?

The problem: When Claude starts the Java process, you don’t see a console. How do we set breakpoints?

Solution: Remote Debugging (JDWP). Temporarily change the config in Claude like this:

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

Now you can start a “Remote JVM Debug” configuration in IntelliJ that connects to port 5005. As soon as you ask Claude a question, your debugger will kick in. This is developer experience at a professional level.

Conclusion

We showed that you don’t necessarily need Python or TypeScript for modern AI integrations. On the contrary: With Spring Boot 4 and modern Java, we have built an MCP server that is type-safe, robust and extremely powerful thanks to the Java ecosystem.

The Model Context Protocol is the link between LLMs and our real world. As Java developers, we now have the tools to build this bridge robustly.