Skip to content
yisusvii Blog
Go back

Building Custom MCP Servers for Legacy Systems

Suggest Changes

Legacy systems represent decades of business logic, institutional knowledge, and critical operations. While these systems may run on older technologies, they remain essential to many organizations. The Model Context Protocol (MCP) provides an elegant solution for exposing legacy system capabilities to modern AI applications without requiring invasive modifications.

The Challenge with Legacy Systems

Organizations face a common dilemma: legacy systems contain valuable data and business logic, but they often lack modern APIs that AI systems can consume. These systems might include:

Building custom MCP servers allows you to create a bridge between these legacy systems and AI models, enabling natural language interactions with decades-old infrastructure.

MCP Server Architecture for Legacy Integration

┌──────────────────────────────────────────────────────────────────┐
│                    AI Application (Claude, GPT)                   │
└─────────────────────────────┬────────────────────────────────────┘
                              │ MCP Protocol

┌──────────────────────────────────────────────────────────────────┐
│                     Custom MCP Server                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
│  │  Tool Handlers  │  │ Resource Layer  │  │  Prompt Layer   │  │
│  └────────┬────────┘  └────────┬────────┘  └────────┬────────┘  │
│           │                    │                     │           │
│           └──────────┬─────────┴─────────────────────┘           │
│                      ▼                                            │
│           ┌──────────────────────┐                               │
│           │  Integration Layer   │                               │
│           │  (Adapters/Mappers)  │                               │
│           └──────────┬───────────┘                               │
└──────────────────────┼───────────────────────────────────────────┘

    ┌──────────────────┼──────────────────┐
    ▼                  ▼                  ▼
┌────────┐      ┌────────────┐      ┌──────────┐
│Mainframe│     │Legacy DB   │      │SOAP/XML  │
│(CICS)   │     │(Oracle)    │      │Services  │
└────────┘      └────────────┘      └──────────┘

Setting Up a Java-Based MCP Server

Spring Boot provides an excellent foundation for building MCP servers. The MCP SDK for Java follows familiar Spring patterns, making it accessible to enterprise Java developers.

Project Setup

First, create a new Spring Boot project with the MCP dependencies:

<project>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.0</version>
    </parent>

    <dependencies>
        <!-- MCP Server SDK -->
        <dependency>
            <groupId>io.modelcontextprotocol</groupId>
            <artifactId>mcp-server-java-sdk</artifactId>
            <version>1.0.0</version>
        </dependency>
        
        <!-- Spring Boot Web for transport -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- Legacy integration -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        
        <!-- IBM MQ for mainframe integration (optional) -->
        <dependency>
            <groupId>com.ibm.mq</groupId>
            <artifactId>mq-jms-spring-boot-starter</artifactId>
            <version>3.2.1</version>
        </dependency>
    </dependencies>
</project>

Implementing the MCP Server Core

Create the main MCP server configuration:

@Configuration
@EnableMcpServer
public class McpServerConfig {
    
    @Bean
    public McpServer mcpServer(List<McpTool> tools, 
                               List<McpResource> resources) {
        return McpServer.builder()
            .name("legacy-systems-mcp")
            .version("1.0.0")
            .description("MCP server for legacy system integration")
            .tools(tools)
            .resources(resources)
            .build();
    }
}

Connecting to Legacy Databases

Many legacy systems store data in older database formats. Here’s how to create an MCP tool that queries a legacy Oracle database with complex stored procedures:

@Component
@McpTool(
    name = "query_customer_history",
    description = "Retrieves complete customer transaction history from the legacy customer database. " +
                  "Returns transactions including order details, payment history, and service records."
)
public class CustomerHistoryTool {
    
    private final JdbcTemplate jdbcTemplate;
    // CustomerMapper implements RowMapper<Transaction> to map result set rows to Transaction objects
    private final CustomerMapper customerMapper;
    
    public CustomerHistoryTool(JdbcTemplate jdbcTemplate, 
                                CustomerMapper customerMapper) {
        this.jdbcTemplate = jdbcTemplate;
        this.customerMapper = customerMapper;
    }
    
    @ToolFunction
    public CustomerHistoryResponse getCustomerHistory(
        @ToolParameter(
            description = "Customer ID (format: CUST-XXXXXX)",
            required = true
        ) String customerId,
        
        @ToolParameter(
            description = "Start date for history (ISO format: YYYY-MM-DD)",
            required = false
        ) String startDate,
        
        @ToolParameter(
            description = "Include archived transactions (default: false)",
            required = false
        ) Boolean includeArchived
    ) {
        // Validate customer ID format
        validateCustomerId(customerId);
        
        // Call legacy stored procedure
        Map<String, Object> result = jdbcTemplate.call(
            new SimpleJdbcCall(jdbcTemplate.getDataSource())
                .withProcedureName("PKG_CUSTOMER.GET_HISTORY")
                .declareParameters(
                    new SqlParameter("P_CUSTOMER_ID", Types.VARCHAR),
                    new SqlParameter("P_START_DATE", Types.DATE),
                    new SqlParameter("P_INCLUDE_ARCHIVED", Types.CHAR),
                    new SqlOutParameter("P_RESULT_CURSOR", OracleTypes.CURSOR)
                )
                .returningResultSet("transactions", customerMapper),
            Map.of(
                "P_CUSTOMER_ID", customerId,
                "P_START_DATE", parseDate(startDate),
                "P_INCLUDE_ARCHIVED", includeArchived ? "Y" : "N"
            )
        );
        
        @SuppressWarnings("unchecked")
        List<Transaction> transactions = (List<Transaction>) result.get("transactions");
        
        return new CustomerHistoryResponse(customerId, transactions);
    }
    
    private void validateCustomerId(String customerId) {
        if (!customerId.matches("CUST-\\d{6}")) {
            throw new McpToolException(
                "Invalid customer ID format. Expected: CUST-XXXXXX"
            );
        }
    }
    
    private LocalDate parseDate(String dateStr) {
        if (dateStr == null || dateStr.isEmpty()) {
            return LocalDate.now().minusYears(1);
        }
        return LocalDate.parse(dateStr);
    }
}

Integrating with Mainframe Systems via MQ

For mainframe integration, IBM MQ provides reliable message-based communication. Here’s an MCP tool that submits batch jobs to a mainframe:

@Component
@McpTool(
    name = "submit_mainframe_job",
    description = "Submits a batch job to the mainframe for processing. " +
                  "Supports payroll, inventory, and report generation jobs."
)
public class MainframeJobTool {
    
    private final JmsTemplate jmsTemplate;
    private final JobResponseHandler responseHandler;
    
    @Value("${mainframe.request.queue}")
    private String requestQueue;
    
    @Value("${mainframe.response.queue}")
    private String responseQueue;
    
    public MainframeJobTool(JmsTemplate jmsTemplate,
                            JobResponseHandler responseHandler) {
        this.jmsTemplate = jmsTemplate;
        this.responseHandler = responseHandler;
    }
    
    @ToolFunction
    public MainframeJobResponse submitJob(
        @ToolParameter(
            description = "Job type: PAYROLL, INVENTORY, REPORT, or BATCH_UPDATE",
            required = true
        ) String jobType,
        
        @ToolParameter(
            description = "Job parameters in key=value format, separated by semicolons",
            required = false
        ) String parameters,
        
        @ToolParameter(
            description = "Priority level: HIGH, NORMAL, or LOW (default: NORMAL)",
            required = false
        ) String priority
    ) {
        // Validate job type
        JobType type = validateAndGetJobType(jobType);
        
        // Build COBOL-compatible message
        String correlationId = UUID.randomUUID().toString();
        String message = buildMainframeMessage(type, parameters, priority);
        
        // Send to mainframe via MQ
        jmsTemplate.convertAndSend(requestQueue, message, m -> {
            m.setJMSCorrelationID(correlationId);
            m.setStringProperty("JOB_TYPE", type.name());
            m.setIntProperty("JOB_PRIORITY", getPriorityValue(priority));
            return m;
        });
        
        // Wait for acknowledgment (with timeout)
        try {
            JobAcknowledgment ack = responseHandler.waitForAck(
                correlationId, 
                Duration.ofSeconds(30)
            );
            
            return new MainframeJobResponse(
                ack.getJobId(),
                ack.getStatus(),
                "Job submitted successfully. Estimated completion: " + 
                    ack.getEstimatedCompletion()
            );
        } catch (TimeoutException e) {
            return new MainframeJobResponse(
                null,
                "PENDING",
                "Job submitted but acknowledgment not received. " +
                "Check mainframe queue for status."
            );
        }
    }
    
    private String buildMainframeMessage(JobType type, 
                                          String parameters, 
                                          String priority) {
        // Build fixed-width COBOL-compatible message
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("%-10s", type.getMainframeCode()));
        sb.append(String.format("%-8s", LocalDate.now().format(
            DateTimeFormatter.ofPattern("yyyyMMdd"))));
        sb.append(String.format("%-200s", parameters != null ? parameters : ""));
        sb.append(String.format("%-1s", 
            "HIGH".equals(priority) ? "H" : 
            "LOW".equals(priority) ? "L" : "N"));
        return sb.toString();
    }
}

Exposing SOAP Services as MCP Resources

Many legacy systems expose functionality via SOAP web services. MCP resources can provide structured access to these services:

@Component
public class LegacySoapResourceProvider implements McpResourceProvider {
    
    private final WebServiceTemplate webServiceTemplate;
    private final XmlMapper xmlMapper;
    
    @Override
    public List<McpResource> getResources() {
        return List.of(
            McpResource.builder()
                .uri("soap://erp/products")
                .name("ERP Product Catalog")
                .description("Complete product catalog from legacy ERP system")
                .mimeType("application/json")
                .build(),
            McpResource.builder()
                .uri("soap://erp/inventory/{warehouseId}")
                .name("Warehouse Inventory")
                .description("Real-time inventory levels for a specific warehouse")
                .mimeType("application/json")
                .build()
        );
    }
    
    @Override
    public McpResourceContent getResourceContent(String uri, 
                                                  Map<String, String> params) {
        if (uri.startsWith("soap://erp/products")) {
            return fetchProductCatalog();
        } else if (uri.startsWith("soap://erp/inventory/")) {
            String warehouseId = extractWarehouseId(uri);
            return fetchInventory(warehouseId);
        }
        throw new McpResourceNotFoundException("Unknown resource: " + uri);
    }
    
    private McpResourceContent fetchProductCatalog() {
        // Call legacy SOAP service
        GetProductCatalogRequest request = new GetProductCatalogRequest();
        request.setIncludeDiscontinued(false);
        
        GetProductCatalogResponse response = 
            (GetProductCatalogResponse) webServiceTemplate.marshalSendAndReceive(
                "http://legacy-erp:8080/ws/products",
                request
            );
        
        // Convert SOAP response to JSON
        List<ProductDto> products = response.getProducts().stream()
            .map(this::toDto)
            .collect(Collectors.toList());
        
        return McpResourceContent.builder()
            .uri("soap://erp/products")
            .mimeType("application/json")
            .content(toJson(products))
            .build();
    }
    
    private McpResourceContent fetchInventory(String warehouseId) {
        GetInventoryRequest request = new GetInventoryRequest();
        request.setWarehouseCode(warehouseId);
        
        GetInventoryResponse response = 
            (GetInventoryResponse) webServiceTemplate.marshalSendAndReceive(
                "http://legacy-erp:8080/ws/inventory",
                request
            );
        
        InventoryDto inventory = new InventoryDto(
            warehouseId,
            response.getItems().stream()
                .map(this::toInventoryItemDto)
                .collect(Collectors.toList()),
            LocalDateTime.now()
        );
        
        return McpResourceContent.builder()
            .uri("soap://erp/inventory/" + warehouseId)
            .mimeType("application/json")
            .content(toJson(inventory))
            .build();
    }
}

Handling Legacy Data Formats

Legacy systems often use non-standard data formats. Here’s how to handle fixed-width file parsing:

@Component
@McpTool(
    name = "parse_legacy_report",
    description = "Parses fixed-width legacy report files and returns structured data"
)
public class LegacyReportParser {
    
    private final ResourceLoader resourceLoader;
    
    @ToolFunction
    public ParsedReportResponse parseReport(
        @ToolParameter(
            description = "Path to the legacy report file",
            required = true
        ) String filePath,
        
        @ToolParameter(
            description = "Report type: DAILY_SALES, INVENTORY_COUNT, or PAYROLL",
            required = true
        ) String reportType
    ) {
        ReportFormat format = getFormatForType(reportType);
        
        try (BufferedReader reader = new BufferedReader(
                new FileReader(filePath))) {
            
            List<Map<String, String>> records = new ArrayList<>();
            String line;
            int lineNumber = 0;
            
            while ((line = reader.readLine()) != null) {
                lineNumber++;
                
                // Skip header lines based on format
                if (lineNumber <= format.getHeaderLines()) {
                    continue;
                }
                
                // Parse fixed-width fields
                Map<String, String> record = new LinkedHashMap<>();
                for (FieldDefinition field : format.getFields()) {
                    String value = extractField(line, field);
                    record.put(field.getName(), value);
                }
                records.add(record);
            }
            
            return new ParsedReportResponse(
                reportType,
                records.size(),
                format.getFields().stream()
                    .map(FieldDefinition::getName)
                    .collect(Collectors.toList()),
                records
            );
            
        } catch (IOException e) {
            throw new McpToolException(
                "Failed to parse report: " + e.getMessage()
            );
        }
    }
    
    private String extractField(String line, FieldDefinition field) {
        int start = field.getStartPosition() - 1; // 1-based to 0-based
        int end = Math.min(start + field.getLength(), line.length());
        
        if (start >= line.length()) {
            return "";
        }
        
        String value = line.substring(start, end).trim();
        
        // Apply field-specific transformations
        return switch (field.getType()) {
            case NUMERIC -> cleanNumeric(value);
            case DATE -> parseAndFormatDate(value, field.getDateFormat());
            case PACKED_DECIMAL -> unpackDecimal(value);
            default -> value;
        };
    }
}

Error Handling and Resilience

Legacy systems require robust error handling. Implement circuit breakers and fallbacks:

@Component
@McpTool(
    name = "get_account_balance",
    description = "Retrieves current account balance from the legacy banking system"
)
public class AccountBalanceTool {
    
    private final LegacyBankingClient bankingClient;
    private final AccountCacheService cacheService;
    
    @ToolFunction
    @CircuitBreaker(name = "legacyBanking", fallbackMethod = "getBalanceFallback")
    @Retry(name = "legacyBanking", fallbackMethod = "getBalanceFallback")
    @TimeLimiter(name = "legacyBanking")
    public AccountBalanceResponse getBalance(
        @ToolParameter(
            description = "Account number (10 digits)",
            required = true
        ) String accountNumber
    ) {
        validateAccountNumber(accountNumber);
        
        // Call legacy system
        LegacyBalanceResult result = bankingClient.queryBalance(accountNumber);
        
        // Cache the result
        cacheService.cacheBalance(accountNumber, result);
        
        return new AccountBalanceResponse(
            accountNumber,
            result.getAvailableBalance(),
            result.getCurrentBalance(),
            result.getLastUpdated(),
            "LIVE"
        );
    }
    
    // Fallback to cached data when legacy system is unavailable
    public AccountBalanceResponse getBalanceFallback(
            String accountNumber, Exception e) {
        
        log.warn("Legacy system unavailable, using cached data: {}", 
            e.getMessage());
        
        Optional<CachedBalance> cached = cacheService.getBalance(accountNumber);
        
        if (cached.isPresent()) {
            CachedBalance balance = cached.get();
            return new AccountBalanceResponse(
                accountNumber,
                balance.getAvailableBalance(),
                balance.getCurrentBalance(),
                balance.getCachedAt(),
                "CACHED - Legacy system temporarily unavailable"
            );
        }
        
        throw new McpToolException(
            "Unable to retrieve account balance. Legacy system is unavailable " +
            "and no cached data exists for this account."
        );
    }
}

Security Considerations

Legacy system integration requires careful security measures:

Authentication and Authorization

@Component
public class LegacySecurityConfig {
    
    @Bean
    public McpServerSecurityCustomizer securityCustomizer() {
        return server -> server
            .addAuthenticationFilter(this::validateApiKey)
            .addAuthorizationFilter(this::checkToolPermissions);
    }
    
    private boolean validateApiKey(McpRequest request) {
        String apiKey = request.getHeader("X-API-Key");
        return apiKeyValidator.isValid(apiKey);
    }
    
    private boolean checkToolPermissions(McpRequest request, McpTool tool) {
        String apiKey = request.getHeader("X-API-Key");
        Set<String> allowedTools = permissionService.getAllowedTools(apiKey);
        return allowedTools.contains(tool.getName());
    }
}

Audit Logging

@Aspect
@Component
public class McpAuditAspect {
    
    private final AuditLogRepository auditLogRepository;
    
    @Around("@annotation(McpTool)")
    public Object auditToolInvocation(ProceedingJoinPoint joinPoint) 
            throws Throwable {
        
        String toolName = extractToolName(joinPoint);
        Map<String, Object> parameters = extractParameters(joinPoint);
        Instant startTime = Instant.now();
        
        try {
            Object result = joinPoint.proceed();
            
            auditLogRepository.save(AuditLog.builder()
                .toolName(toolName)
                .parameters(toJson(parameters))
                .status("SUCCESS")
                .startTime(startTime)
                .endTime(Instant.now())
                .build());
            
            return result;
            
        } catch (Exception e) {
            auditLogRepository.save(AuditLog.builder()
                .toolName(toolName)
                .parameters(toJson(parameters))
                .status("ERROR")
                .errorMessage(e.getMessage())
                .startTime(startTime)
                .endTime(Instant.now())
                .build());
            
            throw e;
        }
    }
}

Testing MCP Servers

Comprehensive testing ensures reliable legacy integration:

@SpringBootTest
@ExtendWith(MockitoExtension.class)
class CustomerHistoryToolTest {
    
    @Autowired
    private CustomerHistoryTool customerHistoryTool;
    
    @MockBean
    private JdbcTemplate jdbcTemplate;
    
    @Test
    void testGetCustomerHistory_ValidCustomer() {
        // Arrange
        List<Transaction> mockTransactions = List.of(
            new Transaction("TXN001", BigDecimal.valueOf(100.00), 
                LocalDateTime.now()),
            new Transaction("TXN002", BigDecimal.valueOf(250.50), 
                LocalDateTime.now().minusDays(5))
        );
        
        when(jdbcTemplate.call(any(), anyMap()))
            .thenReturn(Map.of("transactions", mockTransactions));
        
        // Act
        CustomerHistoryResponse response = customerHistoryTool.getCustomerHistory(
            "CUST-123456", 
            null, 
            false
        );
        
        // Assert
        assertThat(response.getCustomerId()).isEqualTo("CUST-123456");
        assertThat(response.getTransactions()).hasSize(2);
    }
    
    @Test
    void testGetCustomerHistory_InvalidCustomerId() {
        // Act & Assert
        assertThatThrownBy(() -> 
            customerHistoryTool.getCustomerHistory("INVALID", null, false))
            .isInstanceOf(McpToolException.class)
            .hasMessageContaining("Invalid customer ID format");
    }
}

Deployment Considerations

Containerizing the MCP Server

FROM eclipse-temurin:21-jre-alpine

# Security: Run as non-root user
RUN addgroup -g 1000 mcpuser && \
    adduser -u 1000 -G mcpuser -s /bin/sh -D mcpuser

WORKDIR /app

# Copy application
COPY target/legacy-mcp-server.jar app.jar

# Set ownership
RUN chown -R mcpuser:mcpuser /app

USER mcpuser

# Health check
HEALTHCHECK --interval=30s --timeout=3s \
    CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: legacy-mcp-server
spec:
  replicas: 2
  selector:
    matchLabels:
      app: legacy-mcp-server
  template:
    metadata:
      labels:
        app: legacy-mcp-server
    spec:
      containers:
        - name: mcp-server
          image: legacy-mcp-server:1.0.0
          ports:
            - containerPort: 8080
          env:
            - name: LEGACY_DB_URL
              valueFrom:
                secretKeyRef:
                  name: legacy-db-credentials
                  key: url
            - name: LEGACY_DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: legacy-db-credentials
                  key: username
            - name: LEGACY_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: legacy-db-credentials
                  key: password
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5

Real-World Use Cases

1. Insurance Claims Processing

Connect AI assistants to legacy policy administration systems to help agents quickly retrieve policy details, claims history, and coverage information using natural language.

2. Banking Transaction Lookup

Enable customer service representatives to query transaction history across multiple legacy core banking systems through a unified AI interface.

3. Manufacturing ERP Integration

Expose inventory levels, production schedules, and order status from legacy ERP systems to AI-powered supply chain optimization tools.

4. Healthcare Records Access

Provide controlled access to legacy EMR/EHR systems for AI-assisted clinical decision support while maintaining HIPAA compliance.

Best Practices Summary

  1. Design for Failure: Legacy systems are often fragile. Implement circuit breakers, timeouts, and graceful degradation.

  2. Cache Strategically: Reduce load on legacy systems by caching frequently accessed data with appropriate TTLs.

  3. Validate Thoroughly: Legacy systems often have strict input requirements. Validate all parameters before calling backend systems.

  4. Log Everything: Comprehensive audit logging is essential for debugging and compliance.

  5. Version Your Tools: As legacy systems evolve, version your MCP tools to maintain backward compatibility.

  6. Document Limitations: Be explicit about what your MCP tools can and cannot do. Include rate limits and data freshness in tool descriptions.

Conclusion

Building custom MCP servers for legacy systems opens up exciting possibilities for modernizing enterprise AI capabilities without replacing core infrastructure. By creating standardized interfaces to legacy data and functionality, organizations can leverage the power of modern AI while protecting their existing investments.

The combination of Spring Boot’s robust enterprise features, MCP’s standardized protocol, and careful integration patterns creates a solid foundation for legacy system AI integration.

References and Further Reading


The code examples in this post are simplified for clarity. Always follow security best practices and thoroughly test integrations before deploying to production.


Suggest Changes
Share this post on:

Previous Post
Implementing RAG (Retrieval-Augmented Generation) with Spring AI
Next Post
Spring AI Meets Model Context Protocol: Building Context-Aware AI Applications