DEV Community

Said Olano
Said Olano

Posted on

How to Create a Java MCP Server: A Complete Guide

How to Create a Java MCP Server: A Complete Guide

The Model Context Protocol (MCP) is revolutionizing how AI assistants integrate with external systems. If you're a Java developer looking to build MCP servers, this comprehensive guide walks you through everything you need to know—from foundational concepts to production-ready implementations.

What is MCP and Why Should Java Developers Care?

The Model Context Protocol is an open standard that enables seamless communication between AI models and external tools, databases, and services. Instead of struggling with custom API integrations or maintaining brittle LLM connectors, MCP provides a standardized, bidirectional communication protocol.

For Java developers, MCP opens powerful possibilities:

  • Expose existing Java services to AI models without rewriting infrastructure
  • Build intelligent tooling that combines your domain expertise with LLM capabilities
  • Maintain type safety and performance while connecting to AI systems
  • Leverage Spring Boot and existing ecosystems for rapid MCP server development

An MCP server acts as a bridge—it listens for requests from MCP clients (like Claude), executes Java logic, and returns results. Think of it as creating a specialized API endpoint that AI models can invoke with context-awareness.

Architecture Overview: How MCP Servers Work

Before diving into code, let's understand the architecture:

┌─────────────────┐
│   MCP Client    │ (e.g., Claude desktop app)
│  (stdio/SSE)    │
└────────┬────────┘
         │
    (JSON-RPC 2.0)
         │
┌────────▼────────┐
│  Java MCP       │
│   Server        │
│  (Spring Boot)  │
└────────┬────────┘
         │
    (Service calls)
         │
┌────────▼────────┐
│ Business Logic  │
│ Databases       │
│ External APIs   │
└─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key concepts:

  • Protocol: JSON-RPC 2.0 over stdio or Server-Sent Events (SSE)
  • Resources: Data sources the MCP server exposes (database tables, APIs, files)
  • Tools: Functions the server can execute for clients
  • Prompts: Pre-defined AI prompts the server can provide
  • Sampling: Optional sampling capabilities for advanced use cases

Setting Up Your Java MCP Server Project

Prerequisites

# Required
- Java 17+ (we'll use Java 21 LTS for this guide)
- Maven 3.8+ or Gradle 8.0+
- Spring Boot 3.2+
- An IDE (IntelliJ IDEA or VS Code with Java extensions)

# Optional but recommended
- Docker for containerization
- OCI CLI or AWS CLI for cloud deployment
Enter fullscreen mode Exit fullscreen mode

Project Structure

Create a new Spring Boot project. Here's the recommended structure:

mcp-java-server/
├── src/main/java/com/example/mcp/
│   ├── McpServerApplication.java
│   ├── config/
│   │   ├── McpConfig.java
│   │   └── StdioTransportConfig.java
│   ├── protocol/
│   │   ├── JsonRpcRequest.java
│   │   ├── JsonRpcResponse.java
│   │   └── McpMessageHandler.java
│   ├── resources/
│   │   ├── ResourceProvider.java
│   │   └── DatabaseResourceProvider.java
│   ├── tools/
│   │   ├── ToolRegistry.java
│   │   └── QueryTool.java
│   └── handlers/
│       ├── InitializeHandler.java
│       ├── ResourceHandler.java
│       └── CallToolHandler.java
├── src/main/resources/
│   ├── application.yml
│   └── logback.xml
├── pom.xml
└── docker/
    └── Dockerfile
Enter fullscreen mode Exit fullscreen mode

Maven POM Configuration

<?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 
         http://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.3.0</version>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>mcp-java-server</artifactId>
    <version>1.0.0</version>
    <name>MCP Java Server</name>

    <properties>
        <java.version>21</java.version>
        <jackson.version>2.17.0</jackson.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Core -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <!-- JSON Processing -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>${jackson.version}</version>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
            <version>${jackson.version}</version>
        </dependency>

        <!-- Logging -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </dependency>

        <!-- Testing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- Optional: Database Support -->
        <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>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
Enter fullscreen mode Exit fullscreen mode

Building Your First MCP Server

1. Core Data Models for JSON-RPC

package com.example.mcp.protocol;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class JsonRpcRequest {
    private String jsonrpc = "2.0";
    private String method;
    private Map<String, Object> params;
    private String id;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class JsonRpcResponse {
    private String jsonrpc = "2.0";
    private Object result;
    private JsonRpcError error;
    private String id;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class JsonRpcError {
    private int code;
    private String message;
    private Object data;
}
Enter fullscreen mode Exit fullscreen mode

2. Protocol Handler

package com.example.mcp.protocol;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class McpMessageHandler {

    private final ObjectMapper objectMapper;
    private final McpRequestDispatcher dispatcher;

    public String handleMessage(String rawMessage) {
        try {
            JsonRpcRequest request = objectMapper.readValue(
                rawMessage, 
                JsonRpcRequest.class
            );

            log.info("Received MCP request: method={}, id={}", 
                request.getMethod(), 
                request.getId()
            );

            JsonRpcResponse response = dispatcher.dispatch(request);

            return objectMapper.writeValueAsString(response);
        } catch (Exception e) {
            log.error("Error processing MCP message", e);
            return buildErrorResponse(e);
        }
    }

    private String buildErrorResponse(Exception e) {
        try {
            JsonRpcResponse error = JsonRpcResponse.builder()
                .error(JsonRpcError.builder()
                    .code(-32603)
                    .message("Internal error: " + e.getMessage())
                    .build())
                .build();
            return objectMapper.writeValueAsString(error);
        } catch (Exception ex) {
            return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603," +
                   "\"message\":\"Serialization error\"}}";
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Resource Provider Implementation

package com.example.mcp.resources;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;

@Data
@AllArgsConstructor
@Builder
public class McpResource {
    private String uri;
    private String name;
    private String mimeType;
    private String description;
}

public interface ResourceProvider {
    List<McpResource> listResources();
    String getResourceContent(String uri);
}

package com.example.mcp.resources;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
public class InMemoryResourceProvider implements ResourceProvider {

    private final Map<String, String> resources = new ConcurrentHashMap<>();

    public InMemoryResourceProvider() {
        // Initialize with sample resources
        resources.put("database://users", 
            "{\n  \"tables\": [\"users\", \"orders\", \"products\"]\n}");
        resources.put("database://analytics", 
            "{\n  \"metrics\": [\"daily_revenue\", \"user_growth\"]\n}");
    }

    @Override
    public List<McpResource> listResources() {
        List<McpResource> resourceList = new ArrayList<>();

        resources.forEach((uri, content) -> {
            resourceList.add(McpResource.builder()
                .uri(uri)
                .name(extractName(uri))
                .mimeType("application/json")
                .description("Resource: " + uri)
                .build());
        });

        return resourceList;
    }

    @Override
    public String getResourceContent(String uri) {
        log.info("Retrieving resource: {}", uri);
        String content = resources.get(uri);

        if (content == null) {
            throw new IllegalArgumentException("Resource not found: " + uri);
        }

        return content;
    }

    private String extractName(String uri) {
        return uri.substring(uri.lastIndexOf('/') + 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Tool Registry and Execution

package com.example.mcp.tools;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.Map;

@Data
@AllArgsConstructor
@Builder
public class McpTool {
    private String name;
    private String description;
    private Map<String, ToolParameter> inputSchema;
}

@Data
@AllArgsConstructor
@Builder
public class ToolParameter {
    private String type;
    private String description;
    private boolean required;
}

package com.example.mcp.tools;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.*;

@Slf4j
@Component
public class ToolRegistry {

    private final Map<String, ToolExecutor> executors = new HashMap<>();

    public ToolRegistry() {
        registerDefaultTools();
    }

    private void registerDefaultTools() {
        // Register query tool
        executors.put("query_database", new QueryDatabaseTool());

        // Register analytics tool
        executors.put("get_analytics", new AnalyticsTool());

        // Register date formatting tool
        executors.put("format_date", new DateFormattingTool());
    }

    public List<McpTool> getAvailableTools() {
        return List.of(
            McpTool.builder()
                .name("query_database")
                .description("Execute SQL queries against the database")
                .inputSchema(Map.of(
                    "query", ToolParameter.builder()
                        .type("string")
                        .description("SQL query to execute")
                        .required(true)
                        .build()
                ))
                .build(),
            McpTool.builder()
                .name("get_analytics")
                .description("Fetch analytics metrics")
                .inputSchema(Map.of(
                    "metric", ToolParameter.builder()
                        .type("string")
                        .description("Metric name")
                        .required(true)
                        .build()
                ))
                .build()
        );
    }

    public Object executeTool(String toolName, Map<String, Object> input) {
        ToolExecutor executor = executors.get(toolName);

        if (executor == null) {
            throw new IllegalArgumentException("Tool not found: " + toolName);
        }

        log.info("Executing tool: {} with input: {}", toolName, input);
        return executor.execute(input);
    }
}

public interface ToolExecutor {
    Object execute(Map<String, Object> input);
}

@Slf4j
class QueryDatabaseTool implements ToolExecutor {
    @Override
    public Object execute(Map<String, Object> input) {
        String query = (String) input.get("query");
        log.info("Executing database query: {}", query);

        // Simulate query execution
        return Map.of(
            "rows", 5,
            "query", query,
            "status", "success"
        );
    }
}

@Slf4j
class AnalyticsTool implements ToolExecutor {
    @Override
    public Object execute(Map<String, Object> input) {
        String metric = (String) input.get("metric");
        log.info("Fetching analytics for: {}", metric);

        return Map.of(
            "metric", metric,
            "value", Math.random() * 10000,
            "unit", "USD"
        );
    }
}

@Slf4j
class DateFormattingTool implements ToolExecutor {
    @Override
    public Object execute(Map<String, Object> input) {
        String date = (String) input.get("date");
        return Map.of(
            "original", date,
            "formatted", "2026-01-15T10:30:00Z"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Request Dispatcher

package com.example.mcp.protocol;

import com.example.mcp.resources.ResourceProvider;
import com.example.mcp.tools.ToolRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class McpRequestDispatcher {

    private final ResourceProvider resourceProvider;
    private final ToolRegistry toolRegistry;

    private boolean initialized = false;

    public JsonRpcResponse dispatch(JsonRpcRequest request) {
        try {
            switch (request.getMethod()) {
                case "initialize":
                    return handleInitialize(request);
                case "resources/list":
                    return handleListResources(request);
                case "resources/read":
                    return handleReadResource(request);
                case "tools/list":
                    return handleListTools(request);
                case "tools/call":
                    return handleCallTool(request);
                default:
                    return buildErrorResponse(request.getId(), -32601, 
                        "Method not found: " + request.getMethod());
            }
        } catch (Exception e) {
            log.error("Error dispatching request", e);
            return buildErrorResponse(request.getId(), -32603, 
                "Internal error: " + e.getMessage());
        }
    }

    private JsonRpcResponse handleInitialize(JsonRpcRequest request) {
        initialized = true;

        Map<String, Object> result = new HashMap<>();
        result.put("protocolVersion", "2024-11-05");
        result.put("implementation", "java-mcp-server");
        result.put("serverVersion", "1.0.0");

        Map<String, Object> capabilities = new HashMap<>();
        capabilities.put("resources", Map.of("subscribe", false));
        capabilities.put("tools", new HashMap<>());
        result.put("capabilities", capabilities);

        return JsonRpcResponse.builder()
            .result(result)
            .id(request.getId())
            .build();
    }

    private JsonRpcResponse handleListResources(JsonRpcRequest request) {
        Map<String, Object> result = new HashMap<>();
        result.put("resources", resourceProvider.listResources());

        return JsonRpcResponse.builder()
            .result(result)
            .id(request.getId())
            .build();
    }

    private JsonRpcResponse handleReadResource(JsonRpcRequest request) {
        String uri = (String) request.getParams().get("uri");
        String content = resourceProvider.getResourceContent(uri);

        Map<String, Object> result = new HashMap<>();
        result.put("contents", new Object[]{
            Map.of(
                "uri", uri,
                "mimeType", "application/json",
                "text", content
            )
        });

        return JsonRpcResponse.builder()
            .result(result)
            .id(request.getId())
            .build();
    }

    private JsonRpcResponse handleListTools(JsonRpcRequest request) {
        Map<String, Object> result = new HashMap<>();
        result.put("tools", toolRegistry.getAvailableTools());

        return JsonRpcResponse.builder()
            .result(result)
            .id(request.getId())
            .build();
    }

    private JsonRpcResponse handleCallTool(JsonRpcRequest request) {
        String toolName = (String) request.getParams().get("name");
        @SuppressWarnings("unchecked")
        Map<String, Object> arguments = (Map<String, Object>) request.getParams().get("arguments");

        Object result = toolRegistry.executeTool(toolName, arguments);

        Map<String, Object> content = new HashMap<>();
        content.put("type", "text");
        content.put("text", result.toString());

        Map<String, Object> responseResult = new HashMap<>();
        responseResult.put("content", new Object[]{content});

        return JsonRpcResponse.builder()
            .result(responseResult)
            .id(request.getId())
            .build();
    }

    private JsonRpcResponse buildErrorResponse(String id, int code, String message) {
        return JsonRpcResponse.builder()
            .error(JsonRpcError.builder()
                .code(code)
                .message(message)
                .build())
            .id(id)
            .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

6. Stdio Transport Implementation

package com.example.mcp.transport;

import com.example.mcp.protocol.McpMessageHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;

@Slf4j
@Component
@RequiredArgsConstructor
public class StdioTransport {

    private final McpMessageHandler messageHandler;
    private PrintWriter out;
    private BufferedReader in;

    public void start() {
        out = new PrintWriter(
            new OutputStreamWriter(System.out, StandardCharsets.UTF_8), 
            true
        );
        in = new BufferedReader(
            new InputStreamReader(System.in, StandardCharsets.UTF_8)
        );

        log.info("Stdio transport started");
        startListening();
    }

    private void startListening() {
        Thread listenerThread = new Thread(() -> {
            try {
                String line;
                while ((line = in.readLine()) != null) {
                    if (line.isEmpty()) {
                        continue;
                    }

                    log.debug("Received: {}", line);
                    String response = messageHandler.handleMessage(line);

                    if (response != null && !response.isEmpty()) {
                        out.println(response);
                        log.debug("Sent: {}", response);
                    }
                }
            } catch (Exception e) {
                log.error("Error in stdio listener", e);
            }
        });

        listenerThread.setDaemon(true);
        listenerThread.start();
    }

    public void send(String message) {
        out.println(message);
        log.debug("Sent message: {}", message);
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Application Bootstrap

package com.example.mcp;

import com.example.mcp.transport.StdioTransport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@Slf4j
@SpringBootApplication
@RequiredArgsConstructor
public class McpServerApplication implements CommandLineRunner {

    private final StdioTransport stdioTransport;

    public static void main(String[] args) {
        SpringApplication.run(McpServerApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        log.info("Starting Java MCP Server...");
        stdioTransport.start();

        // Keep the application running
        Thread.currentThread().join();
    }
}
Enter fullscreen mode Exit fullscreen mode

Deployment and Testing

Building and Running

# Build with Maven
mvn clean package

# Run locally
java -jar target/mcp-java-server-1.0.0.jar

# Or with Spring Boot Maven plugin
mvn spring-boot:run
Enter fullscreen mode Exit fullscreen mode

Testing with MCP Inspector

# Install MCP Inspector
npm install -g @modelcontextprotocol/inspector

# Run your server with inspector
mcp-inspector node /path/to/your/server.js

# For Java:
mcp-inspector java -jar target/mcp-java-server-1.0.0.jar
Enter fullscreen mode Exit fullscreen mode

Docker Deployment

FROM eclipse-temurin:21-jdk-jammy

WORKDIR /app

COPY target/mcp-java-server-1.0.0.jar .

ENTRYPOINT ["java", "-jar", "mcp-java-server-1.0.0.jar"]
Enter fullscreen mode Exit fullscreen mode

Build and run:

docker build -t mcp-java-server .
docker run -it mcp-java-server
Enter fullscreen mode Exit fullscreen mode

Production Considerations

Error Handling and Resilience

@Slf4j
@Component
public class ResilientMcpHandler {

    public JsonRpcResponse executeWithRetry(
        String method, 
        Map<String, Object> params) {

        int maxRetries = 3;
        int retryDelay = 1000;

        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                return execute(method, params);
            } catch (TemporaryException e) {
                if (attempt == maxRetries) throw e;

                log.warn("Attempt {} failed, retrying in {}ms", 
                    attempt, retryDelay);
                Thread.sleep(retryDelay);
                retryDelay *= 2;
            }
        }

        throw new RuntimeException("Max retries exceeded");
    }
}
Enter fullscreen mode Exit fullscreen mode

Monitoring and Logging

Configure structured logging in logback.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/mcp-server.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/mcp-server-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>10MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>
Enter fullscreen mode Exit fullscreen mode

Next Steps and Best Practices

  1. Authentication: Implement OAuth2 or API key validation for production servers
  2. Rate Limiting: Add request rate limiting to prevent abuse
  3. Caching: Implement caching for expensive resource operations
  4. Monitoring: Use Spring Boot Actuator for health checks and metrics
  5. Testing: Write comprehensive unit and integration tests for your tools
  6. Documentation: Generate OpenAPI/Swagger docs for your MCP server
  7. Versioning: Plan for API versioning as your MCP server evolves

Conclusion

Building Java MCP servers gives you the power to connect enterprise Java systems with AI capabilities while maintaining type safety, performance, and reliability. By following this guide, you've learned how to build a production-ready MCP server from scratch.

The beauty of this approach is that you're not tied to Python or JavaScript—you can leverage your existing Java expertise, frameworks, and libraries to create intelligent AI-integrated systems.

Start small: Build a simple MCP server exposing one resource or tool, test it thoroughly, and then expand based on your use cases.


Share this guide if you found it helpful! Connect with me on LinkedIn to discuss MCP implementations, Java architecture, or LLM integration patterns.

MCP #Java #SpringBoot #AI #LLM #Integration #Microservices #OpenStandards

Top comments (0)