Building an MCP Server in Java with Spring AI 1.0
Introduction
The Model Context Protocol (MCP), originally developed by Anthropic and now maintained as an open standard, is quickly becoming the "USB-C for AI" — a universal plug that lets any AI agent connect to any data source or external tool through a standardised interface. If you have ever wired up a custom LangChain tool, built a ChatGPT plugin, or hand-crafted a function-calling wrapper, MCP is what replaces all of that with a single, interoperable protocol.
As a solution architect, the pattern you want to reach for is this: your backend services become MCP servers that expose typed tools, resources, and prompts. Any MCP-compatible client — Claude Desktop, a Spring AI ChatClient, your own orchestration layer — discovers and invokes those tools at runtime without you writing a bespoke adapter for each pairing. One server, many clients.
This tutorial walks you through building a production-quality MCP server with Spring AI 1.0.0 and Spring Boot 3.4.5. The example domain is a task-management backend: the server exposes tools for an AI agent to list tasks, query by status, create new tasks, and mark them complete — all backed by a real JPA repository. By the end you will have a running server you can connect to Claude Desktop and query in plain English.
Prerequisites
- Java 21 (LTS)
- Maven 3.9+
- Spring Boot 3.4.5
- Spring AI BOM 1.0.0
- Node.js 18+ (optional, for the MCP Inspector)
- Claude Desktop (optional, for live testing)
Project Setup
Create a new Spring Boot project or add the following to an existing one.
<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<groupId>com.vimleshpandey.demo</groupId>
<artifactId>mcp-task-server</artifactId>
<version>1.0.0</version>
<name>mcp-task-server</name>
<description>MCP Server exposing Task Management tools via Spring AI</description>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- MCP Server — HTTP Streamable transport (recommended) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<!-- JPA and H2 for demo persistence -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Notice the single key dependency: spring-ai-starter-mcp-server-webmvc. This starter wires up the MCP endpoint at /mcp using Spring MVC's Streamable HTTP transport — the recommended choice for network-accessible servers in Spring AI 1.x.
There are three starter variants and they are mutually exclusive:
| Artifact | Transport | Use case |
|---|---|---|
spring-ai-starter-mcp-server | STDIO | Claude Desktop subprocess |
spring-ai-starter-mcp-server-webmvc | HTTP Streamable | Spring MVC, network clients |
spring-ai-starter-mcp-server-webflux | SSE/WebFlux | Reactive stack |
You cannot mix them in the same application context. If you need Claude Desktop's local subprocess mode, swap in the plain mcp-server starter.
Configuring the Server
# src/main/resources/application.yml
spring:
ai:
mcp:
server:
name: task-management-server
version: "1.0.0"
protocol: STREAMABLE
instructions: |
You are connected to the Task Management MCP server.
Use the available tools to query, create, and update tasks.
Always filter by status or owner when possible to keep responses concise.
datasource:
url: jdbc:h2:mem:taskdb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
h2:
console:
enabled: true
server:
port: 8080
The instructions field is shown to the AI client on connection — treat it like a system prompt that explains the server's purpose and calling conventions.
Set protocol: STREAMABLE explicitly. SSE transport was deprecated in Spring AI 2.x, and being explicit protects you at upgrade time.
Defining the Domain Model
// src/main/java/com/vimleshpandey/demo/model/Task.java
package com.vimleshpandey.demo.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TaskStatus status = TaskStatus.TODO;
private String owner;
@Column(updatable = false)
private Instant createdAt = Instant.now();
private Instant completedAt;
public enum TaskStatus { TODO, IN_PROGRESS, DONE, BLOCKED }
public Long getId() { return id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public TaskStatus getStatus() { return status; }
public void setStatus(TaskStatus status) { this.status = status; }
public String getOwner() { return owner; }
public void setOwner(String owner) { this.owner = owner; }
public Instant getCreatedAt() { return createdAt; }
public Instant getCompletedAt() { return completedAt; }
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
}
// src/main/java/com/vimleshpandey/demo/repository/TaskRepository.java
package com.vimleshpandey.demo.repository;
import com.vimleshpandey.demo.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByStatus(Task.TaskStatus status);
List<Task> findByOwner(String owner);
List<Task> findByOwnerAndStatus(String owner, Task.TaskStatus status);
}
Implementing the MCP Tools
This is the core of the server. Spring AI scans every @Component bean for methods annotated with @McpTool and automatically:
- Generates a JSON schema from the method's parameter types
- Registers the tool at the MCP endpoint
- Routes incoming tool-call requests to the method at runtime
// src/main/java/com/vimleshpandey/demo/tools/TaskToolService.java
package com.vimleshpandey.demo.tools;
import com.vimleshpandey.demo.model.Task;
import com.vimleshpandey.demo.repository.TaskRepository;
import org.springframework.ai.tool.annotation.McpTool;
import org.springframework.ai.tool.annotation.McpToolParam;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class TaskToolService {
private final TaskRepository taskRepository;
public TaskToolService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@McpTool(
name = "list_tasks",
description = "List all tasks, optionally filtered by status (TODO, IN_PROGRESS, DONE, BLOCKED) and/or owner name."
)
public String listTasks(
@McpToolParam(description = "Filter by status: TODO, IN_PROGRESS, DONE, or BLOCKED. Omit to return all.", required = false)
String status,
@McpToolParam(description = "Filter by task owner name. Omit to return tasks for all owners.", required = false)
String owner
) {
List<Task> tasks;
if (status != null && owner != null) {
tasks = taskRepository.findByOwnerAndStatus(owner, Task.TaskStatus.valueOf(status));
} else if (status != null) {
tasks = taskRepository.findByStatus(Task.TaskStatus.valueOf(status));
} else if (owner != null) {
tasks = taskRepository.findByOwner(owner);
} else {
tasks = taskRepository.findAll();
}
if (tasks.isEmpty()) {
return "No tasks found matching the given criteria.";
}
return tasks.stream()
.map(t -> String.format("[%d] %s — %s (owner: %s)",
t.getId(), t.getTitle(), t.getStatus(),
t.getOwner() != null ? t.getOwner() : "unassigned"))
.collect(Collectors.joining("\n"));
}
@McpTool(
name = "get_task",
description = "Retrieve full details of a single task by its numeric ID."
)
public String getTask(
@McpToolParam(description = "The numeric task ID.", required = true)
Long id
) {
return taskRepository.findById(id)
.map(t -> String.format(
"ID: %d\nTitle: %s\nDescription: %s\nStatus: %s\nOwner: %s\nCreated: %s\nCompleted: %s",
t.getId(), t.getTitle(), t.getDescription(), t.getStatus(),
t.getOwner(), t.getCreatedAt(),
t.getCompletedAt() != null ? t.getCompletedAt().toString() : "—"))
.orElse("Task not found with id " + id);
}
@McpTool(
name = "create_task",
description = "Create a new task with status TODO. Returns the new task ID and title."
)
public String createTask(
@McpToolParam(description = "Short, descriptive title for the task.", required = true)
String title,
@McpToolParam(description = "Optional longer description of the task.", required = false)
String description,
@McpToolParam(description = "Name of the person responsible for this task.", required = false)
String owner
) {
Task task = new Task();
task.setTitle(title);
task.setDescription(description);
task.setOwner(owner);
Task saved = taskRepository.save(task);
return String.format("Task created: ID=%d, title=\"%s\", status=TODO", saved.getId(), saved.getTitle());
}
@McpTool(
name = "update_task_status",
description = "Update the status of an existing task. Valid statuses: TODO, IN_PROGRESS, DONE, BLOCKED."
)
public String updateTaskStatus(
@McpToolParam(description = "The numeric task ID to update.", required = true)
Long id,
@McpToolParam(description = "New status: TODO, IN_PROGRESS, DONE, or BLOCKED.", required = true)
String newStatus
) {
return taskRepository.findById(id).map(task -> {
Task.TaskStatus statusEnum = Task.TaskStatus.valueOf(newStatus);
task.setStatus(statusEnum);
if (statusEnum == Task.TaskStatus.DONE) {
task.setCompletedAt(Instant.now());
}
taskRepository.save(task);
return String.format("Task %d updated to %s.", id, newStatus);
}).orElse("Task not found with id " + id);
}
}
A few architectural points worth noting:
Descriptions are prompt engineering. The description on @McpTool and @McpToolParam is what the LLM reads to decide when and how to call the tool. "Omit to return all" prevents the model from passing null literally. Naming the exact enum values eliminates guessing.
Return String. Tool methods can return any JSON-serialisable type, but plain String is the most portable. Returning a raw JPA entity with lazy-loaded relationships triggers LazyInitializationException at serialisation time — always project to a simple type before returning.
Keep tools idempotent where possible. LLMs may retry on timeout or ambiguity. list_tasks and get_task are naturally safe; create_task is not. In production, accept a caller-supplied idempotency key to prevent duplicates.
Seeding Sample Data
// src/main/java/com/vimleshpandey/demo/DataSeeder.java
package com.vimleshpandey.demo;
import com.vimleshpandey.demo.model.Task;
import com.vimleshpandey.demo.repository.TaskRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class DataSeeder implements CommandLineRunner {
private final TaskRepository taskRepository;
public DataSeeder(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Override
public void run(String... args) {
taskRepository.save(task("Implement OAuth2 login", "Add Google SSO", "alice", Task.TaskStatus.IN_PROGRESS));
taskRepository.save(task("Write API documentation", "OpenAPI 3.1 spec", "bob", Task.TaskStatus.TODO));
taskRepository.save(task("Set up Prometheus metrics", null, "alice", Task.TaskStatus.DONE));
taskRepository.save(task("Review pull request #42", "Security review", "carol", Task.TaskStatus.BLOCKED));
taskRepository.save(task("Deploy to staging", null, "bob", Task.TaskStatus.TODO));
}
private Task task(String title, String desc, String owner, Task.TaskStatus status) {
Task t = new Task();
t.setTitle(title);
t.setDescription(desc);
t.setOwner(owner);
t.setStatus(status);
return t;
}
}
Main Application Class
// src/main/java/com/vimleshpandey/demo/McpServerApplication.java
package com.vimleshpandey.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
}
Testing the MCP Server
Integration Test with Spring AI
// src/test/java/com/vimleshpandey/demo/McpServerIntegrationTest.java
package com.vimleshpandey.demo;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class McpServerIntegrationTest {
@Autowired
private McpSyncServerExchange exchange;
@Test
void toolListContainsExpectedTools() {
var result = exchange.listTools(null);
var names = result.tools().stream()
.map(t -> t.name())
.toList();
assertThat(names).contains("list_tasks", "get_task", "create_task", "update_task_status");
}
@Test
void listTasksReturnsSeededData() {
var response = exchange.callTool(
new io.modelcontextprotocol.spec.McpSchema.CallToolRequest(
"list_tasks",
java.util.Map.of("status", "TODO")
)
);
assertThat(response.content()).isNotEmpty();
String text = response.content().get(0).toString();
assertThat(text).contains("TODO");
}
}
Testing with the MCP Inspector
The MCP Inspector is an interactive browser tool for exploring and invoking any MCP server without writing client code. With the server running:
npx @modelcontextprotocol/inspector http://localhost:8080/mcp
Navigate to http://localhost:5173. The Tools tab lists all four tools. Click list_tasks, set status to IN_PROGRESS, and hit Run — the result appears immediately. This is the fastest way to verify tool descriptions and response formats before connecting a real LLM.
Running the Application
mvn spring-boot:run
The MCP endpoint is available at http://localhost:8080/mcp.
Connecting Claude Desktop
Add the following to your Claude Desktop configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"task-server": {
"url": "http://localhost:8080/mcp"
}
}
}
Restart Claude Desktop. You can now ask: "What tasks is alice working on?" and Claude will call list_tasks(owner="alice"), format the result, and reply in natural language.
Securing the Endpoint
An unauthenticated MCP server must never be exposed beyond localhost. For internal network use, HTTP Basic via Spring Security is sufficient:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/mcp/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.build();
}
}
Spring AI 1.1+ ships a dedicated spring-ai-starter-mcp-server-security starter that adds OAuth2/OIDC protection and token introspection for production deployments.
Common Pitfalls and Tips
1. Wrong starter for your transport. The three starters are mutually exclusive. Mixing them causes a context load failure. mcp-server is STDIO only; mcp-server-webmvc and mcp-server-webflux are HTTP. Pick one.
2. SSE transport is deprecated. In Spring AI 2.x, SSE was deprecated in favour of Streamable HTTP. Set protocol: STREAMABLE explicitly now so the upgrade is a no-op.
3. Lazy JPA relationships. Returning a managed JPA entity from a tool method triggers LazyInitializationException during serialisation. Map to a DTO or a plain String before returning.
4. Missing required = true. Without this flag, the LLM may omit a required argument. Spring AI uses it to populate the required array in the generated JSON schema. Mark every non-optional parameter explicitly.
5. Vague tool descriptions. A tool called manage_tasks with a broad description makes the LLM guess. Prefer narrow, single-purpose tools with explicit descriptions that name exactly what values are valid.
6. Tool count. MCP clients retrieve the full tool list on every connection. Around 50 tools, the context overhead becomes noticeable. Group related operations rather than exposing every CRUD method individually.
7. Write-tool idempotency. LLMs retry on timeout. Accept an optional idempotency key on any tool that mutates state, and return the existing result if the key has been seen before.
Conclusion
MCP is becoming the standard integration layer between enterprise backends and AI agents. Building a server with Spring AI 1.0 requires almost no boilerplate: a single Maven dependency, a few lines of YAML, and @McpTool-annotated methods on any Spring component. Your existing JPA repositories, service layers, and Spring Security setup all carry over directly.
The pattern scales beyond CRUD. The same architecture works for CI/CD orchestration, CRM integration, Prometheus metrics queries, and internal knowledge retrieval. Any service that can be described as "a set of named, typed operations" is a good MCP server candidate — and now there is a proper standard to hang that on.
Fork the full project on GitHub or grab the ZIP file below, run mvn spring-boot:run, and open the MCP Inspector. Your first AI-callable database tool is three commands away.