Skip to main content

Command Palette

Search for a command to run...

Build an MCP Server with Spring Boot 4

Build a Spring Boot 4 MCP server that monitors external apps via Actuator endpoints and connects to Claude Code using Spring AI's @McpTool annotations.

Updated
15 min read
Build an MCP Server with Spring Boot 4

Build an MCP Server with Spring Boot 4

Every MCP tutorial starts the same way: "First, install Python." Or TypeScript. Or Go. If you are a Java developer with a Spring Boot stack, you have been waiting for a Java option.

Spring Boot 4 changes that. Combined with Spring AI's @McpTool annotations, you can build an MCP server in Java that is just as concise as Python — with dependency injection, native image support, and the full Spring ecosystem behind it.

TL;DR: We build a lightweight MCP server with Spring Boot 4 that monitors external Spring Boot applications via their Actuator endpoints. Connect it to Claude Code and ask "is the order service healthy?" in natural language. Full working code included.

Companion code: spring-ai-mcp-actuator — three independent Maven projects you can build and run in minutes.

What Is MCP?

The Model Context Protocol (MCP) is an open standard for connecting AI applications to external tools and data. Think of it as a USB-C port for AI: one protocol, many connections.

The architecture is simple:

  • Client: the AI application (Claude Code, Claude Desktop, Cursor)
  • Server: your service that exposes capabilities
  • Three capability types: Tools (actions the AI can call), Resources (data the AI can read), Prompts (reusable templates)

MCP is now governed by the Agentic AI Foundation (AAIF) under the Linux Foundation and adopted by Google, Microsoft, OpenAI, and Amazon. It is not a niche experiment anymore — it is becoming the default integration layer for AI tooling.

For the full specification, see modelcontextprotocol.io. We will focus on building, not theory.

MCP vs Claude Code Skills: Skills (like /article-reviewer) are prompt-driven workflows that run inside Claude Code. MCP servers are standalone tool servers that follow an open protocol — any MCP client can connect to them, not just Claude Code. Think of Skills as internal scripts and MCP servers as external services.

Java vs Python: The Verbosity Myth

Before we start, let me address the common assumption. Most developers assume Java means more code. Here is a side-by-side comparison.

Python (FastMCP):

@mcp.tool()
def check_health(app_name: str = "") -> str:
    """Check the health of a monitored Spring Boot application"""
    return get_health(app_name)

Java (Spring AI):

@McpTool(description = "Check the health of a monitored Spring Boot application")
public String checkHealth(String appName) {
    return getHealth(appName);
}

Three lines vs three lines. The difference is cosmetic. But with Spring Boot you also get dependency injection, Spring Security, Spring Data, and the entire Spring ecosystem. For free.

What We Are Building

We will build a lightweight MCP server that monitors external Spring Boot applications via their Actuator endpoints. The MCP server itself does not run a web server — it communicates with Claude Code over STDIO and calls your apps' Actuator endpoints over HTTP.

Diagram

When we are done, you can open Claude Code and have conversations like this:

You: Is localhost:8080 healthy?
Claude: [calls check-health(appName="localhost:8080")]
        localhost:8080 (http://localhost:8080) is UP
        {"status":"UP","components":{"db":{"status":"UP","details":
        {"database":"PostgreSQL","validationQuery":"isValid()"}},...}}

You: Check all apps
Claude: [calls check-health()]
        localhost:8080 (http://localhost:8080): UP
        localhost:8081 (http://localhost:8081): UP

You: What is the JVM memory usage on localhost:8080?
Claude: [calls get-metric(appName="localhost:8080", metricName="jvm.memory.used")]
        localhost:8080 — jvm.memory.used: {"name":"jvm.memory.used",
        "measurements":[{"statistic":"VALUE","value":1.34217728E8}],
        "baseUnit":"bytes"}

This is a practical pattern. Every Spring Boot app ships with Actuator. After this tutorial, you can point this MCP server at any running Spring Boot application and monitor it through natural language.

Prerequisites

  • Java 21 or later
  • Spring Boot 4.0 (GA, released November 2025)
  • Spring AI 2.0.0-M2 (current milestone as of March 2026)
  • Claude Code installed (code.claude.com)
  • Basic familiarity with Spring Boot

Note: Spring AI 2.0 is at milestone 2, not GA yet. APIs may change before the final release — no official GA date has been announced, but mid-2026 is a reasonable community estimate. The annotation-based approach shown here has been stable since M1.

Project Setup

Go to start.spring.io and configure:

  • Project: Maven
  • Language: Java
  • Spring Boot: 4.0.x
  • Group: com.hochbichler
  • Artifact: mcp-actuator
  • Java: 21
  • Dependencies: Spring Web

Download and unzip. Then add the Spring AI MCP Server dependency to your pom.xml:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>2.0.0-M2</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- Spring AI MCP Server (STDIO transport, no web server) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server</artifactId>
    </dependency>

    <!-- RestClient for calling Actuator endpoints on target apps -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

Watch out: older tutorials reference spring-ai-mcp-server-spring-boot-starter. That artifact name was renamed in Spring AI 1.0.0-M7. The correct name is spring-ai-starter-mcp-server.

We include spring-boot-starter-web for RestClient — Spring Boot 4's modern HTTP client. The web server will not conflict with STDIO because we explicitly set spring.main.web-application-type=none, disabling the embedded web server.

Now configure application.properties:

# MCP Server configuration
spring.ai.mcp.server.stdio=true
spring.ai.mcp.server.type=SYNC
spring.ai.mcp.server.annotation-scanner.enabled=true

# Application name
spring.application.name=mcp-actuator

# No web server — STDIO only
spring.main.web-application-type=none

Three MCP properties and one explicit web-type override. That is all the framework configuration you need.

  • stdio=true — use STDIO transport (Claude Code launches your JAR as a subprocess)
  • type=SYNC — synchronous server (filters out any Mono/Flux return types)
  • annotation-scanner.enabled=true — auto-discover @McpTool methods at startup
  • web-application-type=none — no embedded web server (required when using spring-boot-starter-web alongside the STDIO transport)

The target app URLs are passed as CLI arguments: --apps=http://localhost:8080,http://localhost:8081. We will parse those next.

App Registry: Parsing CLI Arguments

Create an AppRegistry component that parses the --apps argument and stores the target applications:

package com.hochbichler.mcpactuator;

import java.net.URI;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class AppRegistry {

    private final Map<String, String> apps = new LinkedHashMap<>();

    public AppRegistry(@Value("${apps:}") String appsArg) {
        if (!appsArg.isBlank()) {
            for (String url : appsArg.split(",")) {
                url = url.trim();
                String name = extractAppName(url);
                apps.put(name, url);
            }
        }
    }

    private String extractAppName(String url) {
        URI uri = URI.create(url);
        String host = uri.getHost();
        int port = uri.getPort();
        return host + (port > 0 ? ":" + port : "");
    }

    public Map<String, String> getApps() {
        return Collections.unmodifiableMap(apps);
    }

    public String getUrl(String appName) {
        return apps.get(appName);
    }
}

Spring Boot maps --apps=value on the command line to the apps property. The @Value("${apps:}") annotation injects it with an empty default. The registry derives a name from each URL — localhost:8080, localhost:8081, etc. — and stores the mapping.

Run the server with:

java -jar mcp-actuator.jar --apps=http://localhost:8080,http://localhost:8081

Your First MCP Tool: Health Check

Create a new class ActuatorMcpTools.java:

package com.hochbichler.mcpactuator;

import java.util.Map;

import org.springaicommunity.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

@Component
public class ActuatorMcpTools {

    private final AppRegistry appRegistry;
    private final RestClient restClient;

    public ActuatorMcpTools(AppRegistry appRegistry) {
        this.appRegistry = appRegistry;
        this.restClient = RestClient.create();
    }

    @McpTool(
        name = "check-health",
        description = "Check the health of a monitored Spring Boot application. "
            + "Leave appName empty to check all apps.")
    public String checkHealth(
            @McpToolParam(description = "App name (e.g. localhost:8080) or leave empty to check all",
                          required = false)
            String appName) {

        if (appName == null || appName.isBlank()) {
            return checkAllApps();
        }

        String url = appRegistry.getUrl(appName);
        if (url == null) {
            return "Unknown app: " + appName
                + ". Registered apps: " + appRegistry.getApps().keySet();
        }

        return fetchHealth(appName, url);
    }

    private String checkAllApps() {
        var sb = new StringBuilder();
        for (var entry : appRegistry.getApps().entrySet()) {
            sb.append(fetchHealth(entry.getKey(), entry.getValue())).append("\n");
        }
        return sb.toString().trim();
    }

    private String fetchHealth(String name, String url) {
        try {
            String response = restClient.get()
                .uri(url + "/actuator/health")
                .retrieve()
                .body(String.class);
            return name + " (" + url + "): " + response;
        } catch (RestClientException e) {
            return name + " (" + url + "): DOWN — " + e.getMessage();
        }
    }
}

Why org.springaicommunity? The @McpTool and @McpToolParam annotations are not yet included in the official Spring AI 2.0.0-M2 starters. They live in the spring-ai-community/mcp-annotations incubating project (org.springaicommunity:spring-ai-mcp-annotations). Once they graduate into mainline Spring AI (expected in a later milestone), the package will change to org.springframework.ai.mcp.annotation. For now, add the community dependency to your pom.xml:

<dependency>
    <groupId>org.springaicommunity</groupId>
    <artifactId>spring-ai-mcp-annotations</artifactId>
    <version>0.0.3</version>
</dependency>

That is it. The @McpTool annotation tells Spring AI to:

  1. Register this method as an MCP tool named check-health
  2. Generate a JSON schema from the method signature (including the optional appName parameter)
  3. Make it callable by any connected MCP client

The description field is important. MCP clients show this to the AI model so it knows when to use the tool. Be specific.

Notice the error handling: if a target app is down or unreachable, we catch the RestClientException and report it as DOWN instead of crashing. The MCP server stays healthy even when monitored apps are not.

Adding a Metrics Tool

Add this method to the same ActuatorMcpTools class:

@McpTool(
    name = "get-metric",
    description = "Get a specific metric from a monitored app. "
        + "Common metrics: jvm.memory.used, http.server.requests, "
        + "system.cpu.usage, process.uptime",
    annotations = @McpTool.McpAnnotations(
        readOnlyHint = true,
        destructiveHint = false
    ))
public String getMetric(
        @McpToolParam(description = "App name (e.g. localhost:8080)",
                      required = true)
        String appName,
        @McpToolParam(description = "Metric name, e.g. jvm.memory.used",
                      required = true)
        String metricName) {

    String url = appRegistry.getUrl(appName);
    if (url == null) {
        return "Unknown app: " + appName
            + ". Registered apps: " + appRegistry.getApps().keySet();
    }

    try {
        String response = restClient.get()
            .uri(url + "/actuator/metrics/" + metricName)
            .retrieve()
            .body(String.class);
        return appName + " — " + metricName + ": " + response;
    } catch (RestClientException e) {
        return "Failed to fetch " + metricName + " from " + appName
            + ": " + e.getMessage();
    }
}

A few things to notice:

@McpToolParam adds metadata to each parameter. The description tells the AI model what format to use. The required = true flag means the client must provide this value. Both appName and metricName are required here — unlike check-health, which makes appName optional for the "check all" convenience.

@McpTool.McpAnnotations was introduced in Spring AI 1.1 via the community annotations project and is available in Spring AI 2.0. The readOnlyHint tells the client this tool does not change any state. The destructiveHint = false confirms it is safe. These hints help AI models decide when to call your tools without asking for confirmation.

The description lists common metric names. This is a practical trick: when the AI model reads the tool description, it knows which values are valid. Without this, the model has to guess or ask the user.

Let us also add a tool to list all available metrics for a given app:

@McpTool(
    name = "list-metrics",
    description = "List all available metric names for a monitored app",
    annotations = @McpTool.McpAnnotations(readOnlyHint = true))
public String listMetrics(
        @McpToolParam(description = "App name (e.g. localhost:8080)",
                      required = true)
        String appName) {

    String url = appRegistry.getUrl(appName);
    if (url == null) {
        return "Unknown app: " + appName
            + ". Registered apps: " + appRegistry.getApps().keySet();
    }

    try {
        return restClient.get()
            .uri(url + "/actuator/metrics")
            .retrieve()
            .body(String.class);
    } catch (RestClientException e) {
        return "Failed to fetch metrics from " + appName + ": " + e.getMessage();
    }
}

Exposing App Info as an MCP Resource

Before adding the resource, it helps to understand why MCP distinguishes resources from tools at all — and why health status is not a good fit for a resource.

The official MCP specification draws a clear line: tools are model-controlled, resources are application-driven.

  • A tool is something the AI invokes — it decides when to call it, picks the arguments, and acts on the result. Tools are designed for interaction: querying a database, calling an API, running a computation.
  • A resource is something the AI (or the host application) reads — it is a URI-addressable piece of context: a file, a schema, a configuration snapshot. The MCP spec says resources exist to "share data that provides context to language models".

The key question when choosing between the two is: how often does this data change?

  • Health status changes every few seconds — an app can go from UP to DOWN while you are mid-conversation. If you expose health as a resource, Claude might read it once at the start and act on stale data. Health belongs as a tool: invoked on demand, always fresh.
  • Build info (version number, artifact name, git commit) is written at compile time and never changes while the app is running. This is safe to read once as background context. App info belongs as a resource.

The rule of thumb: expose data as a resource when it is stable during runtime (versions, registered apps, configuration). Expose it as a tool when it changes frequently or requires parameters to fetch a specific value.

Add a new class ActuatorMcpResources.java:

package com.hochbichler.mcpactuator;

import org.springaicommunity.mcp.annotation.McpResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

@Component
public class ActuatorMcpResources {

    private final AppRegistry appRegistry;
    private final RestClient restClient;

    public ActuatorMcpResources(AppRegistry appRegistry) {
        this.appRegistry = appRegistry;
        this.restClient = RestClient.create();
    }

    @McpResource(
        uri = "apps://info",
        name = "App Registry",
        description = "Registered apps and their static build info from /actuator/info")
    public String getAppInfo() {
        var sb = new StringBuilder();
        for (var entry : appRegistry.getApps().entrySet()) {
            String name = entry.getKey();
            String url = entry.getValue();
            sb.append("=== ").append(name).append(" ===\n");
            sb.append("URL: ").append(url).append("\n");
            try {
                String info = restClient.get()
                    .uri(url + "/actuator/info")
                    .retrieve()
                    .body(String.class);
                sb.append(info).append("\n");
            } catch (RestClientException e) {
                sb.append("info: not available\n");
            }
            sb.append("\n");
        }
        return sb.toString().trim();
    }
}

When Claude Code connects, it can read apps://info to learn which apps are registered and what versions they are running — without you having to ask. That context is stable for the entire session. When you then ask "why is the order service slow?", Claude already knows the service exists and what version it is; it only needs to call the check-health or get-metric tools for the live data.

Connecting to Claude Code

Build the JAR:

./mvnw clean package -DskipTests

Make sure your target Spring Boot applications are running with Actuator enabled. For example, if you have two apps running on ports 8080 and 8081, add the MCP server to Claude Code:

claude mcp add --transport stdio spring-actuator \
  -- java -jar /absolute/path/to/target/mcp-actuator-0.0.1-SNAPSHOT.jar \
  --apps=http://localhost:8080,http://localhost:8081

Important: use the absolute path to your JAR. Relative paths break because Claude Code launches the process from a different working directory.

Verify the connection inside Claude Code:

/mcp

You should see spring-actuator listed with status "connected" and three tools: check-health, get-metric, list-metrics.

The architecture here is key: Claude Code launches the MCP server JAR as a subprocess (via STDIO). The MCP server itself does not run a web server — it is a lightweight process that calls the Actuator endpoints on your target apps over HTTP using RestClient. Your actual applications run independently and just need Actuator exposed.

Try it:

You: Is the order service healthy?
You: What metrics are available on localhost:8080?
You: How much JVM memory is the order service using?

Claude Code calls your MCP tools, which fetch the Actuator data from the target apps over HTTP, and responds in natural language.

Spring Boot Cold Start and MCP_TIMEOUT

You might hit a connection timeout on first launch. Spring Boot needs a few seconds to start, and Claude Code's default MCP timeout may be too short.

Fix it by setting the timeout before starting Claude Code:

MCP_TIMEOUT=10000 claude

This gives your server 10 seconds to start. We will address this properly in the native image section.

When Things Go Wrong

Here are the issues I have run into and how to fix them:

ProblemCauseFix
Tool does not appear in /mcpMethod returns Mono<T> but server type is SYNCChange return type to a plain type, or set spring.ai.mcp.server.type=ASYNC
Connection closed errorWrong JAR path or JAR does not existUse absolute path, run ls on the JAR to verify
ENOENT on WindowsWindows cannot execute java directly via STDIOUse cmd /c java -jar ... as the command
Tool exists but AI never calls itDescription is too vagueMake the description specific. List valid input values if possible
annotation-scanner finds nothingClass is missing @ComponentAdd @Component to your tool class. It must be a Spring-managed bean

Where to find logs: Claude Code logs MCP communication. Run /mcp and check the server status. For Spring-side logs, add logging.level.org.springframework.ai.mcp=DEBUG to application.properties.

The silent async trap: if you write a method that returns Mono<String> in a SYNC server, Spring AI drops it with a warning in the startup log. No error. The tool just does not show up. Check your startup logs if tools are missing.

Beyond STDIO: HTTP Transport for Teams

STDIO works great for local development. But it requires Claude Code to launch your JAR as a subprocess. For team use or remote servers, switch to Streamable HTTP transport.

Step 1: swap the dependency in pom.xml:

<!-- Replace spring-ai-starter-mcp-server with: -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>

Step 2: update application.properties:

# Remove: spring.ai.mcp.server.stdio=true
# Add:
spring.ai.mcp.server.protocol=STREAMABLE
server.port=8081

Step 3: start the app normally and connect Claude Code:

./mvnw spring-boot:run
claude mcp add --transport http spring-actuator http://localhost:8081/mcp

When to use which:

STDIOStreamable HTTP
Use whenLocal dev, single userTeam use, remote servers, CI/CD
StartupClaude Code launches the JARYou run the server independently
NetworkingNone (in-process pipes)HTTP, can run anywhere
Trade-offCold start delayNeed to manage a running server

Native Image: Instant Startup for MCP Servers

The STDIO cold start problem has a clean solution: GraalVM native image.

A Spring Boot 4 native image compiles your app ahead of time into a standalone binary. The result: startup in ~100ms instead of 3-5 seconds. No JVM needed at runtime. For a minimal MCP server like ours, the binary is typically around 50-80 MB (larger apps with more dependencies can exceed 100 MB).

For an MCP server — a small, single-purpose tool — this is a perfect fit.

Add the GraalVM native support to your pom.xml (Spring Boot 4 includes the plugin by default, you just need to activate the profile):

./mvnw -Pnative native:compile

This produces a binary at target/mcp-actuator. Add it to Claude Code without the java -jar wrapper:

claude mcp add --transport stdio spring-actuator \
  -- /absolute/path/to/target/mcp-actuator \
  --apps=http://localhost:8080,http://localhost:8081

No more MCP_TIMEOUT workaround. The server starts before Claude Code even finishes sending the initialization handshake.

Trade-offs:

  • Build time is significantly longer (2-5 minutes vs seconds for a regular JAR)
  • Reflection-based libraries may need GraalVM configuration hints
  • Spring Boot 4's improved AOT engine handles most cases automatically, but test your native build before relying on it
  • You need GraalVM installed locally or use a CI pipeline with native image support

For local development, stick with the regular JAR and MCP_TIMEOUT. Use native image for the version you distribute to your team or deploy as a shared tool.

Conclusion

The architectural choices worth remembering:

  • STDIO for local dev, Streamable HTTP for teams — same tool code, different transport dependency
  • Tools for live data, resources for stable context — health status changes every second; build info does not
  • Native image eliminates the cold start problem — a 100ms startup means no more MCP_TIMEOUT hacks

Every Spring Boot application already ships with Actuator — point this MCP server at any running instance and you can monitor it through natural language. To extend this further, add tools for /actuator/env, /actuator/loggers, or /actuator/threaddump using the same @McpTool pattern. Fork the companion code at spring-ai-mcp-actuator and try it with your own services.