Building Model Context Protocol (MCP) Servers in Java: A Complete Guide
Introduction
Model Context Protocol (MCP) is transforming how AI applications integrate with external systems. If you're building Java applications that need to give Claude access to your data, APIs, or tools, MCP is the standard way to do it.
Think of MCP as a bridge: Claude sits on one side, your Java services sit on the other, and MCP ensures they communicate safely and efficiently. Instead of cramming everything into a system prompt or manually passing data, MCP gives Claude standardized access to your resources.
In this guide, we'll explore what MCPs are, why they matter, how to build one in Java, and how to verify it works end-to-end.
What is Model Context Protocol (MCP)?
MCP is an open protocol that allows AI models (like Claude) to access tools, databases, and external systems in a structured, safe way.
Traditional Approach (Pre-MCP)
Your App → Claude (via API call)
→ Manually format all context
→ Send everything in the prompt
→ No tool standardization
→ Security concerns
MCP Approach
Your Java Service (MCP Server)
↓
[Standardized MCP Protocol]
↓
Claude (MCP Client) → Access tools, resources, data
Key Concepts
MCP Server: Your Java application that exposes resources and tools via MCP
MCP Client: Claude (or any AI model) that consumes what your server provides
Resources: Documents, database records, files your server can expose
Tools: Functions the model can call to take action
Prompts: Templates that help the model understand your domain
Why MCPs Matter
1. Standardization
MCP is a standard protocol. Write once, works with any MCP-compatible AI model—Claude, future models, and more.
2. Security
Instead of embedding credentials in prompts, MCPs handle authentication behind the scenes.
3. Scalability
Resources and tools are accessed on-demand through your MCP server, not loaded upfront.
4. Flexibility
Your Java backend can implement business logic, caching, rate limiting, and validation before exposing data to Claude.
5. Separation of Concerns
AI logic (prompts, reasoning) stays separate from business logic (database queries, API calls).
Building Your First MCP Server in Java
What You'll Need
- Java 17+ - Modern Spring Boot or standalone app
- MCP SDK for Java - Anthropic's official library (or community implementations)
- JSON-RPC 2.0 - The underlying protocol MCPs use
- Your Business Logic - Database, APIs, services you want to expose
Installation
If using Maven:
<!-- Add to your pom.xml -->
<dependency>
<groupId>com.anthropic</groupId>
<artifactId>sdk</artifactId>
<version>0.1.0</version> <!-- Check for latest -->
</dependency>
Or with Gradle:
dependencies {
implementation 'com.anthropic:sdk:0.1.0'
}
Step 1: Define Your Resources
Let's build an MCP server that exposes a customer database:
import com.anthropic.sdk.mcp.*;
import java.util.*;
public class CustomerMcpServer {
// Simulated customer database
private final Map<String, Customer> customers = Map.ofEntries(
Map.entry("C001", new Customer("C001", "Alice Johnson", "alice@example.com", "Premium")),
Map.entry("C002", new Customer("C002", "Bob Smith", "bob@example.com", "Standard")),
Map.entry("C003", new Customer("C003", "Carol Davis", "carol@example.com", "Standard"))
);
// Define resources your MCP server exposes
public List<Resource> getResources() {
return List.of(
new Resource(
uri = "customer://list",
name = "List All Customers",
description = "Get a list of all customers in the system",
mimeType = "application/json"
),
new Resource(
uri = "customer://search",
name = "Search Customers",
description = "Search customers by name or email",
mimeType = "application/json"
)
);
}
}
// Customer data model
public class Customer {
public String id;
public String name;
public String email;
public String tier;
public Customer(String id, String name, String email, String tier) {
this.id = id;
this.name = name;
this.email = email;
this.tier = tier;
}
}
Step 2: Implement Resource Handlers
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
@Service
public class CustomerResourceHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final CustomerService customerService;
public String handleResource(String resourceUri, Map<String, String> params)
throws Exception {
switch (resourceUri) {
case "customer://list":
return handleListCustomers();
case "customer://search":
return handleSearchCustomers(params.get("query"));
default:
throw new IllegalArgumentException("Unknown resource: " + resourceUri);
}
}
private String handleListCustomers() throws Exception {
List<Customer> customers = customerService.findAll();
return objectMapper.writeValueAsString(Map.of(
"status", "success",
"data", customers,
"count", customers.size()
));
}
private String handleSearchCustomers(String query) throws Exception {
if (query == null || query.isEmpty()) {
throw new IllegalArgumentException("Query parameter required");
}
List<Customer> results = customerService.search(query);
return objectMapper.writeValueAsString(Map.of(
"status", "success",
"data", results,
"count", results.size()
));
}
}
Step 3: Define Tools
Tools allow Claude to take actions. Here's a tool to create a new customer:
import com.anthropic.sdk.mcp.Tool;
public class CustomerToolDefinitions {
public static List<Tool> getTools() {
return List.of(
new Tool(
name = "create_customer",
description = "Create a new customer in the system",
inputSchema = Map.of(
"type", "object",
"properties", Map.of(
"name", Map.of(
"type", "string",
"description", "Customer's full name"
),
"email", Map.of(
"type", "string",
"description", "Customer's email address"
),
"tier", Map.of(
"type", "string",
"enum", List.of("Standard", "Premium", "Enterprise"),
"description", "Customer subscription tier"
)
),
"required", List.of("name", "email", "tier")
)
),
new Tool(
name = "update_customer_tier",
description = "Upgrade or downgrade a customer's subscription tier",
inputSchema = Map.of(
"type", "object",
"properties", Map.of(
"customerId", Map.of(
"type", "string",
"description", "The customer ID"
),
"newTier", Map.of(
"type", "string",
"enum", List.of("Standard", "Premium", "Enterprise"),
"description", "The new tier"
)
),
"required", List.of("customerId", "newTier")
)
),
new Tool(
name = "get_customer_activity",
description = "Retrieve recent activity for a customer",
inputSchema = Map.of(
"type", "object",
"properties", Map.of(
"customerId", Map.of(
"type", "string",
"description", "The customer ID"
),
"days", Map.of(
"type", "integer",
"description", "Number of days to look back (default: 30)"
)
),
"required", List.of("customerId")
)
)
);
}
}
Step 4: Implement Tool Handlers
@Service
public class CustomerToolHandler {
private final CustomerService customerService;
private final ObjectMapper objectMapper = new ObjectMapper();
public String handleTool(String toolName, Map<String, Object> params)
throws Exception {
switch (toolName) {
case "create_customer":
return handleCreateCustomer(params);
case "update_customer_tier":
return handleUpdateCustomerTier(params);
case "get_customer_activity":
return handleGetCustomerActivity(params);
default:
throw new IllegalArgumentException("Unknown tool: " + toolName);
}
}
private String handleCreateCustomer(Map<String, Object> params)
throws Exception {
String name = (String) params.get("name");
String email = (String) params.get("email");
String tier = (String) params.get("tier");
// Validate
if (name == null || email == null || tier == null) {
throw new IllegalArgumentException("Missing required parameters");
}
if (!email.contains("@")) {
throw new IllegalArgumentException("Invalid email format");
}
// Create customer
Customer customer = customerService.create(name, email, tier);
return objectMapper.writeValueAsString(Map.of(
"status", "success",
"message", "Customer created successfully",
"data", customer
));
}
private String handleUpdateCustomerTier(Map<String, Object> params)
throws Exception {
String customerId = (String) params.get("customerId");
String newTier = (String) params.get("newTier");
Customer updated = customerService.updateTier(customerId, newTier);
return objectMapper.writeValueAsString(Map.of(
"status", "success",
"message", "Customer tier updated",
"data", updated
));
}
private String handleGetCustomerActivity(Map<String, Object> params)
throws Exception {
String customerId = (String) params.get("customerId");
Integer days = (Integer) params.getOrDefault("days", 30);
List<String> activity = customerService.getActivity(customerId, days);
return objectMapper.writeValueAsString(Map.of(
"status", "success",
"data", activity
));
}
}
Step 5: Wire Everything in Spring Boot
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
import com.anthropic.sdk.mcp.*;
@SpringBootApplication
public class CustomerMcpApplication {
public static void main(String[] args) {
SpringApplication.run(CustomerMcpApplication.class, args);
}
}
@RestController
@RequestMapping("/mcp")
public class McpController {
private final CustomerResourceHandler resourceHandler;
private final CustomerToolHandler toolHandler;
private final CustomerMcpServer mcpServer;
@PostMapping("/resource")
public String handleResource(@RequestBody ResourceRequest request)
throws Exception {
return resourceHandler.handleResource(
request.uri,
request.params
);
}
@PostMapping("/tool")
public String handleTool(@RequestBody ToolRequest request)
throws Exception {
return toolHandler.handleTool(
request.name,
request.params
);
}
@GetMapping("/info")
public McpServerInfo getInfo() {
return new McpServerInfo(
name = "Customer MCP Server",
version = "1.0.0",
resources = mcpServer.getResources(),
tools = CustomerToolDefinitions.getTools()
);
}
}
// Request models
class ResourceRequest {
public String uri;
public Map<String, String> params;
}
class ToolRequest {
public String name;
public Map<String, Object> params;
}
class McpServerInfo {
public String name;
public String version;
public List<Resource> resources;
public List<Tool> tools;
public McpServerInfo(String name, String version,
List<Resource> resources, List<Tool> tools) {
this.name = name;
this.version = version;
this.resources = resources;
this.tools = tools;
}
}
Testing Your MCP Server
1. Unit Tests for Individual Components
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import static org.junit.jupiter.api.Assertions.*;
public class CustomerToolHandlerTest {
@Mock
private CustomerService customerService;
private CustomerToolHandler handler;
@BeforeEach
public void setup() {
handler = new CustomerToolHandler(customerService);
}
@Test
public void testCreateCustomerSuccess() throws Exception {
// Arrange
Map<String, Object> params = Map.of(
"name", "John Doe",
"email", "john@example.com",
"tier", "Premium"
);
Customer expected = new Customer("C004", "John Doe", "john@example.com", "Premium");
when(customerService.create(anyString(), anyString(), anyString()))
.thenReturn(expected);
// Act
String result = handler.handleTool("create_customer", params);
// Assert
assertTrue(result.contains("success"));
assertTrue(result.contains("John Doe"));
}
@Test
public void testCreateCustomerInvalidEmail() {
// Arrange
Map<String, Object> params = Map.of(
"name", "Jane Doe",
"email", "invalid-email",
"tier", "Standard"
);
// Act & Assert
assertThrows(IllegalArgumentException.class, () ->
handler.handleTool("create_customer", params)
);
}
}
2. Integration Tests with Spring Boot Test
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
public class McpControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testGetMcpInfo() throws Exception {
mockMvc.perform(get("/mcp/info"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Customer MCP Server"))
.andExpect(jsonPath("$.version").value("1.0.0"))
.andExpect(jsonPath("$.resources").isArray())
.andExpect(jsonPath("$.tools").isArray());
}
@Test
public void testResourceEndpoint() throws Exception {
String requestBody = """
{
"uri": "customer://list",
"params": {}
}
""";
mockMvc.perform(post("/mcp/resource")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data").isArray());
}
@Test
public void testToolEndpoint() throws Exception {
String requestBody = """
{
"name": "create_customer",
"params": {
"name": "Test User",
"email": "test@example.com",
"tier": "Premium"
}
}
""";
mockMvc.perform(post("/mcp/tool")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"));
}
}
3. End-to-End Testing with Claude
# Step 1: Start your MCP server
mvn spring-boot:run
# Step 2: Configure Claude to use your MCP server
# In the Claude interface or via API, add your MCP server configuration
# Step 3: Test with prompts like:
# "Show me all customers and find ones from the Premium tier"
# "Create a new customer named 'Test User' with email 'test@example.com' as Premium"
# "Get the last 60 days of activity for customer C001"
4. Manual Testing Script
#!/bin/bash
# Test MCP server endpoints directly
echo "=== Getting MCP Server Info ==="
curl http://localhost:8080/mcp/info | jq .
echo "\n=== Listing Customers ==="
curl -X POST http://localhost:8080/mcp/resource \
-H "Content-Type: application/json" \
-d '{
"uri": "customer://list",
"params": {}
}' | jq .
echo "\n=== Searching Customers ==="
curl -X POST http://localhost:8080/mcp/resource \
-H "Content-Type: application/json" \
-d '{
"uri": "customer://search",
"params": {"query": "alice"}
}' | jq .
echo "\n=== Creating Customer ==="
curl -X POST http://localhost:8080/mcp/tool \
-H "Content-Type: application/json" \
-d '{
"name": "create_customer",
"params": {
"name": "New Customer",
"email": "new@example.com",
"tier": "Standard"
}
}' | jq .
echo "\n=== Updating Customer Tier ==="
curl -X POST http://localhost:8080/mcp/tool \
-H "Content-Type: application/json" \
-d '{
"name": "update_customer_tier",
"params": {
"customerId": "C001",
"newTier": "Enterprise"
}
}' | jq .
Best Practices for MCP Servers
1. Validation First
Always validate inputs before processing:
private void validateParams(Map<String, Object> params) {
// Type checking
if (!(params.get("email") instanceof String)) {
throw new IllegalArgumentException("Email must be a string");
}
// Format validation
String email = (String) params.get("email");
if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
// Business logic validation
if (customerService.exists(email)) {
throw new IllegalArgumentException("Customer already exists");
}
}
2. Comprehensive Error Handling
public String handleTool(String toolName, Map<String, Object> params) {
try {
validateParams(params);
return executeToolLogic(toolName, params);
} catch (ValidationException e) {
return errorResponse("VALIDATION_ERROR", e.getMessage());
} catch (NotFoundException e) {
return errorResponse("NOT_FOUND", e.getMessage());
} catch (Exception e) {
logger.error("Tool execution failed", e);
return errorResponse("INTERNAL_ERROR", "An unexpected error occurred");
}
}
private String errorResponse(String code, String message) {
return objectMapper.writeValueAsString(Map.of(
"status", "error",
"code", code,
"message", message
));
}
3. Rate Limiting & Security
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Bucket4j;
@Component
public class RateLimiter {
private final Bucket bucket;
public RateLimiter() {
Bandwidth limit = Bandwidth.simple(100, Duration.ofMinutes(1));
this.bucket = Bucket4j.builder()
.addLimit(limit)
.build();
}
public boolean allowRequest() {
return bucket.tryConsume(1);
}
}
@RestController
public class McpController {
@Autowired
private RateLimiter rateLimiter;
@PostMapping("/tool")
public String handleTool(@RequestBody ToolRequest request) {
if (!rateLimiter.allowRequest()) {
return errorResponse("RATE_LIMIT_EXCEEDED", "Too many requests");
}
// ... process tool
}
}
4. Logging & Monitoring
import org.springframework.boot.actuate.metrics.annotation.Timed;
@Service
public class CustomerToolHandler {
private static final Logger logger = LoggerFactory.getLogger(CustomerToolHandler.class);
@Timed(value = "mcp.tool.execution", description = "MCP tool execution time")
public String handleTool(String toolName, Map<String, Object> params) {
long startTime = System.currentTimeMillis();
logger.info("Executing MCP tool: {}", toolName);
logger.debug("Tool parameters: {}", params);
try {
String result = executeToolLogic(toolName, params);
long duration = System.currentTimeMillis() - startTime;
logger.info("Tool {} completed in {}ms", toolName, duration);
return result;
} catch (Exception e) {
logger.error("Tool {} failed: {}", toolName, e.getMessage(), e);
throw e;
}
}
}
Common Pitfalls
Pitfall 1: Not Validating Input Types
Problem: Assuming params are the expected type
Solution: Always validate and cast safely
Pitfall 2: Exposing Too Much Data
Problem: Returning entire database dumps
Solution: Paginate results and filter sensitive fields
Pitfall 3: No Rate Limiting
Problem: Claude can call tools rapidly, overwhelming your backend
Solution: Implement rate limiting and backpressure handling
Pitfall 4: Unclear Error Messages
Problem: Generic errors that don't help Claude understand what went wrong
Solution: Provide specific, actionable error messages
Deployment Checklist
- ✅ All input validation in place
- ✅ Rate limiting configured
- ✅ Comprehensive logging enabled
- ✅ Unit tests pass
- ✅ Integration tests pass
- ✅ End-to-end testing with Claude completed
- ✅ Security review done (no credential leaks)
- ✅ Performance testing completed
- ✅ Error handling covers all edge cases
- ✅ Documentation updated
- ✅ Monitoring and alerting configured
Conclusion
Model Context Protocol transforms how Java applications integrate with AI. By building a well-designed MCP server, you give Claude safe, structured access to your systems while maintaining full control over authentication, authorization, validation, and logging.
The pattern we've covered—defining resources and tools, implementing handlers, wrapping in Spring Boot, and testing thoroughly—scales to production systems handling millions of requests.
Your next step: Pick a system you want Claude to access, expose it via MCP, and watch as Claude becomes a far more capable assistant to your team.
Building MCPs? Share your use cases in the comments. What systems would benefit most from Claude integration in your organization?
Top comments (0)