Turn a legacy SOAP/WSDL service into a safe, typed, schema-validated, audit-logged MCP server
Point it at a WSDL. Get an MCP server whose tools can't drift from the service, can't send unvalidated arguments, and can't call write operations you didn't opt into.
[!NOTE]
legacy2mcpintrospects every operation in a WSDL, builds a real JSON Schema for each one from the WSDL's own XSD types, and exposes them as MCP tools — with every call schema-validated before it reaches your SOAP endpoint, write-like operations excluded by default, and every call audit-logged. No hand-written adapter code, no hand-maintained schemas.
pip install legacy2mcp
# or: uv tool install legacy2mcp · pipx install legacy2mcp
Also published to the MCP Registry as io.github.bvenkata/legacy2mcp, so registry-aware MCP clients can discover it directly.
Try it end-to-end against the bundled mock SOAP service — no external network, no real backend:
git clone https://github.com/bvenkata/legacy2mcp.git
cd legacy2mcp
pip install -e ".[dev]"
# 1. start the demo SOAP service (dneonline-style Calculator WSDL)
python examples/soap/run_mock_calculator.py &
# 2. see the MCP tools generated from its WSDL
legacy2mcp inspect --config examples/soap/config.calculator.yaml
docker compose up demo-soap-service -d
docker compose run --rm legacy2mcp legacy2mcp inspect \
--config examples/soap/config.calculator.docker.yaml
flowchart LR
WSDL["WSDL / XSD"] --> GEN["legacy2mcp<br/>schema generation"]
GEN --> TOOLS["Typed MCP tools<br/>one per operation"]
AGENT["AI agent /<br/>MCP client"] -->|tool call| VAL{"schema<br/>validation"}
TOOLS -. defines .-> VAL
VAL -->|invalid args| REJ["rejected, never<br/>reaches SOAP"]
VAL -->|valid and allowed| SOAP["SOAP endpoint"]
SOAP --> RESP["plain JSON<br/>back to the agent"]
VAL --> LOG[("audit log")]
zeep, a mature, widely-used Python SOAP client.schema/xsd_to_jsonschema.py) — simple types, nested complex types, enums and arrays, recursively, depth-limited for pathological WSDLs.<adapter_id>_<OperationName>.jsonschema (schemas use additionalProperties: false), calls the operation via zeep, serializes the response to plain JSON, and writes an audit entry.allow_write_operations: true.# config.yaml
server:
name: my-legacy-mcp
adapters:
- id: legacy
type: soap
config:
wsdl_url: "https://service.example.com/LegacyService?wsdl"
auth:
type: basic
username: "svc-account"
password_env: "SERVICE_PASSWORD" # value read from the environment, never the file
allow_write_operations: false # Create*/Update*/Delete*/… stay hidden
include_operations: ["GetRecord", "GetRecordDetails", "SearchRecords"]
security:
audit:
enabled: true
path: "./legacy-mcp-audit.log"
export SERVICE_PASSWORD=...
legacy2mcp inspect --config config.yaml # review the generated tools
legacy2mcp run --config config.yaml # start the MCP server (stdio)
A production-shaped template with comments lives at examples/soap/config.template.yaml.
{
"mcpServers": {
"legacy": {
"command": "legacy2mcp",
"args": ["run", "--config", "/absolute/path/to/config.yaml"]
}
}
}
| Area | Covered |
|---|---|
| Type mapping | string / int / long / decimal / boolean / date / dateTime / base64Binary / … → JSON Schema types + formats |
| Structure | nested complex types, repeated elements → arrays, xsd:enumeration → enum, recursion depth-limited |
| Discovery | every service → port → binding → operation; duplicate tool names rejected at startup |
| Invocation | argument validation, zeep call, response serialized to plain JSON, single-field responses re-wrapped to a named result |
| Errors | SOAP faults and transport errors caught and returned as clean messages — no stack traces to the caller |
| Auth | HTTP basic (username + *_env password); anonymous |
| Transport | stdio (the transport Claude Desktop and most agent frameworks spawn) |
See docs/security.md for the full, honest security model — what's covered today and what isn't yet.
| Layer | What it does |
|---|---|
| Schema validation | No arguments reach the SOAP layer without passing jsonschema.validate against that operation's generated schema. |
| Read-only by default | Operation names are matched against write-verb prefixes (Create, Update, Delete, Cancel, Void, Submit, Pay, …); those tools aren't exposed unless you set allow_write_operations: true. |
| Explicit allow / deny | include_operations (allowlist) and exclude_operations (denylist) on top of the heuristic. |
| Audit log | One JSON line per call — tool, arguments, timestamp, outcome, duration. |
| Secret hygiene | Credentials come from named environment variables; the YAML stays safe to commit. |
[!WARNING] The write-operation filter is a name heuristic, not semantic analysis — an operation called
ProcessRecordthat deletes data would not be caught. For any system where a wrong call has real consequences, setinclude_operationsexplicitly and don't rely on the heuristic. There is also no auth/authz on the MCP server itself yet — don't expose a v0.1 server to untrusted callers. Details in docs/security.md.
| Domain | Shape |
|---|---|
| Systems of record | An agent reads status/detail records from a legacy back-office platform, read-only, every lookup logged. |
| Financial services | Expose account and transaction reads without exposing transfers or adjustments. |
| Supply chain / ERP | Surface order status, inventory, shipment tracking from an old SOAP middleware layer. |
| Internal support tooling | A support copilot gets safe, typed access to the system of record instead of a scraped UI. |
| Migration & modernization | Put an MCP layer in front of a legacy service now; swap the backend later without touching the agent. |
legacy2mcp inspect loads the config, contacts the WSDL, builds every schema, and exits non-zero if anything fails:
- name: Check the WSDL still generates valid MCP tools
env:
SERVICE_PASSWORD: ${{ secrets.SERVICE_PASSWORD }}
run: |
pip install legacy2mcp
legacy2mcp inspect --config config/legacy.yaml > tools.json
git diff --exit-code --no-index tools/legacy.snapshot.json tools.json # optional: pin the contract
legacy2mcp run speaks MCP over stdio. Package it with your config using the provided Dockerfile and let your MCP client launch it.
Call the same generated, validated tools from your own code via any MCP client library to pull records on a schedule — the audit log records exactly what was fetched.
| Key | Default | Meaning |
|---|---|---|
server.name | legacy2mcp | MCP server name reported to clients |
server.transport | stdio | only stdio is implemented in v0.1 |
adapters[].id | — | prefix for this adapter's tool names |
adapters[].type | — | soap (implemented); db / queue are on the roadmap |
adapters[].config.wsdl_url | — | WSDL location (http(s)://…?wsdl or a file path) |
adapters[].config.auth | {type: none} | none or {type: basic, username, password_env} |
adapters[].config.allow_write_operations | false | expose write-like operations |
adapters[].config.include_operations | all | allowlist of operation names |
adapters[].config.exclude_operations | [] | denylist of operation names |
adapters[].config.timeout_seconds | 15 | per-call SOAP timeout |
security.audit.enabled | true | write the audit log |
security.audit.path | ./legacy2mcp-audit.log | audit log location |
| Version | Scope | Status |
|---|---|---|
| v0.1 | SOAP/WSDL adapter, schema generation, validation, read-only default, audit log, run + inspect CLI, basic auth, stdio | ✅ shipped |
| v0.2 | Database adapter (parameterized-query-only, table/operation allowlists), HTTP/SSE transport, role→tool authorization, OAuth2 for SOAP | planned |
| v0.3+ | Queue adapter (Kafka/RabbitMQ/SQS), workflow composition with approval gates, OpenTelemetry export | ideas |
Full detail in docs/roadmap.md. The BaseAdapter interface (discover_tools() + invoke()) is the extension point — the server core handles validation, dispatch and audit for any adapter.
pip install -e ".[dev]"
pytest tests/ -v # runs against an in-process mock SOAP service — no network
CI runs the suite on Python 3.10–3.12 (ci.yml). Releases to PyPI and the MCP Registry are tag-triggered — see docs/releasing.md. The demo GIF is regenerated with vhs demo/demo.tape (demo/).
Adapters for new legacy systems are the highest-value contribution — implement BaseAdapter and the core handles the rest. Issues and PRs welcome.
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
uvx legacy2mcpMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-bvenkata-legacy2mcp": {
"command": "uvx",
"args": [
"legacy2mcp"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referencelegacy2mcppypilegacy2mcp works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.