DEV Community

Said Olano
Said Olano

Posted on

Building Model Context Protocol (MCP) Servers in Java: A Complete Guide

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
Enter fullscreen mode Exit fullscreen mode

MCP Approach

Your Java Service (MCP Server)
      ↓
[Standardized MCP Protocol]
      ↓
Claude (MCP Client) → Access tools, resources, data
Enter fullscreen mode Exit fullscreen mode

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

  1. Java 17+ - Modern Spring Boot or standalone app
  2. MCP SDK for Java - Anthropic's official library (or community implementations)
  3. JSON-RPC 2.0 - The underlying protocol MCPs use
  4. 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>
Enter fullscreen mode Exit fullscreen mode

Or with Gradle:

dependencies {
    implementation 'com.anthropic:sdk:0.1.0'
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
                )
            )
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    ));
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)