legacy2mcp
Uses a Postman collection as the source contract for an interface that benefits from a review step, writing a config and generating an MCP server with the same test chat and client connection snippet (legacy2mcp discover).
Discovers OpenAPI/Swagger specifications from a REST API (e.g. legacy2mcp init --rest-url "https://petstore3.swagger.io/api/v3") and generates one typed MCP tool per allowlisted endpoint, splitting validated arguments into path, query, and body parameters.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@legacy2mcpWhat operations does the demo calculator service expose?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
legacy2mcp
Convert anything to MCP in 60 seconds. Read-only by default, with every call audit-logged.
Related MCP server: SOAP MCP Server
Quick start
No API key, no cloning — pick whichever matches what you actually have, against live public demo targets:
pip install legacy2mcp
# REST API -- tries OpenAPI/Swagger discovery
legacy2mcp init --rest-url "https://petstore3.swagger.io/api/v3"
# WSDL / SOAP service -- zeep introspects it live, no scan needed
legacy2mcp init --wsdl-url "http://www.dneonline.com/calculator.asmx?WSDL"
# A website with no API docs at all -- crawls it for forms/content
legacy2mcp init --webpage-url "https://legacy2mcp.com/"Run any one of those from a real terminal and that's the whole thing: it
writes config.yaml, opens a local test-chat page with a clickable example
question under every discovered tool — click one and see a real response
come back from the live source, no AI key needed, just local keyword
matching to sanity-check the server works — and prints a ready-to-paste
connection snippet for Claude Desktop, Cursor, VS Code, and Claude Code.
Wiring it into a real AI client is a copy-paste, not a separate step.
(Prefer the raw tool list instead? legacy2mcp inspect --config config.yaml.)
legacy2mcp discover
does the same for sources that benefit from a review step first (a
Postman collection, a HAR capture, …): write a contract, open the same
test chat, print the same connection snippet — one command either way.
Have your own WSDL, REST API, website, or codebase instead? legacy2mcp init (no flags) walks you through it — see
Generate a config automatically. More
demos (a local SOAP service, Docker, a WSDL served by raw URL) are listed in
examples/.
legacy2mcp introspects your legacy system live — every WSDL operation, every database table, every REST spec it can find — and builds a real JSON Schema for each tool from it. Every MCP tool call is schema-validated before it reaches your backend, write operations are excluded by default, and every call is audit-logged. No hand-written adapter code, no hand-maintained schemas, no arbitrary SQL from the LLM.
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 legacy2mcpAlso published to the MCP Registry as io.github.bvenkata/legacy2mcp, so registry-aware MCP clients can discover it directly.
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:
Loads the WSDL with
zeep, a mature, widely-used Python SOAP client.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.Registers one MCP tool per operation, named
<adapter_id>_<OperationName>.On a tool call: validates arguments with
jsonschema(schemas useadditionalProperties: false), calls the operation viazeep, serializes the response to plain JSON, and writes an audit entry.
DB:
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).Registers
select_<table>/insert_<table>per allowlisted table-and-operation, plusquery_<name>per named query in config.On a tool call: validates arguments, builds SQL from the allowlisted table/columns (or runs the config-supplied named query) using one dialect-neutral
:paramplaceholder convention, executes it, and writes an audit entry. The LLM never sees or writes SQL text.Operations whose names look like writes are excluded unless
allow_write_operations: true.
REST:
There's no live schema to introspect a plain REST API from the way there is a WSDL or a DB catalog, so the endpoint list (method, path, typed params) comes from config -- hand-written, or generated by
legacy2mcp init's OpenAPI/Swagger discovery or source-code scan.Registers one MCP tool per allowlisted endpoint, named
<adapter_id>_<name>.On a tool call: validates arguments, splits them into path/query/body by each param's declared
location, substitutes{param}placeholders into the URL, and calls it withrequests-- auth applied per the adapter'sauthconfig (basic/bearer/none).Endpoints marked
write: true(non-GETby default) are excluded unlessallow_write_operations: true.
Why not just write an adapter by hand?
Because a hand-written adapter is a second copy of your system's schema that nobody remembers to update. The first time a WSDL operation's signature shifts or a column changes, your tool's JSON Schema silently drifts from reality — and an LLM calling it finds out by sending arguments that are wrong in a way nothing catches, or by getting a confusing runtime error from deep inside the SOAP/DB client. legacy2mcp builds the schema from the live system every time it starts, so a drifted call fails at validation, before it ever reaches your backend — not after.
Hand-written adapter (FastMCP / raw SDK) | Generic OpenAPI-to-MCP proxy | legacy2mcp | |
Schema source | Typed by hand, per tool | The OpenAPI spec, if one exists | The live WSDL / DB schema, or a discovered OpenAPI spec |
Can drift from the real system | Yes — nothing re-checks it | Only if the spec itself is stale | No — rebuilt from the live source at startup |
Covers SOAP / a database | You write that client code yourself | No — REST/OpenAPI only | Yes, plus REST and plain websites |
Write operations hidden by default | No — you remember to gate each one | No — usually exposes everything the spec defines | Yes — explicit |
Audit log of every call | DIY | Usually no | Built in |
Time to a working server | Hours to days, per system | Minutes, for a REST API with a spec | Minutes — |
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 toolsColumns 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.
Both a non-GET method and an LLM-assisted classification are
heuristics, not a guarantee the endpoint is actually safe to call
unsupervised -- review write: flags and allow_write_operations before
running against a real backend, same caveat as the SOAP adapter's
write-verb heuristic below.
Generate a config automatically
legacy2mcp init asks what to point it at, then writes a starting
config.yaml for you:
legacy2mcp initWhat do you want to point legacy2mcp at?
1) WSDL URL (SOAP service)
2) REST API URL (tries OpenAPI/Swagger discovery)
3) A website with no API docs at all (crawls it for forms/content)
4) GitHub repo URL or a local path to its source code
Choice [1-4]:Option | What happens | Needs an LLM key? |
WSDL URL | Points a | No |
REST API URL | Tries the common OpenAPI/Swagger spec locations ( | No |
Website | Crawls same-site pages starting there ( | No |
GitHub repo / local path | Clones (or reads) the repo and statically scans its Python source (AST only, never executed) for a | Optional |
Each also works non-interactively via a flag -- --wsdl-url, --rest-url,
--webpage-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.yamlWhichever 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 serverlegacy2mcp init also writes a plain-English HTML report next to the
config (e.g. config.yaml -> config.html) -- open it in a browser to see,
in sentences rather than YAML, what tools the server will expose (every
REST endpoint / DB table / SOAP target it found, which ones can change data
and are gated behind allow_write_operations, and the review items above).
Pass --html-out to change where it's written, or --no-html-report to
skip it.
Run init from a real terminal against a config that's already usable
(a live --wsdl-url/--rest-url, or a scan that found something concrete)
and it also opens a local test-chat page in your browser, with a
clickable example question under every discovered tool (generated from
that tool's own name and schema, e.g. getPetById -> "get pet by id 4") so
you don't have to guess what to type. Click one, or type your own, and it
matches it to a discovered tool, calls the real adapter, and shows you the
real response. No AI/API key needed for this -- it's local keyword
matching against the tool names/descriptions, not an LLM, just enough to
sanity-check that the server actually works before you wire up a real MCP
client. It's skipped automatically when the config still has TODOs to fill
in (nothing to connect to yet), and never opens at all in
non-interactive/scripted runs. Pass --no-chat to opt out.
See docs/security.md for exactly what this heuristic does and doesn't guarantee before pointing it at anything real.
Discover a legacy interface before generating a server
init covers a live WSDL, a live REST URL, a website (--webpage-url),
and a repo it can scan -- all of those go straight to a config, no
separate step needed. For everything else -- a Postman collection someone
already built, an OpenAPI spec file sitting in a repo with no server
running yet, a database's stored-procedure catalog, a HAR capture of real
traffic, or queues you already know about -- there's an explicit step
first:
legacy2mcp discover --postman-file my-collection.json --out contract.yaml
legacy2mcp discover --openapi-file spec.yaml --out contract.yaml
legacy2mcp discover --har-file traffic.har --out contract.yaml
legacy2mcp discover --stored-procs --db-dialect postgres --db-dsn-env LEGACY_DB_DSN --out contract.yaml
legacy2mcp discover --queue-config queues.yaml --out contract.yamlA website can go through discover --webpage-url too (see below) if you
want to review the contract before anything gets built -- init --webpage-url runs the identical crawl, it just doesn't pause there.
Each writes a Legacy Interface Contract (contract.yaml): every
operation's inputs, outputs, enums, error codes, whether it reads or
writes, and a confidence level -- high for a live schema/catalog,
medium for a curated artifact like a Postman collection, low for
anything inferred from sampled traffic -- plus needs_review flags and
notes on anything uncertain. Review it like you'd review config.yaml's
# TODOs.
Run that same command from a real terminal, though, and discover doesn't
stop at the contract: if the contract has anything invocable in it (not
every source does yet -- see below), it also writes a runnable
config.yaml, opens the same no-API-key test-chat page init can
auto-launch, and prints the same ready-to-paste MCP client JSON --
discover, try it, and get the connection snippet, in the one command you
already ran. Pass --no-chat to skip that and just get the contract (the
default in non-interactive/CI runs already), or --no-write-config to skip
only the config.yaml write.
Prefer to review the contract first before anything gets built or run? Skip straight to either step yourself instead:
legacy2mcp init --from-contract contract.yaml --out config.yaml
legacy2mcp demo --from-contract contract.yamldemo is the same "try it" step discover can auto-launch, just callable
on its own (also works from an existing --config config.yaml, not only a
contract) -- it saves a standalone chat.html pointing at the server it
just started (opened automatically unless --no-open), and prints the
client JSON once a config exists to point it at. The command has to keep
running (Ctrl+C to stop) for that page to work; it's a thin client page
with no backend of its own, so the real HTTP calls still have to be made by
something, same reason the browser can't just open a config file and
call a REST API directly.
A few things worth knowing:
No raw secret is ever written into a contract. A Postman collection's saved bearer token, a HAR capture's
Authorization/Cookieheader values -- none of it gets persisted; only a note that auth was present, for you to configure yourself. The one controlled exception is a small, non-sensitive-looking enum (e.g. astatusfield that only ever took a handful of values across enough samples to mean something) -- see docs/security.md.For
--har-file, legacy2mcp never drives a capture proxy itself -- point it at a HAR produced by anything: your browser devtools' "Export HAR", Postman's own traffic capture, or mitmproxy's built-in exporter (mitmdump --set hardump=out.har, or "Export as HAR" in the GUI). This is the lowest-confidence source (inferred from whatever was sampled, not a declared contract) -- every operation comes back flagged for review.--webpage-urlis for a legacy web app or content site with no API docs at all -- just a URL. It crawls same-site pages from there (--max-pages/--max-depthbound it, default 25 pages / depth 2, never leaving the starting host), turning every<form>into an operation (a<select>'s options, or a shared-name radio/checkbox group, become anenum) and every form-less page with real content into a read-only "get this page" tool. No JavaScript is executed and nothing is ever submitted; non-GET forms get a note to confirm the backend accepts a JSON body (the REST adapter always sends one, even though a real browser would POSTapplication/x-www-form-urlencoded). Same lowest-confidence posture as--har-file-- but unlike every otherdiscoversource,base_urlfills in automatically (it's the host just fetched), so the test-chat page opens right away. Full crawl mechanics: docs/security.md.Stored procedures and queue descriptions are documented, not runnable yet.
--stored-procsand--queue-configstill produce real contract entries -- there's no adapter to actually call either today (see docs/roadmap.md), soinit --from-contractskips them with a clear TODO rather than pretending they work. Same reasondiscover's auto-demo (above) has nothing to launch for a contract made up only of those -- it says so and just exits with the contract already written, rather than failing the whole command.Every generated server also gets a
describe_capabilitiestool for free -- a connected agent can call it to ask "what can this system do?" (every tool's description, side effect, confidence, required fields, known error codes) before calling anything for real.--out contract.htmlwrites a human-readable report instead of the plain YAML -- the same operations, as a table, in your browser. It's still a valid--from-contract/demo --from-contractinput: the full contract is embedded in the page (in a<script type="application/json">tag), not just summarized for reading.
Teach your AI coding agent about legacy2mcp
The sections above are about the MCP servers legacy2mcp produces. This
one is different: it's for when an AI coding agent (Claude Code, Cursor,
Codex CLI, Gemini CLI, GitHub Copilot, ...) is helping you build the
integration in the first place, and you want it to already know how.
legacy2mcp install-skillThis writes short, command-first legacy2mcp instructions directly into the
current repo's own agent context files — a Claude Code slash command
(.claude/commands/legacy2mcp.md), a Cursor rule
(.cursor/rules/legacy2mcp.mdc), and a ## legacy2mcp section appended (or
updated in place) in AGENTS.md, GEMINI.md, and
.github/copilot-instructions.md. The agent then already knows the core
workflow — which init flag fits which source, review the # TODOs before
trusting a generated config, check inspect before running anything live —
without you re-explaining it every session. It's idempotent: re-running it
replaces its own section rather than duplicating it, so it's safe to run
again after an upgrade. Review what it writes and commit it so your whole
team gets the same instructions (this repo does exactly that — see
AGENTS.md).
There's also a standalone SKILL.md in this repo,
written to the open Agent Skills standard
— the same know-how in a form marketplaces like Agensi
can list independently of any particular repo, for agents that discover
skills that way instead.
Common commands
# Print the installed legacy2mcp version
legacy2mcp --version
# Generate a config from a live REST API (tries OpenAPI/Swagger discovery)
legacy2mcp init --rest-url "https://api.example.com" --out config.yaml
# Generate a config from a WSDL URL directly -- no scan needed
legacy2mcp init --wsdl-url "https://service.example.com/Legacy?wsdl" --out config.yaml
# Generate a config from a website with no API docs at all -- crawls it
legacy2mcp init --webpage-url "https://your-legacy-app.example.com" --out config.yaml
# Generate a config by statically scanning a GitHub repo (no AI, no API key)
legacy2mcp init --github-url https://github.com/you/your-api --scan-only --out config.yaml
# Same, with AI-assisted refinement of ambiguous parameter types / read-vs-write
legacy2mcp init --github-url https://github.com/you/your-api --llm-api-key-env OPENAI_API_KEY --out config.yaml
# Review a source before generating anything -- a Postman collection, OpenAPI
# file, HAR capture, stored-procedure catalog, or queue description
legacy2mcp discover --postman-file my-collection.json --out contract.yaml
# Try a config or contract in a chat page right now, no separate init step
legacy2mcp demo --from-contract contract.yaml
legacy2mcp demo --config config.yaml
# Review the tools a config generates -- no server started
legacy2mcp inspect --config config.yaml
# Print the tool list as JSON, for scripting or a CI diff check
legacy2mcp inspect --config config.yaml > tools.json
# Show only one exact tool name
legacy2mcp inspect --config config.yaml --tool legacy_get_order
# Start the MCP server (stdio)
legacy2mcp run --config config.yaml
# Teach the AI coding agent working in *this* repo how to drive legacy2mcp
# (see "Teach your AI coding agent about legacy2mcp" above)
legacy2mcp install-skillClient support
"Verified" here means a specific, narrow thing: the exact command + config
shape shown below was actually spawned and completed a real MCP
initialize handshake over stdio -- not just "MCP should be compatible."
For MCP Inspector, the loop was driven further: a real tools/list, a real
tools/call, and the resulting audit log entry, all
headlessly via the Inspector's own --cli mode. The last badge is what
keeps that claim honest over time rather than a one-time snapshot: a CI job
(mcp-compatibility.yml) re-runs
that same tools/list / tools/call / audit-log loop on every push and
fails the build if the server can't start or exposes the wrong tools. Every
client below talks to legacy2mcp over stdio only -- there's no
HTTP/SSE transport yet (it's on the roadmap, not
shipped), so an http://localhost:... MCP URL won't work here.
Client | Status | How it was verified |
Claude Desktop | Verified | Spawned the exact |
Cursor | Verified | Same, via |
VS Code | Verified | Same, via |
Claude Code | Documented | Uses the identical launch command verified above via |
MCP Inspector | Tested | Full CLI loop -- |
This repo also ships the checked-in configs themselves --
.mcp.json, .cursor/mcp.json, and
.vscode/mcp.json -- pointed at the bundled Petstore
demo, so opening this repo in one of these tools gets you a working server
with no setup. Copy whichever matches your tool into your own project and
point --config at your own config.yaml.
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"legacy2mcp": {
"command": "uvx",
"args": ["legacy2mcp", "run", "--config", "/absolute/path/to/config.yaml"]
}
}
}If you installed with pip/pipx instead of uv, use "command": "legacy2mcp" and drop the
first element of args.
Project-local (.cursor/mcp.json) or global (~/.cursor/mcp.json):
{
"mcpServers": {
"legacy2mcp": {
"command": "uvx",
"args": ["legacy2mcp", "run", "--config", "/absolute/path/to/config.yaml"]
}
}
}{
"servers": {
"legacy2mcp": {
"type": "stdio",
"command": "uvx",
"args": ["legacy2mcp", "run", "--config", "/absolute/path/to/config.yaml"]
}
}
}claude mcp add legacy2mcp -- uvx legacy2mcp run --config /absolute/path/to/config.yamlAdd --scope project to write a shared .mcp.json for your team (keep secrets out of it);
check with claude mcp list.
npx @modelcontextprotocol/inspector uvx legacy2mcp run --config /absolute/path/to/config.yamlOpens a local web UI: connect to the server, confirm tools are listed, run one read-only tool,
and check legacy2mcp-audit.log for the matching entry. That exact loop -- tool discovery, one
real call, the audit log it produces -- is what the "Tested with MCP Inspector" badge above
is actually backed by, driven headlessly through the Inspector's own --cli mode rather than
just opened in a browser.
Passing secrets and troubleshooting: docs/clients.md.
What's handled
Area | Covered |
Type mapping |
|
Structure | nested complex types, repeated elements → arrays, |
Discovery | every service → port → binding → operation; duplicate tool names rejected at startup |
Invocation | argument validation, |
Errors | SOAP faults and transport errors caught and returned as clean messages — no stack traces to the caller |
Auth | HTTP basic (username + |
Transport |
|
DB dialects | Postgres, SQL Server, Oracle — table allowlist ( |
REST | explicit endpoint allowlist (method + path + typed params) from config or |
Discover sources | Postman collections, local OpenAPI/Swagger files, a live DB's stored-procedure catalog, HAR traffic captures, declarative queue descriptions, and websites ( |
| every generated server exposes this meta-tool for free — an agent can ask what the server can do before calling anything |
Try it + connect |
|
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 |
Read-only by default | SOAP: operation names are matched against write-verb prefixes ( |
Explicit allow / deny | SOAP: |
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. |
The SOAP write-operation filter is aname heuristic, not semantic analysis — an operation called ProcessRecord that deletes data would not be caught. For any system where a wrong call has real consequences, set include_operations explicitly and don't rely on the heuristic. The DB adapter's allowlist is explicit by construction, but there's no column-level data masking — an allowlisted column's values are always returned as-is. There is also no auth/authz on the MCP server itself yet — don't expose a server to untrusted callers. Details in docs/security.md.
Data flow and privacy
No telemetry. legacy2mcp collects no usage data and phones home to nothing. Every network call it makes is one you configured it to make — verified by checking every outbound HTTP call site in the source before writing this sentence.
Adapter calls go exactly where you pointed them, nowhere else. SOAP calls go to your
wsdl_url, DB calls go to yourdsn_envconnection, REST calls go to yourbase_url.legacy2mcp init's discovery calls are scoped to what you gave it. The WSDL and REST(OpenAPI) paths only ever contact the URL you passed. The GitHub/source-code path clones the repo you specify and parses it locally withast— nothing from it is ever executed.The optional LLM-assist step sends only already-detected, ambiguous fragments — never your repo. It only runs on the GitHub/source-code path of
legacy2mcp init, only if you provide an API key, and only sends the specific route names/paths/params the static scan already flagged as ambiguous, not a dump of your source. Skip it entirely with--scan-only.legacy2mcp discoverreads local files, except--stored-procs. Postman/OpenAPI/HAR/queue sources never touch the network — they read a file you gave it.--stored-procsconnects to the DSN in the env var you named, the same way thedbadapter does, only to read the procedure/function catalog. No raw secret or sampled value is ever written into the resultingcontract.yaml— see docs/security.md.Secrets never live in
config.yaml. Passwords, DSNs, and API tokens are all referenced by environment-variable name (password_env,dsn_env,token_env) and read at runtime — the YAML stays safe to commit.The audit log stays local and is not encrypted at rest. Every tool call is appended as one JSON line to
security.audit.path. Arguments are logged as-is — there's no PII redaction; see docs/security.md for the full list of what the audit log does and doesn't do.
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 contractExit codes:
Code | Meaning |
| Success |
| A config, source, or runtime error — bad WSDL/DB/REST config, a repo that couldn't be cloned, a missing required environment variable, and so on |
| Invalid command-line arguments (argparse's own default) |
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 |
|
| MCP server name reported to clients |
|
| only |
| — | prefix for this adapter's tool names |
| — |
|
SOAP adapter config:
Key | Default | Meaning |
| — | WSDL location ( |
|
|
|
|
| expose write-like operations |
| all | allowlist of operation names |
|
| denylist of operation names |
|
| per-call SOAP timeout |
DB adapter config:
Key | Default | Meaning |
| — |
|
| — | env var holding the connection string/DSN |
|
| expose |
|
| connection timeout |
| — | table (and optional schema/owner) to allowlist |
|
|
|
| all introspected | column allowlist |
|
| columns usable as equality filters on |
|
| row cap, enforced after the query runs |
| — | a named query; |
|
| gates this query behind |
REST adapter config:
Key | Default | Meaning |
| — | base URL the configured endpoints are relative to |
|
|
|
|
| expose endpoints marked |
|
| per-call HTTP timeout |
| — | one allowlisted endpoint; |
| — |
|
| unset | a closed set of allowed values, enforced by schema validation — not just descriptive |
|
| gates this endpoint behind |
| unset / | only ever populated by |
Security config:
Key | Default | Meaning |
|
| write the audit log |
|
| audit log location |
Documentation
Doc | What it covers |
The full, honest security model — what's validated and audited today, and what isn't yet | |
Shipped vs. planned, version by version | |
Connecting every supported MCP client, secrets, troubleshooting | |
The PyPI / MCP Registry release process | |
Registry / awesome-list submission status and reusable copy | |
Every runnable demo config (SOAP, REST, Docker) | |
A machine-readable project summary for LLMs and agents | |
A SKILL.md for coding agents (Claude Code, Cursor, Copilot, ...) — teaches an agent when and how to drive | |
Notable changes, release by release | |
Dev setup, how to add an adapter, PR expectations |
Roadmap
Version | Scope | Status |
v0.1 | SOAP/WSDL adapter, schema generation, validation, read-only default, audit log, | ✅ 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); | ✅ shipped |
v0.3.x |
| ✅ shipped |
v0.4.x |
| ✅ shipped |
v0.5.x |
| ✅ shipped |
planned | HTTP/SSE transport, role→tool authorization, OAuth2 for SOAP | planned |
ideas | 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/ -vTests 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. See CONTRIBUTING.md for dev setup, how adapters are structured, and PR expectations. This project follows the Code of Conduct. Issues and PRs welcome.
License
Available Tools
3 toolsorders_GetInventoryC
SOAP operation 'GetInventory' on service 'Orders' (adapter: orders).
| Name | Required | Description | Default |
|---|---|---|---|
| sku | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full behavioral burden and mostly does not meet it: it does not confirm whether the call is read-only, whether authentication or a service session is required, or what happens on an unknown SKU. The 'Get' prefix weakly implies a read, which is the only behavioral signal present.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Very short and front-loaded, so it is not verbose, but the one sentence spends its words on adapter/service plumbing ('adapter: orders') rather than information the agent can act on.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
No output schema exists, so the description should say what inventory data comes back, and it does not. For a lookup tool with an undocumented parameter and no annotations, the definition leaves the agent guessing on both input format and return shape.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0% and the description says nothing about the single required 'sku' parameter — no format, casing, or example. The parameter name is largely self-explanatory, so this is not fully opaque, but the description adds zero meaning beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description is essentially a restatement of the operation name plus adapter metadata: 'SOAP operation GetInventory on service Orders.' It conveys that inventory is retrieved, but never says what inventory means (stock levels, availability, warehouse counts) or how it differs from orders_GetOrder / orders_ListOrders beyond the name.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
No guidance at all on when to call this versus the sibling order tools, and no preconditions or exclusions are stated. The agent must infer usage purely from the operation name.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
orders_GetOrderC
SOAP operation 'GetOrder' on service 'Orders' (adapter: orders).
| Name | Required | Description | Default |
|---|---|---|---|
| orderId | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full behavioral burden. It only states that this is a SOAP operation on a service with a given adapter; it does not disclose side effects, authentication requirements, error behavior, or return characteristics, though 'Get' weakly implies a read.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is a single front-loaded sentence with no wasted words. It is appropriately sized, though its brevity contributes to the lack of substantive detail elsewhere.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple one-parameter SOAP operation with no output schema and no annotations, the description still omits essential context: what is returned, any SOAP-specific invocation details, and expected failure modes. It identifies the service and adapter but is not complete enough for confident invocation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema has 0% description coverage for the single required parameter 'orderId', and the description adds no meaning, format, or constraints for it. The parameter name is self-explanatory, but the description does nothing to compensate for the missing schema documentation.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description names the SOAP operation 'GetOrder' on service 'Orders' and identifies the adapter, which implies retrieval of an order. However, it does not state what the operation actually does beyond the name, nor does it explain how it differs from siblings like orders_ListOrders or orders_GetInventory.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
There is no guidance on when to use this operation versus alternatives such as orders_ListOrders or orders_GetInventory. The only implied usage comes from the operation name itself, with no explicit context or exclusions.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
orders_ListOrdersD
SOAP operation 'ListOrders' on service 'Orders' (adapter: orders).
| Name | Required | Description | Default |
|---|---|---|---|
| status | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full burden of behavioral disclosure, but it only identifies the underlying SOAP adapter and operation. It says nothing about pagination, filtering behavior, permissions, or what happens with an empty status.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The sentence is short but wastes its space on adapter metadata rather than useful specification. Brevity here reflects under-specification, not efficiency.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a list operation with an undocumented filter parameter and no output schema, the description omits everything an agent needs: return shape, filtering semantics, and pagination. It is not complete enough to call the tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0% and the single 'status' parameter is undocumented. The description does not describe the parameter's meaning, allowed values, or whether it filters results, leaving the agent with no semantic guidance.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description restates the tool name ('ListOrders' on service 'Orders') without adding a specific verb/resource characterization or scope. It gives no indication of what is returned or how it differs from orders_GetOrder, so it functions largely as a tautology of the name.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
There is no guidance on when to use this tool versus the siblings orders_GetOrder or orders_GetInventory. The only implicit signal is the word 'List' in the name, which is not expanded on.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
3 tool updates
v0.1.0- First observed
orders_GetInventory - First observed
orders_GetOrder - First observed
orders_ListOrders
TDQS
Scored across 3 tools
GetOrder and ListOrders clearly target order retrieval differently, and GetInventory targets a separate resource. Minor overlap in the 'Get' prefix does not meaningfully confuse selection.
All tools follow a consistent adapter-prefixed PascalCase SOAP operation pattern: orders_GetOrder, orders_ListOrders, orders_GetInventory. The mixed Get/List verbs are minor and reflect the underlying SOAP operation names.
Three tools is borderline thin for an orders service, though it may accurately reflect the exposed legacy SOAP operations. It feels scoped but limited.
The surface only supports reading orders and inventory, with no create, update, cancel, or other lifecycle operations. For an order management domain this is a significant gap that would block common agent workflows.
Maintenance
Related MCP Connectors
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Agent-native, pay-per-use MCP tools for web search, email validation, domain intelligence, blockchain lookups and transaction decisions. Machine-readable inputs and outputs designed for autonomous AI agents.
Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 561 connectors: SAP, ERP.
Zero-secret MCP gateway for AI agents: risk-scored, audited calls with human-in-the-loop approval.
Related MCP Servers
- AlicenseCqualityDmaintenanceExposes SOAP-based web services as MCP servers by parsing WSDL files. Enables AI assistants to interact with legacy SOAP/WS-\* web services through the Model Context Protocol.214MIT
- AlicenseNot gradedqualityDmaintenanceExposes SOAP web services as Model Context Protocol (MCP) servers, allowing AI models to interact with legacy SOAP services through automatic method discovery and type mapping.MIT
- FlicenseCqualityDmaintenanceWraps a legacy SOAP + stored-procedure backend with governed MCP tools, including a semantic data dictionary, compensation for transactionless writes, and load protection, enabling AI agents to safely operate on enterprise systems.10-
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to securely discover, invoke, and manage tools through a hardened MCP endpoint with protections like injection detection, circuit breakers, retry backoff, response caching, context-window limiting, and state snapshots.7 npmMIT