soapbridge: Turning a WSDL into REST, MCP, and OpenAPI, from One Parse
Why I built a generator that reads a legacy SOAP contract once and exposes it three ways at once, and how it guarantees the three surfaces can't drift apart.
- #Go
- #SOAP
- #WSDL
- #MCP
- #OpenAPI
- #CLI
- #Systems
Enterprise systems (TMS, WMS, ERP, insurance and logistics platforms) commonly expose only SOAP/XML APIs, often against WSDL contracts written a decade ago. Every team integrating with one hand rolls the same translation layer: parse the WSDL, build the envelope, POST it, parse the response, handle faults, wire it to whatever's calling it. Increasingly, "whatever's calling it" is an AI agent, and an agent wants MCP tools, not raw XML.
I built soapbridge to automate that layer. Point it at a WSDL and it generates a standalone, runnable Go server exposing every SOAP operation as a REST endpoint, an MCP tool, and an OpenAPI 3.0 document, all three read from one internal model built by parsing the WSDL exactly once.
At a Glance
- One parse, three surfaces: REST (
POST /api/{OperationName}), MCP (stdio and HTTP), and an OpenAPI 3.0 document, generated from a single shared model - One SOAP-calling path: the REST handler and the MCP tool handler both call the same
gateway.Client.Call, so the two surfaces can't disagree about what an operation does - Namespace-independent WSDL parsing: resolves prefixes via the xmlns scope in effect at each element, so it doesn't matter whether a document uses
xs:,s:, or something else entirely - A mock SOAP backend from the same WSDL, for exercising a generated gateway end to end without a reachable real service
- Zero web framework dependencies: stdlib
net/httpandencoding/xml, plus Cobra for the CLI
The Core Bet: One Model, Not Three Parsers
The easy way to build this would be three separate generators, each reading the WSDL and XSD directly. That's also the way REST, MCP, and OpenAPI output quietly drift apart over time: someone fixes a nullable-field bug in the REST generator and forgets the OpenAPI one, and now the two surfaces disagree about the same operation.
So soapbridge has exactly one WSDL parser, and everything downstream reads its output, never the raw XML:
model.Definition is deliberately not tied to XML at all: no prefixes, no refs, no raw QName strings left unresolved. It's just operations, types, and bindings. wsdl is the only package allowed to know what a WSDL looks like; schema, gateway, mcpgen, and openapigen all read model and nothing else. That's an actual rule enforced in code review on the project, not just a suggestion in a README.
Namespace Resolution Independent of Prefix Choice
Real WSDLs are inconsistent about namespace prefixes. One service uses xs: for XSD and tns: for its own target namespace. Another uses s: for everything. A third declares a default namespace with no prefix at all. None of that should matter to the parser, and in soapbridge it doesn't: every QName attribute value is resolved against the xmlns scope in effect at the exact element it appears on, not against a hardcoded prefix table.
The resolver runs in three passes over the parsed XML tree:
- Stub every named type and global element. Before resolving a single field, every
complexType,simpleType, and globalelementgets a placeholder entry in a type registry, keyed by its resolved(namespace, local name)QName. - Fill named types. Now that every type has a stable pointer, complex types can reference each other (including forward references and self-referential or mutually-recursive types) without needing a topological sort first.
- Fill global elements. Their body is either an inline type or a reference into the now-fully-resolved type registry.
That stub-then-fill structure is what makes a Category type containing a list of its own subcategories resolve cleanly instead of recursing forever or requiring the WSDL author to declare types in a particular order.
// model.QName is a namespace-qualified name: a namespace URI plus a local
// part. Namespace prefixes (tns:, xs:, s:, ...) are resolved to URIs at
// parse time, so QName equality does not depend on which prefix a
// document used.
type QName struct {
Space string
Local string
}
The test suite backs this with three real, currently-live public demo WSDLs that each use a different prefix convention, plus an adversarial fixture that deliberately swaps every prefix, to catch any place the parser accidentally assumed one.
One SOAP-Calling Path for Everything
This is the constraint I care most about in the whole design. REST and MCP are two different protocols with two different request shapes, but they both end up calling the exact same function: gateway.Client.Call.
func (c *Client) Call(
ctx context.Context,
op model.Operation,
body map[string]interface{},
creds Credentials,
) (interface{}, *Fault, error)
Call builds the SOAP envelope from body using op's input type, POSTs it, and parses the response, either into a JSON-ready value or a *Fault. Both the REST handler (gateway.Handler) and the MCP tool handler (mcpgen.Server.callTool) call it with the same operation and the same JSON-decoded arguments. There's no second envelope builder, no second fault parser, no place the two surfaces could quietly diverge on what an operation actually does.
Faults get mapped to HTTP status codes on the way out: a client-caused fault (bad input) becomes 400, anything else, including the upstream SOAP server itself erroring, becomes 502, since from the REST caller's point of view soapbridge is a gateway to a misbehaving upstream. WS-Security credentials, when a WSDL's binding indicates a UsernameToken is expected, get sourced from the REST caller's HTTP Basic auth or an X-API-Key header and injected into the outgoing envelope's <soap:Header>.
That WS-Security detection is itself a deliberate simplification worth naming: real-world WS-Security is attached via WS-Policy extensibility elements on bindings, and the shape of that attachment varies enough across .NET, Java, and other toolchains that modeling WS-Policy properly is a project on its own. So the parser does a raw byte scan for the WS-Security namespace URI and the string UsernameToken anywhere in the document, which is far more robust in practice than a partial WS-Policy implementation would be, and says so in a comment rather than pretending to be more thorough than it is.
Testing Without a Real Backend
Plenty of WSDLs, especially internal enterprise exports, declare a placeholder soap:address rather than a reachable endpoint. Rather than hand-write a stub SOAP service for every test fixture, soapbridge mock runs one straight from the same WSDL a gateway was generated from:
soapbridge mock --wsdl testdata/wsdl/calculator.wsdl --addr :9000 &
soapbridge generate --wsdl testdata/wsdl/calculator.wsdl --out /tmp/calc-gw \
--endpoint http://localhost:9000/soap --mcp --openapi --run &
curl -X POST localhost:8080/api/Add -d '{"intA": 4, "intB": 5}'
# {"AddResult":1} <- synthetic, but a real round trip: REST -> generated
# gateway -> real SOAP call -> mock -> SOAP response -> JSON
The mock answers every operation with schema-shaped example data: required fields get placeholder values, optional fields are simply omitted, and arrays get exactly one example item, which is what a minimal-but-valid real payload actually looks like. It dispatches by the request body's root element, not SOAPAction, because several real WSDLs declare an empty SOAPAction for every operation, so SOAPAction alone can't disambiguate them.
The example generator carries the same cycle-safety concern as the parser: a self-referential type stops descending on a revisit instead of recursing forever, plus a hard budget on the total number of type nodes it will expand, since a wide, heavily-shared type graph with several required shared fields could otherwise blow up combinatorially even without a literal cycle.
The Dev-Workflow Detail That Almost Bit Me
The generated server has to depend on soapbridge itself somehow. A released binary (from Homebrew or go install) embeds its own version at build time and the generated go.mod pins to that exact version, resolved from the Go module proxy like any other dependency. But developing soapbridge itself from a local checkout with go run ./cmd/soapbridge generate ... needs the generated module to point at that checkout instead, or you'd never be testing your own local changes.
generate handles this by walking up from the current working directory looking for a go.mod whose module path matches soapbridge's own, and injecting a replace directive to it automatically when found (or accepting an explicit --soapbridge-dir). A released build with no embedded version and no local checkout found fails loudly with a clear message rather than silently generating a broken module.
The other trap here: this repo's own source lives in a package directory literally named gateway/. Running soapbridge generate --out ./gateway from inside a soapbridge checkout would try to scaffold a fresh module directly on top of the real gateway package. checkOutDirSafe refuses to scaffold into any directory that already contains Go source that doesn't look like a previous soapbridge-generated output, so this fails with an explicit error instead of silently corrupting the source tree. It's a narrow, self-inflicted edge case, but the kind of thing that's much cheaper to guard against than to explain in a postmortem.
What's Explicitly Out of Scope (v1)
I'd rather a generator refuse a construct loudly than silently mishandle it. So the parser emits a warning, surfaced all the way up through the CLI, whenever it encounters:
- SOAP 1.2 bindings
xsd:choice,xsd:substitutionGroupxsd:import/xsd:includeacross multiple files (single-file WSDL only)- MTOM/attachments
None of these are silently dropped or approximated as something they're not; they're named in the warning, and the parser keeps going with whatever it can resolve. That felt more honest than trying to cover every WSDL construct in a v1 and getting some of them subtly wrong.
What I'd Do Differently
- WS-Policy, properly. The byte-scan heuristic for WS-Security detection works for the common case, but a real WS-Policy parser would catch attachment shapes it currently misses entirely.
- Multi-file WSDL.
xsd:import/xsd:includeacross files is the single-file restriction I expect to hit first against a real enterprise WSDL in the wild, since large services routinely split their schema across files. - Rate limiting and response caching at the gateway layer. Left to a reverse proxy for now, which is a reasonable default but not free for someone who just wants
go run .to be production-ready out of the box.
Key Takeaway
The interesting design decision here wasn't the XML parsing, it was refusing to let REST, MCP, and OpenAPI become three codebases that happen to describe the same service. One model, one SOAP-calling path, and warnings instead of silent approximation for anything out of scope. That constraint made the v1 feature set smaller than it could have been, and made everything in it trustworthy.