Skip to main content
legacy2mcp — legacy SOAP/WSDL turned into safe, typed MCP tools

CI PyPI Python versions MCP Registry Glama score License

Point it at a WSDL or a database. Get an MCP server whose tools can't drift from the underlying schema, can't send unvalidated arguments, and can't call write operations (or run arbitrary SQL) you didn't opt into.

legacy2mcp turning a Calculator WSDL into four typed, schema-validated MCP tools

Contents

Install

# Full install (recommended)
pip install "legacy2mcp[ai]"

# Or the identical, scan-only-ready core -- AI-assisted `legacy2mcp init`
# needs no extra package either way, just an API key at runtime; `[ai]`
# above installs nothing extra today, it's just a stable, familiar name
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.

Quick start

No API key, no cloning, no local setup — point legacy2mcp init at a real, public REST API (it tries OpenAPI/Swagger discovery against it) and generate a working config in one command:

legacy2mcp init --rest-url "https://petstore3.swagger.io/api/v3" --out config.yaml
legacy2mcp inspect --config config.yaml   # see the generated MCP tools

Or run it with no flags for the interactive menu (WSDL URL / REST API URL / GitHub repo) — see Generate a config automatically for what each option does and when it needs an API key (hint: none of them need one unless you're pointing it at a GitHub repo and want AI-assisted help with ambiguous code):

legacy2mcp init
Prefer a fully offline demo? Run the bundled mock SOAP service
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

Or with Docker:

docker compose up demo-soap-service -d
docker compose run --rm legacy2mcp legacy2mcp inspect \
  --config examples/soap/config.calculator.docker.yaml

How it works

flowchart LR
  WSDL["WSDL / XSD"] --> GEN["legacy2mcp<br/>schema generation"]
  DB["DB schema<br/>(allowlisted tables)"] --> GEN
  GEN --> TOOLS["Typed MCP tools<br/>one per operation / table / query"]
  AGENT["AI agent /<br/>MCP client"] -->|tool call| VAL{"schema<br/>validation"}
  TOOLS -. defines .-> VAL
  VAL -->|invalid args| REJ["rejected, never<br/>reaches SOAP/DB"]
  VAL -->|valid and allowed| BACKEND["SOAP endpoint<br/>or database"]
  BACKEND --> RESP["plain JSON<br/>back to the agent"]
  VAL --> LOG[("audit log")]

SOAP:

  1. Loads the WSDL with zeep, a mature, widely-used Python SOAP client.
  2. For every operation on every port/binding, converts the XSD input type into a JSON Schema (schema/xsd_to_jsonschema.py) — simple types, nested complex types, enums and arrays, recursively, depth-limited for pathological WSDLs.
  3. Registers one MCP tool per operation, named <adapter_id>_<OperationName>.
  4. On a tool call: validates arguments with jsonschema (schemas use additionalProperties: false), calls the operation via zeep, serializes the response to plain JSON, and writes an audit entry.

DB:

  1. Connects with the dialect's driver (psycopg / pyodbc / oracledb, imported lazily) and introspects the live column types for each allowlisted table (adapters/db_dialects.py) into JSON Schema (schema/sql_to_jsonschema.py).
  2. Registers select_<table> / insert_<table> per allowlisted table-and-operation, plus query_<name> per named query in config.
  3. On a tool call: validates arguments, builds SQL from the allowlisted table/columns (or runs the config-supplied named query) using one dialect-neutral :param placeholder convention, executes it, and writes an audit entry. The LLM never sees or writes SQL text.
  4. Operations whose names look like writes are excluded unless allow_write_operations: true.

Point it at your own WSDL

# 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.

Point it at your own database

pip install "legacy2mcp[db-postgres]"   # or db-mssql / db-oracle
# config.yaml
adapters:
  - id: orders
    type: db
    config:
      dialect: postgres             # postgres | mssql | oracle
      dsn_env: "ORDERS_DB_DSN"      # connection string, read from the environment
      allow_write_operations: false # insert_<table> / write: true queries stay hidden
      tables:
        - name: orders
          operations: [select]
          columns: [id, status, total, created_at]   # column allowlist
          filterable_columns: [status]                # equality filters on select_orders
          max_rows: 200
      queries:
        - name: orders_by_status_since
          sql: "SELECT id, status, total FROM orders WHERE status = :status AND created_at >= :since"
          params:
            - { name: status, type: string, required: true }
            - { name: since, type: string, required: true }
export ORDERS_DB_DSN=...
legacy2mcp inspect --config config.yaml   # review the generated tools

Columns are introspected from the live schema at startup, the same way SOAP operation schemas come from the WSDL -- there's nothing to hand-maintain. There is no path from the LLM to arbitrary SQL, ever: it can only call select_<table> / insert_<table> for tables listed under tables:, or a query_<name> tool whose SQL you wrote under queries: -- the LLM supplies only the placeholder values a query's params declare, never SQL text. A production-shaped template lives at examples/db/config.template.yaml.

Point it at your own REST API

There's no WSDL or live database schema to introspect a plain REST/JSON API from, so the rest adapter's endpoint list is explicit config -- write it by hand, or generate a starting point with legacy2mcp init (next section).

# config.yaml
adapters:
  - id: orders
    type: rest
    config:
      base_url: "https://api.example.com"
      auth:
        type: bearer
        token_env: "ORDERS_API_TOKEN"
      allow_write_operations: false
      endpoints:
        - name: get_order
          method: GET
          path: "/orders/{order_id}"
          params:
            - { name: order_id, location: path, type: integer, required: true }
        - name: create_order
          method: POST
          path: "/orders"
          write: true   # stays hidden unless allow_write_operations: true
          params:
            - { name: total, location: body, type: number, required: true }

A production-shaped template lives at examples/rest/config.template.yaml.

Generate a config automatically

legacy2mcp init asks what to point it at, then writes a starting config.yaml for you:

legacy2mcp init
What do you want to point legacy2mcp at?
  1) WSDL URL (SOAP service)
  2) REST API URL (tries OpenAPI/Swagger discovery)
  3) GitHub repo URL or a local path to its source code
Choice [1-3]:
Option What happens Needs an LLM key?
WSDL URL Points a soap adapter at it directly -- zeep introspects the live WSDL at inspect/run time anyway, so nothing needs scanning up front. No
REST API URL Tries the common OpenAPI/Swagger spec locations (/openapi.json, /swagger.json, ...) against that URL and builds the rest adapter's endpoint list from the real spec if one's found; an empty scaffold otherwise. No
GitHub repo / local path Clones (or reads) the repo and statically scans its Python source (AST only, never executed) for a zeep.Client(...) call, a SQLAlchemy/Flask-SQLAlchemy model, or Flask/FastAPI routes -- picking soap/db/rest accordingly. Optional

Each also works non-interactively via a flag -- --wsdl-url, --rest-url, --github-url, or --local-path -- for scripting or CI. Only the GitHub/ source-code path can use an LLM, and only to refine what static analysis already found (ambiguous parameter types, read-vs-write classification) -- never a dump of your repo. It needs no extra dependency, just an OpenAI-compatible API key via --llm-api-key-env, or an interactive hidden prompt if you omit that (never written to disk); pass --scan-only to skip it outright and run static-analysis-only, same as not entering a key.

legacy2mcp init --github-url https://github.com/you/your-api --scan-only --out config.yaml

Whichever path you take, review the generated file before running it -- it's marked up with # TODO comments for anything that couldn't be confidently inferred (an unresolved base_url, an unverified param type, which endpoints got classified write: true), and nothing in it is verified against the real backend until you do:

legacy2mcp inspect --config config.yaml   # review the generated tools
legacy2mcp run     --config config.yaml   # start the MCP server

See docs/security.md for exactly what this heuristic does and doesn't guarantee before pointing it at anything real.

Use it from Claude Desktop (or any MCP client)

{
  "mcpServers": {
    "legacy": {
      "command": "legacy2mcp",
      "args": ["run", "--config", "/absolute/path/to/config.yaml"]
    }
  }
}

What's handled

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); bearer token (*_env); anonymous
Transport stdio (the transport Claude Desktop and most agent frameworks spawn)
DB dialects Postgres, SQL Server, Oracle — table allowlist (select/insert) with live schema introspection, plus named parameterized queries
REST explicit endpoint allowlist (method + path + typed params) from config or legacy2mcp init — no live schema to introspect, so nothing here is verified until you run it

See docs/security.md for the full, honest security model — what's covered today and what isn't yet.

Safety model

Layer What it does
Schema validation No arguments reach the SOAP/DB layer without passing jsonschema.validate against that tool's generated schema.
Read-only by default SOAP: operation names are matched against write-verb prefixes (Create, Update, Delete, Cancel, Void, Submit, Pay, …). DB: tables/queries are explicit opt-in, and insert/write-marked queries additionally require it. Neither is exposed unless you set allow_write_operations: true.
Explicit allow / deny SOAP: include_operations / exclude_operations. DB: a table/column allowlist — there's no equivalent of "all tables" or arbitrary SQL.
Audit log One JSON line per call — tool, arguments, timestamp, outcome, duration.
Secret hygiene Credentials and DB connection strings come from named environment variables; the YAML stays safe to commit.

Use cases

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.

Real-world usage

In CI/CD — catch WSDL drift before it reaches production

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

As a sidecar / long-running MCP server

legacy2mcp run speaks MCP over stdio. Package it with your config using the provided Dockerfile and let your MCP client launch it.

In a data pipeline

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.

Configuration reference

Key Default Meaning
server.name legacy2mcp MCP server name reported to clients
server.transport stdio only stdio is implemented so far
adapters[].id — prefix for this adapter's tool names
adapters[].type — soap, db, or rest (implemented); queue is on the roadmap

SOAP adapter config:

Key Default Meaning
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

DB adapter config:

Key Default Meaning
adapters[].config.dialect — postgres, mssql, or oracle
adapters[].config.dsn_env — env var holding the connection string/DSN
adapters[].config.allow_write_operations false expose insert_<table> and write: true queries
adapters[].config.timeout_seconds 15 connection timeout
adapters[].config.tables[].name / .schema — table (and optional schema/owner) to allowlist
adapters[].config.tables[].operations [select] select and/or insert
adapters[].config.tables[].columns all introspected column allowlist
adapters[].config.tables[].filterable_columns [] columns usable as equality filters on select_<table>
adapters[].config.tables[].max_rows 200 row cap, enforced after the query runs
adapters[].config.queries[].name / .sql / .params — a named query; sql uses :param_name placeholders
adapters[].config.queries[].write false gates this query behind allow_write_operations

REST adapter config:

Key Default Meaning
adapters[].config.base_url — base URL the configured endpoints are relative to
adapters[].config.auth {type: none} none, {type: basic, username, password_env}, or {type: bearer, token_env}
adapters[].config.allow_write_operations false expose endpoints marked write: true
adapters[].config.timeout_seconds 15 per-call HTTP timeout
adapters[].config.endpoints[].name / .method / .path / .description — one allowlisted endpoint; path uses {param} placeholders for path params
adapters[].config.endpoints[].params[].name / .location / .type / .required — location is path, query, or body
adapters[].config.endpoints[].write false gates this endpoint behind allow_write_operations

Security config:

Key Default Meaning
security.audit.enabled true write the audit log
security.audit.path ./legacy2mcp-audit.log audit log location

Roadmap

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 (Postgres / SQL Server / Oracle; table/operation allowlists + named parameterized queries, no arbitrary SQL ever) ✅ shipped
v0.2.x REST adapter (explicit endpoint allowlist); legacy2mcp init -- scans a repo (SOAP client / ORM models / Flask/FastAPI routes) and generates a starter config, with optional OpenAI-compatible LLM assist for ambiguous bits ✅ shipped
v0.2.x 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.

Development

pip install -e ".[dev]"
pytest tests/ -v

Tests run against an in-process mock SOAP service and an in-process SQLite-backed stand-in for the DB adapter — no network, no real Postgres/SQL Server/Oracle required. The DB adapter's Postgres/SQL Server/Oracle dialects (db_dialects.py) have their SQL-translation logic covered by driver-free unit tests; connecting to a real instance of each is not part of the default suite.

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/).

Contributing

Adapters for new legacy systems are the highest-value contribution — implement BaseAdapter and the core handles the rest. Issues and PRs welcome.

License

Apache 2.0

Metadata

Release files for legacy2mcp 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for legacy2mcp 0.2.1
File Size Uploaded
legacy2mcp-0.2.1.tar.gz 62.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for legacy2mcp 0.2.1
File Interpreter ABI Platform
legacy2mcp-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 116.0 kB

Release files / legacy2mcp-0.2.1.tar.gz

Download URL legacy2mcp-0.2.1.tar.gz
Size 62.3 kB
Tags Source
SHA-256 checksum
How to use checksums
4185256bdd6d4bfac413bc9bb137aaf7bfd560fe34aeecbf8530c61d767426ea
BLAKE2b-256 checksum
How to use checksums
92fea4b9df6a574d2b210c0fc01ba2ee48391e131745995c6b25af9a7ff6e45d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / legacy2mcp-0.2.1-py3-none-any.whl

Download URL legacy2mcp-0.2.1-py3-none-any.whl
Size 53.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1309249ea28ca80105cf8819d1284348dc724f7ada3dfccabb1dd58f9beb6acd
BLAKE2b-256 checksum
How to use checksums
877906682bbb6674eba7a2a4332971706272e8d2409cd006648be88a3d4fa68e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.6

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page