# 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](https://github.com/thomas-hochbichler/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](https://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):**

```python
@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):**

```java
@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](https://cdn.hashnode.com/res/hashnode/image/upload/v1773595012250/acdcd28b-5f67-4091-bed1-048b6994d8e8.png)

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](https://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](https://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`:

```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`:

```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:

```java
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:

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

## Your First MCP Tool: Health Check

Create a new class `ActuatorMcpTools.java`:

```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](https://github.com/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`:
>
> ```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:

```java
@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:

```java
@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`:

```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:

```bash
./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:

```bash
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:

```bash
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:

| Problem | Cause | Fix |
|---------|-------|-----|
| Tool does not appear in `/mcp` | Method returns `Mono<T>` but server type is `SYNC` | Change return type to a plain type, or set `spring.ai.mcp.server.type=ASYNC` |
| `Connection closed` error | Wrong JAR path or JAR does not exist | Use absolute path, run `ls` on the JAR to verify |
| `ENOENT` on Windows | Windows cannot execute `java` directly via STDIO | Use `cmd /c java -jar ...` as the command |
| Tool exists but AI never calls it | Description is too vague | Make the description specific. List valid input values if possible |
| `annotation-scanner` finds nothing | Class is missing `@Component` | Add `@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`:

```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`:

```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:

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

**When to use which**:

| | STDIO | Streamable HTTP |
|---|---|---|
| **Use when** | Local dev, single user | Team use, remote servers, CI/CD |
| **Startup** | Claude Code launches the JAR | You run the server independently |
| **Networking** | None (in-process pipes) | HTTP, can run anywhere |
| **Trade-off** | Cold start delay | Need 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):

```bash
./mvnw -Pnative native:compile
```

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

```bash
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](https://github.com/thomas-hochbichler/spring-ai-mcp-actuator) and try it with your own services.

