Building an MCP server that wraps a legacy SOAP service

The WSDL is 4,000 lines. It defines 380 operations, half of them deprecated, twelve of them named variations of GetCustomer, and one that returns a 40 MB XML document. The service was written in 2011, still runs on a single VM, and the team that owns it has two people left.

This is a common starting point for an MCP integration, and the temptation is to generate 380 tools from the WSDL and call it done. That produces an agent with a catalog it can't reason about, argument schemas derived from XSD types that don't map cleanly to JSON, and error messages that are XML fault strings wrapped in a stack trace.

Wrapping SOAP is mostly a curation and translation problem. The transport is the easy part.

The shape of a good wrapper

A SOAP-backed MCP server has four jobs, in order of difficulty:

  1. Choose a small surface. Five to ten operations that answer real questions, not every operation the WSDL exposes.
  2. Translate types honestly. XSD has concepts JSON Schema doesn't, and pretending otherwise produces schemas that lie.
  3. Translate faults into a taxonomy the model can act on. A SOAP Fault is not an MCP error until you decide what it means.
  4. Absorb the operational quirks. Sessions, WS-Security, timeouts measured in minutes, and XML responses that need normalization.

The rest of this article is those four jobs, plus the parts that only show up in production.

Curate the surface before you write any code

The single highest-leverage decision is which operations to expose. A rule that works: expose operations that correspond to a question a user would ask, not operations that correspond to a table.

A typical legacy order service exposes GetOrder, GetOrderLines, GetOrderHeader, ListOrdersByCustomer, ListOrdersByDateRange, GetOrderStatus, GetOrderHistory, and GetOrderNotes. That's eight operations and three user intents: "where is my order," "what did I buy," "what happened to it."

Consolidate at the MCP layer. A get_order tool that internally calls GetOrderHeader then GetOrderLines and merges the result is one decision for the model instead of three, and it removes the most common failure mode: the model calls the header, forgets the lines, and summarizes an order without its contents.

@mcp.tool(
    name="get_order",
    description=(
        "Get one order with its line items. Returns status, totals, and "
        "line-level detail. Fails with not_found if the order number "
        "doesn't exist in this account."
    ),
)
async def get_order(order_number: str, ctx: Context) -> dict:
    header, lines = await asyncio.gather(
        soap.call("GetOrderHeader", {"orderNumber": order_number}),
        soap.call("GetOrderLines", {"orderNumber": order_number}),
    )
    if header is None:
        raise ToolError("not_found", f"No order with number {order_number}.")
    return normalize_order(header, lines)

Two calls in parallel, one tool result. The composition is your domain knowledge, encoded once, instead of a per-turn reasoning task.

💡 Practical note: if two operations must be called together, one of them should not be exposed. Every internal call you can hide is a tool-selection mistake the model can't make.

Mapping XSD to JSON Schema without lying

XSD and JSON Schema disagree on several fundamentals, and the disagreements are where generated schemas become unusable.

Nil versus absent. XSD has xsi:nil="true" for an element that exists but has no value, distinct from an element that isn't present at all. JSON has null and undefined, and undefined doesn't survive serialization. Decide per field: either null is meaningful and required includes it, or the field is optional and absence means "not provided." Don't let the parser decide.

Decimals. xs:decimal maps to a JSON number in most translators, which is a precision bug waiting to happen for money. Either keep it as a string in the JSON schema with a documented scale, or use integer minor units and convert at the boundary:

from decimal import Decimal, ROUND_HALF_UP

def to_cents(value: str) -> int:
    return int(
        (Decimal(value) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
    )

Dates and times. xs:date becomes "2026-10-24", xs:dateTime may or may not carry an offset depending on the service, and xs:time is nearly always local to a business unit nobody documented. Normalize to RFC 3339 with an explicit offset at the boundary, and if the source has no offset, state the assumed zone in the tool description. That sentence prevents a class of wrong answers about "today."

Arrays. SOAP arrays come in at least three conventions: a wrapper element with repeated children (the default in most document/literal services), SOAP-ENC:Array with arrayType (rare but present in older rpc/encoded services), and repeated elements with a maxOccurs="unbounded" declaration and no wrapper. Your parser needs to handle what the service actually returns, not what the WSDL suggests it should.

Choice and substitution groups. xs:choice means exactly one branch is present. JSON Schema's oneOf is close, but generated schemas often collapse it into "all properties optional," which tells the model nothing. Rewrite these by hand into a discriminated union if the branch matters for the call.

{
  "name": "search_orders",
  "inputSchema": {
    "type": "object",
    "properties": {
      "by": {
        "oneOf": [
          {"type": "object", "properties": {
             "customer_id": {"type": "string"},
             "since": {"type": "string", "format": "date"}},
           "required": ["customer_id"]},
          {"type": "object", "properties": {
             "order_number": {"type": "string"}},
           "required": ["order_number"]}
        ]
      }
    },
    "required": ["by"],
    "additionalProperties": false
  }
}

That's more work than a generated schema and it's the difference between a tool the model calls correctly and one it fumbles.

Building the SOAP call without a framework

A SOAP envelope is not complicated. For a document/literal service, the payload is:

<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope
    xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
    xmlns:ord="urn:example:orders:v2">
  <soapenv:Header/>
  <soapenv:Body>
    <ord:GetOrderHeader>
      <ord:orderNumber>100045</ord:orderNumber>
    </ord:GetOrderHeader>
  </soapenv:Body>
</soapenv:Envelope>

And the call is an HTTP POST with a Content-Type that depends on the SOAP version: SOAP 1.1 uses text/xml with a SOAPAction header, while SOAP 1.2 uses application/soap+xml with the action inside the Content-Type parameter. Getting this wrong produces a 500 with no useful body, which is the least debuggable failure in the protocol family.

import httpx
from xml.sax.saxutils import escape

SOAP_ENV = "http://schemas.xmlsoap.org/soap/envelope/"

def envelope(action_element: str, params: dict, ns: str) -> str:
    body = "".join(
        f"<ord:{k}>{escape(str(v))}</ord:{k}>" for k, v in params.items()
    )
    return (
        '<?xml version="1.0" encoding="utf-8"?>'
        f'<soapenv:Envelope xmlns:soapenv="{SOAP_ENV}" xmlns:ord="{ns}">'
        "<soapenv:Header/>"
        f"<soapenv:Body><ord:{action_element}>{body}"
        f"</ord:{action_element}></soapenv:Body>"
        "</soapenv:Envelope>"
    )

async def call_soap(client: httpx.AsyncClient, action: str,
                    params: dict) -> "Element":
    response = await client.post(
        "/OrdersService",
        content=envelope(action, params, NS),
        headers={
            "Content-Type": "text/xml; charset=utf-8",
            "SOAPAction": f'"urn:example:orders:v2/{action}"',
        },
        timeout=httpx.Timeout(30.0, connect=5.0),
    )
    return parse_response(response)

escape matters more than it looks. A customer name containing & or < will otherwise produce malformed XML, and the service will respond with a parser error that looks like your request was rejected for unrelated reasons. Use a real XML library to build the envelope if you can — lxml.etree with SubElement avoids hand-escaping entirely — but never concatenate unescaped user input.

Sessions in a supposedly stateless protocol

Many legacy SOAP services are stateful: Login returns a session token or sets a cookie, and subsequent calls must include it. This collides with the stateless MCP core, where every request is independent and there is no session to attach to.

The clean resolution is to keep the SOAP session inside your server, keyed by tenant or credential, and never expose it to the model:

class SoapSessionPool:
    def __init__(self, client: httpx.AsyncClient):
        self._client = client
        self._sessions: dict[str, tuple[str, float]] = {}
        self._lock = asyncio.Lock()

    async def token(self, principal: str) -> str:
        async with self._lock:
            token, issued = self._sessions.get(principal, (None, 0.0))
            if token and time.monotonic() - issued < 600:
                return token
            token = await self._login(principal)
            self._sessions[principal] = (token, time.monotonic())
            return token

The cache key must be the verified principal, not a tool argument, and the token must never appear in a tool result, a log line, or an error message. If the legacy service expires sessions aggressively, add a retry-once-on-auth-failure wrapper rather than pre-emptively re-authenticating on every call.

Authentication on the SOAP side is a funnel, not a feature

Legacy services rarely accept a bearer token. They accept a username and password in a WS-Security header, a client certificate, a vendor-specific Authenticate call, or some combination that changed once in 2014 and was never documented again. All of that has to be hidden behind the MCP server's own authentication, because the model must never see a credential.

The funnel looks like this: an MCP client presents a token, your server verifies it and derives a principal, and a credential store maps that principal to the SOAP-side credential. Nothing in a tool argument selects a credential.

async def soap_credentials(ctx: Context) -> SoapCredentials:
    tenant = ctx.tenant_id           # from the verified token
    creds = await secrets.get(f"soap/{tenant}")
    if creds is None:
        raise ToolError(
            "permission_denied",
            "This workspace has no order-service credentials configured. "
            "An administrator needs to connect the account.",
        )
    return creds

For WS-Security with a username token, the header goes into the envelope and must be built with a real XML library, because the password digest involves canonicalization that string templating gets wrong:

<soapenv:Header>
  <wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/"
                 xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/">
    <wsse:UsernameToken>
      <wsse:Username>svc_mcp</wsse:Username>
      <wsse:Password Type="...#PasswordText">••••••</wsse:Password>
    </wsse:UsernameToken>
  </wsse:Security>
</soapenv:Header>

Password digest authentication is supported by some services instead of plaintext, and it requires a nonce, a created timestamp, and a SHA-1 digest over nonce plus timestamp plus password. If the service supports it, prefer it — plaintext over TLS is acceptable inside a VPC and a liability in transit between clouds.

🚨 Production warning: WS-Security headers end up in request logs at every hop. If the password is in the header, it's in your proxy's access log. Scrub it or use digest auth.

When the WSDL lies

Generated clients assume the WSDL is accurate. In practice, a fifteen-year-old service has drifted, and four kinds of drift cause most of the pain.

Elements marked optional that are required in practice. The schema says minOccurs="0", the service returns a fault if it's missing. Test each operation with the minimum allowed payload and see what happens; the answer becomes part of your required list, with a comment explaining why it diverges from the WSDL.

Enumeration values not in the schema. A status field documented with six values returns PARTIALLY_SHIPPED, which nobody defined but which appears in production data. Your normalization should pass unknown values through in a predictable way rather than raising, and the tool description should say the status is a string rather than an enum. A schema that promises six values and returns seven is worse than a schema that promises a string.

Operations that fail for specific accounts. Multi-tenant legacy systems often have per-tenant configuration that disables features. The WSDL describes the operation; the tenant's contract decides whether it works. Surface that as permission_denied with a message about account configuration, not as internal.

Fields that are present but empty. An empty <OrderDate/> is not a date. Treat empty strings as absent for typed fields, and decide explicitly whether an empty list means "no items" or "not loaded." Those are different answers to a user's question, and the service will not tell you which one it meant.

The general principle: trust fixtures over schemas. Every divergence you discover should become a test case, so the next person doesn't rediscover it in production.

Turning SOAP faults into decisions

A SOAP Fault looks like this:

<soapenv:Fault>
  <faultcode>soapenv:Client</faultcode>
  <faultstring>Order 100045 is not in a cancellable state</faultstring>
  <detail>
    <ord:OrderState>SHIPPED</ord:OrderState>
  </detail>
</soapenv:Fault>

Passed through unchanged, a model will read "Client" and conclude the caller did something wrong — which is roughly true but not actionable. Passed through as an exception, it becomes an internal error and the model retries.

The mapping is a policy decision, and it should be a table rather than a chain of if statements:

Fault signal MCP code Retryable
Client + validation wording invalid_argument No
Client + "not found" not_found No
Client + state conflict conflict No
Server / Receiver upstream_unavailable Yes, once
Auth / security fault permission_denied No
HTTP 500 with empty body upstream_unavailable Yes, once
Timeout upstream_unavailable Yes, once
Anything unclassified internal No
def to_tool_error(fault: Element | None, status: int) -> ToolError:
    if fault is None:
        return ToolError("upstream_unavailable",
                         "The order service returned an empty error response.",
                         retryable=True, retry_after_ms=2000)
    code = fault.findtext(f".//{{{SOAP_ENV}}}faultcode") or ""
    text = (fault.findtext(f".//{{{SOAP_ENV}}}faultstring") or "").strip()

    if "not in a cancellable state" in text:
        state = fault.findtext(".//OrderState") or "unknown"
        return ToolError("conflict",
                         f"Order cannot be cancelled while in state {state}.")
    if code.endswith("Client"):
        return ToolError("invalid_argument", f"The order service rejected the request: {text[:200]}")
    if code.endswith("Server"):
        return ToolError("upstream_unavailable",
                         "The order service failed internally. This is temporary.",
                         retryable=True, retry_after_ms=5000)
    return ToolError("internal", "The order service returned an unrecognized fault.")

Truncate fault strings. They're written for developers, sometimes contain stack traces from the Java side, and occasionally include internal hostnames. Two hundred characters is enough to be actionable and short enough to be safe.

The XML response is not the API

Legacy services return shapes that reflect their internal data model, and handing that straight to a model wastes context and invites misinterpretation. A GetOrderHeader response might include forty fields, twelve of which are nil, and three of which are internal cost allocations.

Normalize at the boundary. Keep what the caller needs, name it consistently across tools, and drop the rest:

def normalize_order(header: Element, lines: Element) -> dict:
    return {
        "order_number": header.findtext(".//OrderNumber"),
        "status": (header.findtext(".//OrderState") or "").lower(),
        "placed_at": to_rfc3339(header.findtext(".//OrderDate")),
        "currency": header.findtext(".//CurrencyCode"),
        "total_cents": to_cents(header.findtext(".//GrandTotal") or "0"),
        "lines": [
            {
                "sku": line.findtext("Sku"),
                "quantity": int(line.findtext("Quantity") or 0),
                "unit_price_cents": to_cents(line.findtext("UnitPrice") or "0"),
            }
            for line in lines.findall(".//OrderLine")
        ],
    }

Three things are happening that are worth naming. Field names become snake_case and stable, so the model doesn't have to learn OrderState for one tool and Status for another. Money becomes integer cents, which removes an entire category of floating-point weirdness from the model's reasoning. Empty elements become None or omitted fields, instead of empty strings that read as real values.

🔍 Why this matters: every unnecessary field in a tool result costs tokens on every call and gives the model another thing to misread. The response shape is a product decision.

Long calls, and what to do about them

Legacy SOAP services are slow in a specific way: they're fast for the operations they were optimized for and brutally slow for anything that touches a reporting table. A GetOrderHistory call that takes forty seconds is normal, and an MCP client that times out at thirty will report failure while the server keeps working.

Three options, and the right one depends on the operation:

Make the tool faster. Cache the stable parts, batch the calls the model would otherwise make separately, and avoid fetching full documents when a summary answers the question.

Return a task handle. The Tasks extension exists for exactly this: the server returns a handle, the client polls with tasks/get, and client input goes through tasks/update. This changes the interaction shape, so the tool description must say "returns a task; poll it" — otherwise the model treats the handle as the result.

Ask for less. A history tool that returns the last five events by default, with an explicit limit, is faster and more useful than one that returns everything and gets truncated.

The trap in all three is the same: a timeout in the middle of a SOAP call has an unknown outcome, and SOAP services are rarely idempotent. A retried CreateOrder creates two orders. If the operation mutates, you need an idempotency key that the legacy service understands — often a client reference field that the service already has and nobody uses.

<ord:CreateOrder>
  <ord:clientReference>mcp-7f3a91c2</ord:clientReference>
  <ord:customerNumber>4410</ord:customerNumber>
</ord:CreateOrder>

If no such field exists, the honest answer is that the tool is not safely retryable, and the MCP layer should say so with retryable: false even on timeouts.

Testing a wrapper you can't integrate against

You will not get a reliable sandbox for a fifteen-year-old SOAP service. Plan accordingly.

Record real responses as fixtures. Capture envelopes for each operation, including at least one fault per class, and test the parser against them. This catches most normalization bugs without a network.

Test the fault mapping as a table. Every row in the mapping table above should have a test with a real fault body. This is where regressions hurt most, because a changed fault string silently reclassifies an error.

Assert that no XML leaks into a tool result. A single test that walks the response and fails on < followed by a known namespace prefix catches the case where someone returns str(element) "temporarily."

def test_no_raw_xml_in_results(world, recorded_fixtures):
    for name, payload in recorded_fixtures.items():
        result = world.normalize(name, payload)
        serialized = json.dumps(result)
        assert "soapenv:" not in serialized, f"{name} leaked SOAP envelope"
        assert "xsi:nil" not in serialized, f"{name} leaked XSD nil"

Test with the service's actual WSDL version pinned. Vendors change WSDLs without notice, and a generated client that re-reads the WSDL at startup will break on a deploy you didn't make. Generate once, commit the generated code, and treat WSDL updates as a deliberate change.

Is SOAP even the right boundary?

Sometimes the better architecture is not to wrap SOAP at all. If the legacy service has a database you're allowed to read, a read-only replica plus curated views can be a far better analytics surface than a set of SOAP calls, and the tool semantics become much simpler.

The decision comes down to what the MCP server is for:

Approach Latency Fidelity to business rules Maintenance
Wrap SOAP operations Seconds High — rules stay in the service Medium; WSDL drift
Read replica + views Milliseconds Low — you reimplement rules High; two sources of truth
Batch export + warehouse Minutes to hours Medium Low per query, high per pipeline
Hybrid: writes via SOAP, reads via replica Mixed High for writes, fast reads Highest, but often best

The hybrid is what most mature teams end up with: mutations go through the service because that's where validation and side effects live, and reads come from a replica because a read-only query doesn't need a SOAP envelope. It's more infrastructure than a wrapper, and it's the difference between an agent that answers in 200ms and one that takes eight seconds per question.

Observability when the dependency is a black box

You can't instrument the SOAP service, so your visibility has to come from the boundary. Three measurements cover most incidents.

Per-operation latency and error rate. Not per tool — per SOAP operation, because one slow operation inside a composed tool makes the whole tool look slow. When get_order degrades, you want to know instantly whether GetOrderHeader or GetOrderLines is responsible.

Fault code distribution. A shift in fault classes is the earliest signal of a schema change or a tenant configuration problem. Client faults rising from 2% to 40% overnight means your requests changed shape, not that users got clumsier.

Upstream timeout rate, separated from client timeout rate. These look identical in a dashboard of "failed calls" and have opposite fixes: one is a slow service, the other is an impatient client.

with tracer.start_as_current_span(f"soap.{action}") as span:
    span.set_attribute("soap.action", action)
    span.set_attribute("soap.endpoint", endpoint)
    started = time.monotonic()
    try:
        response = await client.post(...)
        span.set_attribute("soap.http_status", response.status_code)
        return parse_response(response)
    finally:
        span.set_attribute("soap.duration_ms", (time.monotonic() - started) * 1000)

Record the operation name, never the envelope. A full SOAP request body contains customer data and, depending on the auth scheme, a credential.

A delivery checklist for the wrapper

  • Is the tool count small enough to reason about? Fewer than a dozen is a good target; five is better.
  • Does each tool correspond to a user intent, not a SOAP operation?
  • Are money values integers or documented strings, never floats?
  • Do all timestamps carry an explicit offset, and is the assumed zone documented?
  • Is every user-supplied value escaped by an XML library rather than string concatenation?
  • Are session tokens held server-side, keyed by verified principal, and never logged?
  • Does every fault class map to a specific MCP code with a correct retryable value?
  • Are fault strings truncated before they reach the model?
  • Do mutating tools have a real idempotency story, or an explicit retryable: false?
  • Is the WSDL pinned and the generated client committed?
  • Is there a fixture for at least one fault of each class?
  • Would a read replica be a better answer for the read-only tools? Ask before building twenty of them.

Legacy SOAP services are usually more reliable than their reputation suggests. They're slow, their errors are written for a different audience, and their type system is older than most of the tools calling them. A wrapper that curates the surface, translates types honestly, and maps faults into decisions is a small amount of code — and it's the difference between an agent that uses the service correctly and one that produces confident summaries of data it misread.

Post a Comment

Previous Post Next Post