Skip to main content
MCP Deep Dive

Wrapping a Legacy System

Ravinder··5 min read
MCPModel Context ProtocolAILegacy SystemsSOAPIntegrationModernisation
Share:
Wrapping a Legacy System

The systems that hold the most business-critical data are often the ones that have not changed since the early 2000s. SOAP web services, command-line batch jobs, green-screen terminal applications, COBOL programs that run nightly. These systems are not going away — rewriting them is expensive, risky, and often politically impossible. But they do not have to be walled off from AI agents. MCP gives you a clean way to expose legacy capabilities without touching the legacy system at all.

The Adapter Pattern

The core idea is straightforward: the MCP server is a thin translation layer. It speaks modern JSON-RPC to the agent and whatever protocol the legacy system understands to the backend. The legacy system never knows an AI is involved.

graph LR Agent["AI Agent\n(MCP Client)"] MCP["MCP Server\n(Adapter Layer)"] SOAP["SOAP\nService"] CLI["CLI\nBatch Tool"] COBOL["COBOL\nJob via JCL"] Agent -- "tools/call (JSON-RPC)" --> MCP MCP -- "SOAP envelope (HTTP/XML)" --> SOAP MCP -- "child_process.spawn" --> CLI MCP -- "JES submission via FTP" --> COBOL

The adapter layer does three things:

  1. Translates MCP tool arguments into the legacy call format.
  2. Executes the call.
  3. Translates the response back into MCP content.

Wrapping a SOAP Service

SOAP is XML over HTTP. The adapter sends a raw XML envelope and parses the response.

// src/adapters/soap.ts
import { parseStringPromise } from "xml2js";
 
const WSDL_ENDPOINT = process.env.SOAP_ENDPOINT!; // e.g. http://erp.internal/CustomerService
 
function buildEnvelope(method: string, params: Record<string, string>): string {
  const body = Object.entries(params)
    .map(([k, v]) => `<${k}>${v}</${k}>`)
    .join("\n        ");
  return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
               xmlns:tns="http://erp.internal/CustomerService">
  <soap:Body>
    <tns:${method}>
        ${body}
    </tns:${method}>
  </soap:Body>
</soap:Envelope>`;
}
 
export async function callSoap(method: string, params: Record<string, string>): Promise<Record<string, unknown>> {
  const envelope = buildEnvelope(method, params);
  const res = await fetch(WSDL_ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "text/xml; charset=utf-8",
      "SOAPAction":   `"http://erp.internal/CustomerService/${method}"`
    },
    body: envelope
  });
  const xml = await res.text();
  const parsed = await parseStringPromise(xml, { explicitArray: false });
  const body = parsed["soap:Envelope"]["soap:Body"];
  return body[`${method}Response`] ?? body;
}

MCP tool handler:

case "get_customer": {
  const { customer_id } = req.params.arguments as { customer_id: string };
  const result = await callSoap("GetCustomer", { CustomerId: customer_id });
  return {
    content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
  };
}

Wrapping a CLI Tool

Many legacy capabilities exist only as command-line programs. The adapter spawns the process and captures stdout/stderr.

// src/adapters/cli.ts
import { spawn } from "node:child_process";
 
export function runCli(command: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
  return new Promise((resolve, reject) => {
    const proc = spawn(command, args, { timeout: 30_000 });
    let stdout = "", stderr = "";
    proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
    proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
    proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 0 }));
    proc.on("error", reject);
  });
}

Tool handler using a hypothetical report-gen CLI:

case "generate_report": {
  const { report_type, from_date, to_date } = req.params.arguments as any;
  const { stdout, stderr, code } = await runCli("report-gen", [
    "--type", report_type,
    "--from", from_date,
    "--to",   to_date,
    "--format", "json"
  ]);
  if (code !== 0) throw new Error(`report-gen failed: ${stderr}`);
  return { content: [{ type: "text", text: stdout }] };
}

Wrapping a COBOL Batch Job

COBOL jobs typically run via JCL (Job Control Language) submitted to a mainframe job scheduler. The simplest adapter submits JCL via FTP (the most common mainframe file transfer method) and polls for completion.

# src/adapters/cobol_adapter.py
import ftplib
import time
import os
 
JES_HOST  = os.environ["MAINFRAME_HOST"]
JES_USER  = os.environ["MAINFRAME_USER"]
JES_PASS  = os.environ["MAINFRAME_PASS"]
 
JCL_TEMPLATE = """\
//RPTJOB   JOB (ACCT),'MONTHLY RPT',CLASS=A,MSGCLASS=X
//STEP1    EXEC PGM=MONTHRPT
//PARM     DD *
MONTH={month}
YEAR={year}
/*
//SYSOUT   DD SYSOUT=*
"""
 
def submit_monthly_report(month: str, year: str) -> str:
    jcl = JCL_TEMPLATE.format(month=month, year=year)
    with ftplib.FTP(JES_HOST) as ftp:
        ftp.login(JES_USER, JES_PASS)
        ftp.sendcmd("SITE FILETYPE=JES")
        import io
        ftp.storlines("STOR RPTJOB.JCL", io.StringIO(jcl))
        # In practice: poll JES for job completion and retrieve SYSOUT
    return f"Job RPTJOB submitted for {month}/{year}"

Error Handling and Timeouts

Legacy systems fail in interesting ways: SOAP faults buried in XML, CLI tools that hang indefinitely, mainframe jobs that queue for hours. Your adapter must be defensive.

// Wrap every legacy call with a timeout
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
  const timer = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timer]);
}
 
// Usage
const result = await withTimeout(
  callSoap("GetCustomer", { CustomerId: id }),
  10_000,
  "SOAP GetCustomer"
);

Return structured errors in MCP responses — do not let raw stack traces escape to the agent.

try {
  const result = await callSoap("GetCustomer", { CustomerId });
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
  return {
    content: [{ type: "text", text: `Error fetching customer: ${(err as Error).message}` }],
    isError: true
  };
}

Testing Adapters Without the Legacy System

The key technique is recording real responses and replaying them in tests. We cover this in detail in the next post, but the principle is: capture a real SOAP response or CLI output to a fixture file, then mock the transport layer in tests.

flowchart TD Real["Real legacy system\n(staging)"] -- "record response" --> Fixture["Fixture file\n(SOAP/CLI output)"] Fixture -- "loaded in tests" --> Adapter["Adapter under test"] Adapter -- "returns parsed result" --> Handler["MCP tool handler"] Handler -- "returns MCP content" --> Assert["Jest / pytest assertion"]

Key Takeaways

  • MCP servers act as pure adapters — the legacy system sees its native protocol; the agent sees clean JSON-RPC.
  • SOAP adapters only need raw HTTP POST with the right Content-Type and SOAPAction headers; no WSDL parsing required for most use cases.
  • CLI adapters use child process spawning with explicit timeouts; never trust a legacy CLI to exit on its own.
  • COBOL/mainframe integration typically goes through FTP-based JCL submission and job status polling.
  • Always wrap legacy calls in timeouts and return structured MCP error responses rather than raw exceptions.
  • Fixture-based replay testing lets you validate adapter logic without a live legacy system in CI.
Share: