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:
- Mainframe applications with COBOL or PL/I backends
- AS/400 (IBM i) systems running RPG programs
- Legacy databases like Oracle Forms, FoxPro, or dBase
- File-based systems using flat files, VSAM, or proprietary formats
- SOAP web services with complex WSDL definitions
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
-
Design for Failure: Legacy systems are often fragile. Implement circuit breakers, timeouts, and graceful degradation.
-
Cache Strategically: Reduce load on legacy systems by caching frequently accessed data with appropriate TTLs.
-
Validate Thoroughly: Legacy systems often have strict input requirements. Validate all parameters before calling backend systems.
-
Log Everything: Comprehensive audit logging is essential for debugging and compliance.
-
Version Your Tools: As legacy systems evolve, version your MCP tools to maintain backward compatibility.
-
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
- Model Context Protocol Specification
- Spring Boot Documentation
- IBM MQ Spring Boot Integration
- Resilience4j Circuit Breaker
- InfoQ - Modernizing Legacy Systems
- DZone - Enterprise Integration Patterns
The code examples in this post are simplified for clarity. Always follow security best practices and thoroughly test integrations before deploying to production.