DEV Community

Said Olano
Said Olano

Posted on

MCP Design Patterns: Building Scalable AI-Integrated Systems in Java

MCP Design Patterns: Building Scalable AI-Integrated Systems in Java

The Model Context Protocol (MCP) is transforming how AI models interact with external systems. However, building robust MCP servers isn't just about following the spec—it's about applying proven design patterns that ensure scalability, maintainability, and reliability. In this guide, we'll explore the most effective MCP design patterns and implement each with production-ready Java examples.

Why Design Patterns Matter in MCP

Before diving into specific patterns, understand why they're critical:

  • Consistency: Patterns create a common language for your team
  • Scalability: Well-designed patterns handle growth without refactoring
  • Testability: Patterns make code easier to unit test and mock
  • Maintenance: Future developers understand the architecture at a glance
  • Reliability: Proven patterns reduce bugs and edge cases

MCP servers sit at the intersection of AI and business logic—mistakes here cascade through your entire integration. Design patterns aren't optional; they're insurance.

Pattern 1: Resource Provider Pattern

The Resource Provider pattern abstracts data sources behind a clean interface, allowing your MCP server to expose any resource type (databases, APIs, files) without the client knowing implementation details.

When to use:

  • Exposing database tables to Claude
  • Sharing file systems or document stores
  • Integrating with external APIs
  • Real-time data feeds

Implementation:

package com.example.mcp.patterns.resources;

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

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

public interface ResourceProvider {
    /**
     * List all available resources
     */
    List<McpResource> listResources();

    /**
     * Get resource content with streaming support
     */
    ResourceContent getResourceContent(String uri);

    /**
     * Refresh resource metadata (for cache invalidation)
     */
    void refreshResource(String uri);
}

@Data
@AllArgsConstructor
@Builder
public class ResourceContent {
    private String text;
    private byte[] binary;
    private String mimeType;
    private boolean isStreaming;
}

package com.example.mcp.patterns.resources;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.sql.ResultSet;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Implementation: Database Resource Provider
 * Exposes SQL query results as MCP resources
 */
@Slf4j
@Component
public class DatabaseResourceProvider implements ResourceProvider {

    private final Map<String, ResourceMetadata> resourceCache = new ConcurrentHashMap<>();
    private final DatabaseService databaseService;

    public DatabaseResourceProvider(DatabaseService databaseService) {
        this.databaseService = databaseService;
        initializeResources();
    }

    private void initializeResources() {
        // Register database tables as resources
        List<String> tables = databaseService.getTables();

        tables.forEach(table -> {
            resourceCache.put(
                "db://table/" + table,
                ResourceMetadata.builder()
                    .uri("db://table/" + table)
                    .name(table)
                    .mimeType("application/json")
                    .description("Database table: " + table)
                    .lastModified(System.currentTimeMillis())
                    .build()
            );
        });
    }

    @Override
    public List<McpResource> listResources() {
        return resourceCache.values().stream()
            .map(metadata -> McpResource.builder()
                .uri(metadata.getUri())
                .name(metadata.getName())
                .mimeType(metadata.getMimeType())
                .description(metadata.getDescription())
                .size(estimateSize(metadata))
                .build())
            .toList();
    }

    @Override
    public ResourceContent getResourceContent(String uri) {
        if (!uri.startsWith("db://table/")) {
            throw new IllegalArgumentException("Invalid resource URI: " + uri);
        }

        String tableName = uri.replace("db://table/", "");
        log.info("Fetching table content: {}", tableName);

        try {
            String jsonContent = databaseService.tableToJson(tableName);

            return ResourceContent.builder()
                .text(jsonContent)
                .mimeType("application/json")
                .isStreaming(false)
                .build();
        } catch (Exception e) {
            log.error("Failed to fetch resource: {}", uri, e);
            throw new RuntimeException("Resource fetch failed: " + e.getMessage());
        }
    }

    @Override
    public void refreshResource(String uri) {
        log.info("Refreshing resource cache: {}", uri);
        resourceCache.remove(uri);
        initializeResources();
    }

    private long estimateSize(ResourceMetadata metadata) {
        return databaseService.getTableRowCount(metadata.getName()) * 1024; // rough estimate
    }
}

@Data
@Builder
class ResourceMetadata {
    private String uri;
    private String name;
    private String mimeType;
    private String description;
    private long lastModified;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Tool Executor Pattern

The Tool Executor pattern separates tool definitions from their implementations. This allows registration, validation, and execution of tools without tight coupling.

When to use:

  • Executing database queries
  • Calling external APIs
  • Processing data transformations
  • Running business logic

Implementation:

package com.example.mcp.patterns.tools;

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

@Data
@AllArgsConstructor
@Builder
public class ToolDefinition {
    private String name;
    private String description;
    private List<ToolParameter> parameters;
    private String category; // For organization
}

@Data
@AllArgsConstructor
@Builder
public class ToolParameter {
    private String name;
    private String type; // string, number, boolean, array, object
    private String description;
    private boolean required;
    private Object defaultValue;
    private List<String> enumValues; // For restricted choices
}

@Data
@AllArgsConstructor
@Builder
public class ToolResult {
    private boolean success;
    private Object data;
    private String error;
    private long executionTimeMs;
}

public interface ToolExecutor {
    ToolResult execute(Map<String, Object> arguments) throws ToolExecutionException;

    ToolDefinition getDefinition();
}

public class ToolExecutionException extends Exception {
    public ToolExecutionException(String message) {
        super(message);
    }

    public ToolExecutionException(String message, Throwable cause) {
        super(message, cause);
    }
}

package com.example.mcp.patterns.tools;

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

/**
 * Tool Registry: Manages all available tools
 * Handles registration, validation, and execution
 */
@Slf4j
@Component
public class ToolRegistry {

    private final Map<String, ToolExecutor> executors = new ConcurrentHashMap<>();
    private final ToolValidator validator = new ToolValidator();

    public ToolRegistry(List<ToolExecutor> toolExecutors) {
        // Auto-register beans
        toolExecutors.forEach(this::register);
    }

    public void register(ToolExecutor executor) {
        ToolDefinition def = executor.getDefinition();
        executors.put(def.getName(), executor);
        log.info("Registered tool: {} in category: {}", def.getName(), def.getCategory());
    }

    public List<ToolDefinition> listTools() {
        return executors.values().stream()
            .map(ToolExecutor::getDefinition)
            .toList();
    }

    public List<ToolDefinition> listToolsByCategory(String category) {
        return executors.values().stream()
            .map(ToolExecutor::getDefinition)
            .filter(def -> category.equals(def.getCategory()))
            .toList();
    }

    public ToolResult executeTool(String toolName, Map<String, Object> arguments) 
            throws ToolExecutionException {

        ToolExecutor executor = executors.get(toolName);
        if (executor == null) {
            throw new ToolExecutionException("Tool not found: " + toolName);
        }

        ToolDefinition definition = executor.getDefinition();

        // Validate arguments
        ValidationResult validation = validator.validate(arguments, definition);
        if (!validation.isValid()) {
            throw new ToolExecutionException("Invalid arguments: " + validation.getErrors());
        }

        log.info("Executing tool: {} with arguments: {}", toolName, arguments);

        long startTime = System.currentTimeMillis();
        try {
            ToolResult result = executor.execute(arguments);
            result.setExecutionTimeMs(System.currentTimeMillis() - startTime);
            return result;
        } catch (Exception e) {
            log.error("Tool execution failed: {}", toolName, e);
            throw new ToolExecutionException("Execution failed: " + e.getMessage(), e);
        }
    }
}

/**
 * Concrete Tool Implementation: Query Executor
 */
@Slf4j
@Component
public class QueryExecutorTool implements ToolExecutor {

    private final DatabaseService databaseService;

    public QueryExecutorTool(DatabaseService databaseService) {
        this.databaseService = databaseService;
    }

    @Override
    public ToolDefinition getDefinition() {
        return ToolDefinition.builder()
            .name("execute_query")
            .description("Execute SQL SELECT queries against the database")
            .category("database")
            .parameters(List.of(
                ToolParameter.builder()
                    .name("query")
                    .type("string")
                    .description("SQL SELECT query to execute")
                    .required(true)
                    .build(),
                ToolParameter.builder()
                    .name("limit")
                    .type("number")
                    .description("Maximum rows to return")
                    .required(false)
                    .defaultValue(100)
                    .build()
            ))
            .build();
    }

    @Override
    public ToolResult execute(Map<String, Object> arguments) throws ToolExecutionException {
        String query = (String) arguments.get("query");
        Integer limit = ((Number) arguments.getOrDefault("limit", 100)).intValue();

        // Validate query safety
        if (containsDangerousKeywords(query)) {
            throw new ToolExecutionException("Query contains forbidden keywords");
        }

        try {
            List<Map<String, Object>> results = databaseService.executeQuery(query, limit);

            return ToolResult.builder()
                .success(true)
                .data(Map.of(
                    "rowCount", results.size(),
                    "rows", results
                ))
                .build();
        } catch (Exception e) {
            return ToolResult.builder()
                .success(false)
                .error(e.getMessage())
                .build();
        }
    }

    private boolean containsDangerousKeywords(String query) {
        String upperQuery = query.toUpperCase();
        return upperQuery.contains("DROP") || 
               upperQuery.contains("DELETE") || 
               upperQuery.contains("UPDATE") ||
               upperQuery.contains("TRUNCATE");
    }
}

/**
 * Validation helper
 */
@Data
@AllArgsConstructor
class ValidationResult {
    private boolean valid;
    private List<String> errors;
}

class ToolValidator {
    public ValidationResult validate(Map<String, Object> arguments, ToolDefinition definition) {
        List<String> errors = new ArrayList<>();

        // Check required parameters
        for (ToolParameter param : definition.getParameters()) {
            if (param.isRequired() && !arguments.containsKey(param.getName())) {
                errors.add("Missing required parameter: " + param.getName());
            }
        }

        // Check parameter types
        for (Map.Entry<String, Object> entry : arguments.entrySet()) {
            ToolParameter param = definition.getParameters().stream()
                .filter(p -> p.getName().equals(entry.getKey()))
                .findFirst()
                .orElse(null);

            if (param != null && !isValidType(entry.getValue(), param.getType())) {
                errors.add("Invalid type for parameter: " + entry.getKey());
            }
        }

        return new ValidationResult(errors.isEmpty(), errors);
    }

    private boolean isValidType(Object value, String expectedType) {
        return switch (expectedType) {
            case "string" -> value instanceof String;
            case "number" -> value instanceof Number;
            case "boolean" -> value instanceof Boolean;
            case "array" -> value instanceof List;
            case "object" -> value instanceof Map;
            default -> true;
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Streaming Response Pattern

For large data transfers, streaming prevents memory exhaustion and provides better UX. This pattern handles chunked responses elegantly.

When to use:

  • Large file transfers
  • Real-time data streams
  • Long-running computations
  • API responses that exceed memory limits

Implementation:

package com.example.mcp.patterns.streaming;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.function.Consumer;

public interface StreamingResponse {
    void stream(Consumer<String> chunk) throws StreamingException;

    String getContentType();
    long getEstimatedSize();
}

public class StreamingException extends Exception {
    public StreamingException(String message) {
        super(message);
    }

    public StreamingException(String message, Throwable cause) {
        super(message, cause);
    }
}

@Data
@Builder
public class StreamingConfig {
    private int chunkSizeBytes;
    private long timeoutMs;
    private boolean gzip;
}

package com.example.mcp.patterns.streaming;

import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.util.function.Consumer;
import java.util.zip.GZIPOutputStream;

/**
 * Large Dataset Streaming Implementation
 */
@Slf4j
public class DatabaseStreamingResponse implements StreamingResponse {

    private final String query;
    private final DatabaseService databaseService;
    private final StreamingConfig config;

    public DatabaseStreamingResponse(String query, DatabaseService databaseService, 
                                     StreamingConfig config) {
        this.query = query;
        this.databaseService = databaseService;
        this.config = config;
    }

    @Override
    public void stream(Consumer<String> chunkConsumer) throws StreamingException {
        try (ResultSetIterator iterator = databaseService.streamQuery(query)) {

            StringBuilder buffer = new StringBuilder();
            int count = 0;

            while (iterator.hasNext()) {
                String jsonLine = iterator.next().toJson();
                buffer.append(jsonLine).append("\n");
                count++;

                // Flush when buffer reaches chunk size
                if (buffer.length() >= config.getChunkSizeBytes()) {
                    chunkConsumer.accept(buffer.toString());
                    buffer = new StringBuilder();
                    log.debug("Streamed {} records", count);
                }
            }

            // Flush remaining data
            if (buffer.length() > 0) {
                chunkConsumer.accept(buffer.toString());
            }

            log.info("Completed streaming {} records", count);
        } catch (Exception e) {
            throw new StreamingException("Streaming failed: " + e.getMessage(), e);
        }
    }

    @Override
    public String getContentType() {
        return "application/x-ndjson"; // Newline-delimited JSON
    }

    @Override
    public long getEstimatedSize() {
        return databaseService.estimateQuerySize(query);
    }
}

/**
 * File Streaming Implementation
 */
@Slf4j
public class FileStreamingResponse implements StreamingResponse {

    private final File file;
    private final StreamingConfig config;

    public FileStreamingResponse(File file, StreamingConfig config) {
        this.file = file;
        this.config = config;
    }

    @Override
    public void stream(Consumer<String> chunkConsumer) throws StreamingException {
        try (BufferedReader reader = new BufferedReader(
                new FileReader(file), 
                config.getChunkSizeBytes())) {

            String line;
            long bytesRead = 0;

            while ((line = reader.readLine()) != null) {
                chunkConsumer.accept(line + "\n");
                bytesRead += line.length();

                if (bytesRead > 100 * 1024 * 1024) { // Safety limit
                    throw new StreamingException("File too large to stream");
                }
            }

            log.info("Completed streaming file: {}", file.getName());
        } catch (IOException e) {
            throw new StreamingException("File streaming failed: " + e.getMessage(), e);
        }
    }

    @Override
    public String getContentType() {
        String filename = file.getName().toLowerCase();
        if (filename.endsWith(".json")) return "application/json";
        if (filename.endsWith(".csv")) return "text/csv";
        if (filename.endsWith(".txt")) return "text/plain";
        return "application/octet-stream";
    }

    @Override
    public long getEstimatedSize() {
        return file.length();
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Error Handling & Resilience Pattern

Proper error handling in MCP is critical—errors must be informative but not expose internal details.

When to use:

  • All tool executions
  • Resource access
  • Database operations
  • External API calls

Implementation:

package com.example.mcp.patterns.errors;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

@Data
@AllArgsConstructor
@Builder
public class McpError {
    private int code;
    private String message;
    private String category; // "database", "network", "validation", "internal"
    private Object details; // For debugging (removed in production)
    private String userMessage; // Safe message for the client
}

public enum ErrorCategory {
    VALIDATION(-1, "Validation Error"),
    NOT_FOUND(-2, "Resource Not Found"),
    PERMISSION(-3, "Permission Denied"),
    DATABASE(-4, "Database Error"),
    NETWORK(-5, "Network Error"),
    TIMEOUT(-6, "Operation Timeout"),
    INTERNAL(-32603, "Internal Error");

    public final int code;
    public final String message;

    ErrorCategory(int code, String message) {
        this.code = code;
        this.message = message;
    }
}

package com.example.mcp.patterns.errors;

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

/**
 * Error Handler with retry logic
 */
@Slf4j
@Component
public class ResilientMcpErrorHandler {

    private static final int MAX_RETRIES = 3;
    private static final int INITIAL_BACKOFF_MS = 100;

    public <T> T executeWithRetry(String operationName, 
                                  OperationCallback<T> operation) 
            throws McpException {

        int attempt = 0;
        long backoffMs = INITIAL_BACKOFF_MS;

        while (attempt < MAX_RETRIES) {
            try {
                log.debug("Executing {} (attempt {})", operationName, attempt + 1);
                return operation.execute();
            } catch (TemporaryException e) {
                attempt++;

                if (attempt >= MAX_RETRIES) {
                    throw new McpException(
                        ErrorCategory.INTERNAL,
                        "Operation failed after " + MAX_RETRIES + " retries",
                        e.getMessage()
                    );
                }

                log.warn("Attempt {} failed, retrying in {}ms: {}", 
                    attempt, backoffMs, e.getMessage());

                try {
                    Thread.sleep(backoffMs);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new McpException(
                        ErrorCategory.INTERNAL,
                        "Operation interrupted",
                        ie.getMessage()
                    );
                }

                backoffMs *= 2; // Exponential backoff
            } catch (PermanentException e) {
                log.error("Permanent failure in {}: {}", operationName, e.getMessage());
                throw new McpException(
                    ErrorCategory.INTERNAL,
                    "Operation failed permanently",
                    e.getMessage()
                );
            } catch (TimeoutException e) {
                throw new McpException(
                    ErrorCategory.TIMEOUT,
                    "Operation timeout",
                    operationName + " exceeded timeout"
                );
            }
        }

        throw new McpException(
            ErrorCategory.INTERNAL,
            "Unexpected error",
            "Max retries exceeded for " + operationName
        );
    }

    public McpError handleException(Exception e, boolean includeDetails) {
        log.error("Error occurred", e);

        ErrorCategory category = determineCategory(e);

        return McpError.builder()
            .code(category.code)
            .message(category.message)
            .category(category.name())
            .userMessage(getUserFriendlyMessage(e))
            .details(includeDetails ? e.getMessage() : null)
            .build();
    }

    private ErrorCategory determineCategory(Exception e) {
        if (e instanceof ValidationException) return ErrorCategory.VALIDATION;
        if (e instanceof ResourceNotFoundException) return ErrorCategory.NOT_FOUND;
        if (e instanceof PermissionException) return ErrorCategory.PERMISSION;
        if (e instanceof DatabaseException) return ErrorCategory.DATABASE;
        if (e instanceof NetworkException) return ErrorCategory.NETWORK;
        if (e instanceof TimeoutException) return ErrorCategory.TIMEOUT;
        return ErrorCategory.INTERNAL;
    }

    private String getUserFriendlyMessage(Exception e) {
        if (e instanceof ValidationException) 
            return "The request was invalid. Please check your parameters.";
        if (e instanceof ResourceNotFoundException) 
            return "The requested resource was not found.";
        if (e instanceof PermissionException) 
            return "You don't have permission to perform this operation.";
        return "An error occurred. Please try again later.";
    }
}

@FunctionalInterface
interface OperationCallback<T> {
    T execute() throws Exception;
}

// Custom exceptions
class McpException extends RuntimeException {
    private final ErrorCategory category;
    private final String userMessage;

    public McpException(ErrorCategory category, String message, String userMessage) {
        super(message);
        this.category = category;
        this.userMessage = userMessage;
    }
}

class TemporaryException extends Exception {}
class PermanentException extends Exception {}
class ValidationException extends RuntimeException {}
class ResourceNotFoundException extends RuntimeException {}
class PermissionException extends RuntimeException {}
class DatabaseException extends RuntimeException {}
class NetworkException extends RuntimeException {}
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Caching Pattern

Reduce latency and database load with intelligent caching.

When to use:

  • Resource listings that change infrequently
  • Tool definitions
  • Configuration data
  • Expensive query results

Implementation:

package com.example.mcp.patterns.caching;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
import java.util.Optional;

@Data
@AllArgsConstructor
@Builder
public class CacheEntry<T> {
    private T value;
    private Instant createdAt;
    private Instant expiresAt;
    private int accessCount;

    public boolean isExpired() {
        return Instant.now().isAfter(expiresAt);
    }

    public boolean isAlmostExpired() {
        long msUntilExpiry = expiresAt.toEpochMilli() - System.currentTimeMillis();
        return msUntilExpiry < 60000; // Refresh if within 1 minute of expiry
    }
}

package com.example.mcp.patterns.caching;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Intelligent caching with TTL and refresh strategy
 */
@Slf4j
@Component
public class McpResourceCache {

    private final Map<String, CacheEntry<?>> cache = new ConcurrentHashMap<>();
    private final Map<String, CacheConfig> configMap = new ConcurrentHashMap<>();

    public <T> void put(String key, T value, Duration ttl) {
        CacheEntry<T> entry = CacheEntry.<T>builder()
            .value(value)
            .createdAt(Instant.now())
            .expiresAt(Instant.now().plus(ttl))
            .accessCount(0)
            .build();

        cache.put(key, entry);
        log.debug("Cached key: {} with TTL: {}s", key, ttl.getSeconds());
    }

    @SuppressWarnings("unchecked")
    public <T> Optional<T> get(String key) {
        CacheEntry<?> entry = cache.get(key);

        if (entry == null) {
            log.debug("Cache miss: {}", key);
            return Optional.empty();
        }

        if (entry.isExpired()) {
            log.debug("Cache expired: {}", key);
            cache.remove(key);
            return Optional.empty();
        }

        if (entry.isAlmostExpired()) {
            log.debug("Cache almost expired, should refresh: {}", key);
        }

        entry.setAccessCount(entry.getAccessCount() + 1);
        return Optional.of((T) entry.getValue());
    }

    public void invalidate(String key) {
        cache.remove(key);
        log.debug("Invalidated cache: {}", key);
    }

    public void invalidatePattern(String pattern) {
        cache.keySet()
            .stream()
            .filter(key -> key.matches(pattern))
            .forEach(this::invalidate);
    }

    public void clear() {
        cache.clear();
        log.info("Cleared entire cache");
    }

    public CacheStats getStats() {
        return CacheStats.builder()
            .size(cache.size())
            .totalHits(cache.values().stream()
                .mapToLong(CacheEntry::getAccessCount)
                .sum())
            .build();
    }
}

@Data
@Builder
class CacheStats {
    private int size;
    private long totalHits;
}

@Data
@Builder
class CacheConfig {
    private Duration ttl;
    private boolean autoRefresh;
    private int maxSize;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Pipeline Pattern

Compose multiple operations into a reusable pipeline.

When to use:

  • Multi-step data transformations
  • Chaining tool results
  • Building complex workflows
  • Data validation chains

Implementation:

package com.example.mcp.patterns.pipeline;

import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;

public interface PipelineStage<I, O> {
    O execute(I input) throws PipelineException;

    String getName();
}

public class PipelineException extends Exception {
    private final String stageName;

    public PipelineException(String stageName, String message) {
        super(message);
        this.stageName = stageName;
    }
}

@Data
@AllArgsConstructor
public class PipelineResult<T> {
    private T output;
    private List<StageMetrics> metrics;
    private boolean success;
}

@Data
@AllArgsConstructor
public class StageMetrics {
    private String stageName;
    private long executionTimeMs;
    private String status;
}

package com.example.mcp.patterns.pipeline;

import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;

/**
 * Pipeline Builder: Chains stages together
 */
@Slf4j
public class Pipeline<T> {

    private final List<PipelineStage<?, ?>> stages = new ArrayList<>();
    private final List<StageMetrics> metrics = new ArrayList<>();

    public <I, O> Pipeline<O> addStage(PipelineStage<I, O> stage) {
        stages.add(stage);
        return (Pipeline<O>) this;
    }

    @SuppressWarnings("unchecked")
    public <I> PipelineResult<T> execute(I input) {
        Object current = input;
        metrics.clear();

        for (int i = 0; i < stages.size(); i++) {
            PipelineStage<?, ?> stage = stages.get(i);
            long startTime = System.currentTimeMillis();

            try {
                log.debug("Executing stage: {}", stage.getName());
                current = ((PipelineStage<Object, Object>) stage).execute(current);

                long executionTime = System.currentTimeMillis() - startTime;
                metrics.add(new StageMetrics(stage.getName(), executionTime, "SUCCESS"));
                log.debug("Stage {} completed in {}ms", stage.getName(), executionTime);

            } catch (Exception e) {
                log.error("Pipeline failed at stage: {}", stage.getName(), e);
                metrics.add(new StageMetrics(stage.getName(), 
                    System.currentTimeMillis() - startTime, "FAILED"));

                return new PipelineResult<>(null, metrics, false);
            }
        }

        return new PipelineResult<>((T) current, metrics, true);
    }
}

// Usage Example
@Slf4j
class DataProcessingPipeline {

    public static void main(String[] args) throws PipelineException {
        Pipeline<String> pipeline = new Pipeline<>();

        pipeline
            .addStage(new FetchDataStage())
            .addStage(new FilterDataStage())
            .addStage(new TransformDataStage())
            .addStage(new ValidateDataStage())
            .addStage(new FormatOutputStage());

        PipelineResult<String> result = pipeline.execute("SELECT * FROM users");

        if (result.isSuccess()) {
            log.info("Pipeline completed successfully");
            result.getMetrics().forEach(m -> 
                log.info("  {} took {}ms", m.getStageName(), m.getExecutionTimeMs())
            );
        }
    }
}

// Concrete stage implementations
class FetchDataStage implements PipelineStage<String, List<Map<String, Object>>> {

    @Override
    public List<Map<String, Object>> execute(String query) throws PipelineException {
        // Fetch data from database
        return new ArrayList<>();
    }

    @Override
    public String getName() {
        return "FetchData";
    }
}

class FilterDataStage implements PipelineStage<List<Map<String, Object>>, 
                                               List<Map<String, Object>>> {

    @Override
    public List<Map<String, Object>> execute(List<Map<String, Object>> data) {
        // Filter data based on criteria
        return data.stream()
            .filter(row -> (boolean) row.getOrDefault("active", false))
            .toList();
    }

    @Override
    public String getName() {
        return "FilterData";
    }
}

// Additional stages omitted for brevity...
Enter fullscreen mode Exit fullscreen mode

Pattern 7: Context Preservation Pattern

Maintain request context across tool calls and maintain coherence in multi-turn conversations.

When to use:

  • Multi-step operations
  • Preserving user context across tool calls
  • Audit trail requirements
  • Transaction-like behavior
package com.example.mcp.patterns.context;

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

@Data
@AllArgsConstructor
@Builder
public class ExecutionContext {
    private String requestId; // Unique request identifier
    private String userId;
    private Map<String, Object> state; // Shared state across tools
    private long createdAt;
    private long expiresAt;

    public boolean isExpired() {
        return System.currentTimeMillis() > expiresAt;
    }

    public void putState(String key, Object value) {
        state.put(key, value);
    }

    public Object getState(String key) {
        return state.get(key);
    }
}

package com.example.mcp.patterns.context;

import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Manages execution contexts for each request
 */
@Component
public class ContextManager {

    private final Map<String, ExecutionContext> contexts = new ConcurrentHashMap<>();
    private static final long CONTEXT_TTL_MS = 30 * 60 * 1000; // 30 minutes

    public ExecutionContext createContext(String userId) {
        String requestId = UUID.randomUUID().toString();
        long now = System.currentTimeMillis();

        ExecutionContext context = ExecutionContext.builder()
            .requestId(requestId)
            .userId(userId)
            .state(new HashMap<>())
            .createdAt(now)
            .expiresAt(now + CONTEXT_TTL_MS)
            .build();

        contexts.put(requestId, context);
        return context;
    }

    public ExecutionContext getContext(String requestId) {
        ExecutionContext context = contexts.get(requestId);

        if (context != null && context.isExpired()) {
            contexts.remove(requestId);
            throw new ContextExpiredException("Context expired: " + requestId);
        }

        return context;
    }

    public void cleanup(String requestId) {
        contexts.remove(requestId);
    }
}

class ContextExpiredException extends RuntimeException {
    public ContextExpiredException(String message) {
        super(message);
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices Summary

  1. Separation of Concerns: Keep protocol logic separate from business logic
  2. Type Safety: Leverage Java's type system for validation
  3. Async Processing: Use CompletableFuture for long-running operations
  4. Observability: Log extensively with structured logging
  5. Testing: Mock external dependencies; test patterns independently
  6. Documentation: Clearly document tool definitions and resource formats
  7. Monitoring: Track tool execution times and failure rates

Conclusion

These seven patterns form the foundation of scalable, maintainable MCP servers. Start with the patterns most relevant to your use case—you don't need all of them from day one. Refactor incrementally as your system grows.

The key is consistency: once your team adopts these patterns, onboarding new engineers and maintaining the codebase becomes exponentially easier.

Ready to build production-grade MCP integrations? Start with the Resource Provider and Tool Executor patterns, add error handling, and iterate from there.


Share your own MCP patterns! I'd love to hear how you're structuring MCP servers in production. Connect with me on LinkedIn to discuss architecture, Java best practices, or LLM integration challenges.

MCP #Java #SpringBoot #DesignPatterns #SoftwareArchitecture #AI #Integration #LLM

Top comments (0)