Skip to main content

spec2openapi

Convert legacy API specifications — SOAP/WSDL and Swagger 2.0 — into FastMCP-ready OpenAPI 3.x documents.

CI PyPI Python License: Apache-2.0 Code of Conduct

한국어 문서 (Korean README)


MCP (Model Context Protocol) tooling such as FastMCP can turn an OpenAPI 3.x document into an MCP server automatically — but enterprises are full of services described only by WSDL or Swagger 2.0. spec2openapi closes that gap:

WSDL ─────────┐
              ├──(spec2openapi)──> OpenAPI 3.x (+ x-soap extensions) ──> FastMCP.from_openapi() ──> MCP tools
Swagger 2.0 ──┘

The two inputs produce two kinds of output — this distinction matters:

  • Swagger 2.0 → a plain, standard OpenAPI 3.x document. The paths are the real REST endpoints. Any OpenAPI-driven runtime (FastMCP, or your own httpx-based server) serves it with zero runtime changes — just point it at the converted spec.
  • WSDL → OpenAPI 3.x + an x-soap contract. The generated /operations/... paths are not real REST endpoints; each tool call must be serialized to a SOAP envelope, sent to the SOAP endpoint, and the XML response parsed back to JSON. That logic is not part of a standard OpenAPI runtime — it lives in the SOAP bridge shipped in the [mcp] extra. Serving a SOAP-converted spec with a plain OpenAPI/httpx runtime will POST JSON to the SOAP endpoint and fail every call.

So spec2openapi is a converter for Swagger 2.0, and a converter + runtime contract (with a reference bridge) for SOAP. See How SOAP calls work below.

The fixed-runtime deployment model — build one image, swap the spec via a Kubernetes ConfigMap to mass-produce MCP servers — applies to both, as long as the image includes the [mcp] extra when serving SOAP specs.

Features

  • WSDL → OpenAPI 3.0/3.1 — document/literal and rpc/literal bindings, SOAP 1.1/1.2, nested complex types, arrays, attributes, nillable, inheritance (flattened complexContent extensions), simpleContent (text value + attributes), choice (members become optional + x-soap-choice), substitution groups (a head reference becomes a oneOf of self-describing member branches + x-soap-substitution), default values, recursive types, multi-service/multi-port WSDLs with automatic dedup.
  • XSD facets & docs carried into tool schemas — enumerations, pattern, length and numeric bounds, fractionDigits (→ multipleOf), and xsd:annotation documentation are extracted (including from xsd:import-ed schemas), plus deterministic example values (the first enumeration value; canonical date/time formatting illustrations) so LLMs see well-described, well-constrained tool arguments.
  • x-soap contract — SOAPAction, SOAP version, endpoint, wrapper element QNames, soap:header parts and declared faults are embedded as vendor extensions; OpenAPI xml annotations carry everything a call layer needs to serialize JSON ↔ literal XML.
  • Swagger 2.0 → OpenAPI 3.x upgrade — full mechanical mapping (servers, requestBody, formData/multipart, parameter schema wrapping, collectionFormat → style/explode, $ref rewriting, security schemes, type: file, x-nullable, discriminator), hardened against real-world documents: deep local $refs are hoisted to components, dangling refs and duplicate parameters are neutralized, type-mismatched defaults are coerced, and common vendor extensions (x-example, x-oneOf, x-anyOf) are promoted to native keywords. Every assumption made for missing information is recorded in x-s2o.assumptions; untranslatable constructs are preserved as x- extensions and listed in x-s2o.lossy.
  • Real OpenAPI 3.1 output — --openapi-version 3.1 is a semantic conversion to JSON Schema 2020-12 style (nullable → type arrays, boolean exclusiveMinimum/exclusiveMaximum → numeric bounds), not a version-string bump.
  • FastMCP compatibility, guaranteed and verifiable — operationIds are generated as names FastMCP 4 exposes unchanged ([A-Za-z0-9_], unique, ≤56 chars, no double or edge underscores) so tool name == operationId. spec2openapi validate proves it: static checks, openapi-spec-validator, and a real FastMCP.from_openapi() round-trip that checks every tool is exposed under its operationId.
  • Structured verification (verify) — a library API that runs 22 checks over a converted document (Swagger- and WSDL-converted alike): document shape, MCP tool-name rules (SEP-986), tool descriptions, the full x-soap contract, and — with the optional deps installed — openapi-spec-validator plus the FastMCP in-memory round-trip. Returns a deterministic, JSON-serializable report in which every check carries normative citations and "could not check" (skip) is always distinguishable from "checked and passed". verify never raises; spec2openapi validate --format json exposes the same report on the CLI.
  • MCP tool-payload minification (optional) — minify_for_mcp() shrinks and enriches what FastMCP actually sends the model: foreign vendor extensions are stripped from schema subtrees, and error responses / payload examples — which the MCP tool shape otherwise drops — can be folded into tool descriptions deterministically. Serving behavior is unchanged. See Minifying the LLM-facing surface.
  • LLM-filled descriptions (optional) — spec2openapi agentize writes the descriptions a source spec leaves empty: schema properties, operation summaries, parameter docs; optionally readable tool names (--rename-tools). The model returns strings only, into a fixed JSON Pointer whitelist; every suggestion carries a grounding grade and ungrounded ones are dropped; the result must pass verify() or nothing is written. See Filling the gaps with an LLM.
  • SOAP bridge — required to serve SOAP specs — pip install "spec2openapi[mcp]" adds the bridge (custom httpx2 transport) that implements the x-soap contract, plus FastMCP glue, a fixed Dockerfile, and Kubernetes examples. SOAP faults map to MCP tool errors. Swagger-converted (pure REST) specs do not need this — any OpenAPI runtime serves them. Only SOAP-converted specs require the bridge at runtime.

Installation

Requires Python 3.10+. (On an older interpreter, pip reports No matching distribution found because every release is filtered out by the version floor.)

pip install spec2openapi          # converter + CLI (zeep, lxml, PyYAML)
pip install "spec2openapi[mcp]"   # + SOAP bridge & runtime — required to serve SOAP specs

The core install is enough to convert any spec and to serve Swagger-converted (REST) specs from your own runtime. The [mcp] extra is required only to serve SOAP-converted specs (it provides the bridge that turns JSON tool calls into SOAP envelopes). It installs FastMCP 4 and httpx2.

Quick start

CLI

# See what a WSDL contains (operations, headers, faults, style)
spec2openapi inspect https://legacy-host/OrderService?wsdl

# WSDL -> OpenAPI (also accepts a zip bundle or '-' for stdin)
spec2openapi convert https://legacy-host/OrderService?wsdl -o orders.openapi.yaml
spec2openapi convert vendor-bundle.zip -o orders.openapi.yaml
spec2openapi convert - < service.wsdl

# Swagger 2.0 -> OpenAPI 3.x (assumptions reported on stderr)
spec2openapi upgrade swagger2.json -o service.openapi.yaml

# Prove the spec converts cleanly into MCP tools
spec2openapi validate orders.openapi.yaml
spec2openapi validate orders.openapi.yaml --format json   # machine-readable report

# Fill empty descriptions with an LLM (optional extra; see below)
spec2openapi agentize service.openapi.yaml --dry-run

# Reference MCP runtime (requires the [mcp] extra)
spec2openapi serve orders.openapi.yaml --transport http --port 8000
$ spec2openapi validate orders.openapi.yaml
operations        : 2
component schemas : 3
openapi-spec-validator: OK
FastMCP round-trip: OK (2 tools)
  - CreateOrder(customer, items, note)
  - GetOrder(orderId)

OK: spec is FastMCP-convertible

Library

import spec2openapi
from spec2openapi import ConversionError

# Swagger 2.0 -> OpenAPI dict (input may be a path or an http(s) URL)
try:
    legacy = spec2openapi.load_spec("swagger2.json")
    spec = spec2openapi.convert_swagger(legacy, openapi_version="3.1")
except ConversionError as exc:      # every failure path raises this
    raise SystemExit(f"conversion failed: {exc}")

# everything the converter assumed or could not translate, per document
report = spec.get("x-s2o", {})
report.get("assumptions", [])       # e.g. "missing consumes -> application/json"
report.get("lossy", [])             # e.g. "collectionFormat 'tsv' preserved as x-"
# pipelines that must not accept guesses: convert_swagger(legacy, strict=True)

# the FastMCP-readiness contract as a function (empty list == ready)
problems = spec2openapi.check_fastmcp_ready(spec)

# structured verification: the same contract plus the x-soap checks,
# openapi-spec-validator, and a FastMCP in-memory round-trip
result = spec2openapi.verify(spec)   # Swagger- and WSDL-converted alike
result.ok          # no failed checks
result.complete    # nothing skipped (deep deps installed, so they ran)
result.to_dict()   # JSON-serializable report with per-check citations

# WSDL -> OpenAPI dict (zeep loads on first SOAP use, not at import)
spec = spec2openapi.convert_wsdl(
    "https://legacy-host/OrderService?wsdl",
    forbid_external=True,           # refuse remote imports from untrusted WSDLs
)

# WSDL input is flexible: a path/URL, a zip bundle, the document itself,
# or an in-memory multi-file bundle whose relative imports resolve locally
spec = spec2openapi.convert_wsdl(content=wsdl_bytes)
spec = spec2openapi.convert_wsdl(files={"service.wsdl": wsdl_bytes,
                                        "types.xsd": xsd_bytes})

print(spec2openapi.dump_spec(spec))            # YAML text (fmt="json" for JSON)

# Optional [mcp] extra: run it as an MCP server right away
mcp = spec2openapi.from_openapi_spec(spec)
mcp.run(transport="http", host="0.0.0.0", port=8000)

The public API is exactly spec2openapi.__all__ (typed, PEP 561); anything else is internal and may change without notice. All entry points report failures as ConversionError (a ValueError subclass). Returned documents may share substructures with the input mapping (example/default/enum and x-* values are not deep-copied) — copy.deepcopy the result before mutating it if you keep using the input.

How SOAP calls work (the x-soap contract)

The generated paths (/operations/...) are not real REST endpoints — a SOAP translation layer must build the actual call. Everything it needs ships inside the spec:

Field (paths.*.post.x-soap) Meaning
operation / service / port WSDL names
soapAction, soapVersion, style "1.1"/"1.2", document/rpc
endpoint soap:address (override at runtime)
input / output wrapper element QNames
headers[] soap:header parts with schema refs
faults[] declared faults with schema refs

Declared soap:header values (headers[]) never come from tool arguments — the runtime supplies them via BridgeOptions.soap_headers / SPEC2OPENAPI_SOAP_HEADERS (a JSON object keyed by part or element name). An operation that declares a header with no configured value is sent without it and logs a warning (once per operation/header).

Serialization rules (schema xml annotations): xml.name/xml.namespace (absent namespace = unqualified), xml.attribute: true, xml.x-text: true (simpleContent text), arrays repeat the element, and property order = XSD sequence order (do not alphabetize the document). x-soap-choice lists mutually exclusive property groups: each entry in a group's members is one branch of the xsd:choice — a bare property name for the usual single-element branch, or a list of names when the branch is itself a sequence, whose elements belong together and are chosen or omitted as a unit. x-soap-substitution marks a substitution-group value: the JSON is a self-describing single-key object ({"creditCard": {…}}) and the wire carries that member element itself — the head element never appears.

The [mcp] extra contains a verified implementation of this contract (src/spec2openapi/bridge.py) — use it directly (via spec2openapi serve) or as the reference for your own runtime. There is no way to serve a SOAP-converted spec without an implementation of this contract; a standard OpenAPI runtime cannot do it.

Mixed SOAP + REST specs. The reference runtime routes all traffic through the SOAP bridge if any path carries x-soap, so REST operations in a mixed spec are not served correctly today. Keep SOAP and REST specs separate until this is addressed (tracking issue).

Handling missing information (Swagger 2.0)

Upgrading is favorable: OpenAPI 3.x is a superset of Swagger 2.0, so almost nothing must be invented. Where documents are genuinely underspecified, a three-tier policy applies:

  1. Deterministic, documented defaults — missing consumes/produces → application/json; missing operationId → {method}_{path}; missing host → relative server /; missing schemes → https. All recorded in x-s2o.assumptions.
  2. Preserve, never drop — constructs with no OpenAPI 3 equivalent (e.g. collectionFormat: tsv) are kept as x- extensions and listed in x-s2o.lossy.
  3. Verify the outcome — spec2openapi validate runs the actual FastMCP round-trip; assumptions never block tool generation because tools only need paths and schemas.

Pipelines that must not accept guessed conversions can pass --strict to upgrade (or strict=True to convert_swagger): the conversion then fails with the full list of assumption/lossy records instead of applying them.

Minifying the LLM-facing surface (optional)

When a spec is served over MCP, the model never reads the OpenAPI document. FastMCP sends the tool list: name, description, inputSchema, and an outputSchema built from the 2xx response schema. Everything else — info, unused components, error responses, media-type examples, the $ref structure — stays on the server. That has two costs: schema content is copied into tool schemas verbatim, so machine-to-machine vendor extensions burn context tokens in every tools/list; and human-authored facts with no slot in the tool shape (error responses, request/response examples) silently vanish.

minify_for_mcp addresses both directions as an optional post-processing step — the conversion pipeline itself is unchanged:

spec = spec2openapi.convert_swagger(legacy)          # or convert_wsdl(...)
spec = spec2openapi.minify_for_mcp(spec, enrich=("errors", "examples"))
  • Default: foreign vendor x-* extensions are removed from schema subtrees (the locations FastMCP copies into tool payloads). Everything a runtime or a reader needs survives: x-soap* and xml annotations (SOAP bridge), x-s2o, x-fastmcp-*, the project's preservation extensions (x-pattern, x-collectionFormat), and documentation-bearing extensions (x-enum-varnames, x-example, ...). Protect your own with keep_extensions=("x-acme-*",).
  • enrich: "errors" folds error responses into the description (Errors: 404 (not found); ... — they never reach the model otherwise); "examples" folds request/response payload examples (Example request: {...}) and hoists parameter-level example values into the parameter schema. Only facts already in the document are used — nothing is invented, and re-runs never duplicate a fold.
  • Opt-in trims: max_description=N caps the descriptions that reach the payload (truncated text is marked with …); drop_value_examples=True strips in-schema and parameter-level examples — they usually help the model format arguments, so measure before enabling.

For any option combination the output is a valid OpenAPI document, still passes check_fastmcp_ready, and serves identically — no option touches the callable surface, and SOAP bridge envelopes are byte-identical. What was removed or folded is summarized under x-s2o.minify. Minification is one-way: write the result to a separate file and keep the original.

Large specs: control the tool count with route maps

minify_for_mcp trims each tool, but on a large service the dominant context cost is the number of tools: every operation becomes one, and MCP clients load the entire tools/list result into the model's context on every request. Measured on examples/, one tool payload is roughly 0.3–1.7 KB — a 100-operation service ships tens of KB before the conversation starts, and a model choosing among 100 tools misfires more often than one choosing among 5. An agent usually needs a handful.

Choosing which operations become tools is the OpenAPI→MCP layer's job, and FastMCP already owns it with route maps. from_openapi_spec forwards extra keyword arguments to FastMCP.from_openapi, so route maps pass straight through — and the SOAP bridge keeps working, because excluded operations simply never receive a call:

from fastmcp.server.providers.openapi import MCPType, RouteMap

mcp = spec2openapi.from_openapi_spec(
    spec,
    route_maps=[
        RouteMap(tags={"OrderService"}, mcp_type=MCPType.TOOL),  # keep these
        RouteMap(mcp_type=MCPType.EXCLUDE),                      # drop the rest
    ],
)

WSDL-converted specs tag every operation with its service name, so service-per-agent subsets need no extra tagging. The reference CLI (spec2openapi serve) does not expose route maps — use the Python entry point when you need a subset.

Filling the gaps with an LLM (optional)

spec2openapi agentize uses an LLM to write the descriptions a source spec leaves empty — schema properties, operation summaries, parameter docs — so the tool surface an agent reads says what to send.

pip install 'spec2openapi[llm-anthropic]'
export ANTHROPIC_API_KEY=...          # or: ant auth login

# Swagger 2.0 / OpenAPI 3.x
spec2openapi agentize petstore.json --dry-run          # what would change
spec2openapi agentize petstore.json -o petstore.openapi.json

# WSDL — the same command; conversion happens first
spec2openapi agentize service.wsdl -o service.openapi.yaml

OpenAI works the same way; pass --model explicitly:

pip install 'spec2openapi[llm-openai]'
export OPENAI_API_KEY=...
spec2openapi agentize petstore.json --provider openai --model <model-id> -o out.json

Credentials come from the environment — ANTHROPIC_API_KEY or OPENAI_API_KEY, read natively by each SDK; the Anthropic SDK also authenticates from an ant auth login profile. --dry-run needs none, since it returns before a provider is constructed.

Flag Effect
--dry-run list what would be filled; makes no API call
--target properties,desc,params,examples choose what to fill, in priority order
--rename-tools replace machine-generated operationIds with readable tool names
--self-check re-read the finished tool payload and report operations still ambiguous
--allow-speculative apply suggestions the model graded as unfounded
--overwrite replace existing descriptions regardless of quality
--language <name> output language; detected from the spec when omitted
--max-ops N stop before calling if the spec exceeds N operations

The output language is decided before the first call — detected from the prose already in the spec, not left to the model — and printed with the preflight line so you can see it before paying for anything. Override it with --language.

What a run guarantees:

  • The model returns strings only, and where they may be written is a fixed whitelist — type, required, $ref, x-soap and xml are unreachable by any response.
  • Every suggestion carries a grounding grade (named / documented / inferred / speculative); ungrounded guesses are dropped unless --allow-speculative is given.
  • A run that writes anything must pass verify(), or nothing is written. An input that fails verify() in a way the run cannot fix is rejected before the first model call, and --dry-run says so. Fields the run fills — and, with --rename-tools, a missing or rule-breaking operationId on an operation it queries — are left for it to fix.
  • An array / object field only gets an example that parses as finite JSON of that shape; a field whose type comes from $ref or an untyped allOf / oneOf / anyOf gets none.
  • A rewritten operation description keeps the Errors: ... / Example ...: lines minify_for_mcp folded into it.
  • What was generated is recorded under x-s2o.agentize as JSON Pointers, outside the schema nodes — so re-runs are idempotent and a reviewer can tell model-written prose from the rest.

agentize also applies minify_for_mcp's deterministic clean-up, so that appears in the diff too. The output is a text spec; review it with git diff.

Kubernetes: one image, many MCP servers

docker build -t spec2openapi:0.8.0 .
spec2openapi convert <wsdl> -o openapi.yaml
kubectl create configmap my-mcp-spec --from-file=openapi.yaml
kubectl apply -f k8s/example.yaml    # Deployment mounts /config/openapi.yaml

Only the ConfigMap changes per service; credentials live in a Secret (SPEC2OPENAPI_ENDPOINT, SPEC2OPENAPI_AUTH = basic|wsse, SPEC2OPENAPI_USERNAME/PASSWORD, SPEC2OPENAPI_TIMEOUT, SPEC2OPENAPI_VERIFY, SPEC2OPENAPI_TRUST_ENV, SPEC2OPENAPI_SOAP_HEADERS). TLS certificates are checked against the operating system's trust store; with SPEC2OPENAPI_TRUST_ENV on, SSL_CERT_FILE / SSL_CERT_DIR take precedence. The MCP endpoint is http://<service>:8000/mcp (streamable HTTP).

Limitations

rpc/encoded (skipped and recorded in x-soap.skippedOperations), MTOM/attachments, and WS-Policy/WS-Addressing are not supported. Substitution-group blocking/final constraints are ignored. WS-Security support in the reference runtime is UsernameToken (PasswordText).

Security

All XML parsing disables DTD loading, entity resolution, and parser-level network access. When converting WSDLs from untrusted sources, add --forbid-external (CLI) or forbid_external=True (API) to refuse fetching remote wsdl:/xsd: imports (SSRF mitigation; local relative imports still work). See SECURITY.md for the full notes and how to report vulnerabilities.

Development

git clone https://github.com/Seo-yul/spec2openapi.git
cd spec2openapi
pip install -e ".[dev]"
python -m pytest tests/

The suite covers conversion units, the Swagger upgrader, envelope (de)serialization, end-to-end MCP-tool-call → mock-SOAP-server round-trips (rpc, simpleContent, choice, recursive trees, unqualified forms), FastMCP round-trips for every fixture × OpenAPI 3.0/3.1, and stress patterns (circular $refs, deep nesting, large enums, cross-namespace name collisions, duplicate operation names across services, odd path characters, deep allOf chains). Generated samples live in examples/.

An opt-in corpus sweep additionally runs the Swagger upgrader over a stratified sample of real-world Swagger 2.0 definitions from the public APIs.guru directory (fetched at test time, cached locally, never committed), checking every output against openapi-spec-validator (3.0 and 3.1) and a live FastMCP.from_openapi() round-trip:

python -m pytest -m corpus     # network required; see tests/corpus/

Known failures would be tracked in tests/corpus/known_failures.txt with issue links so the sweep only fails on regressions — the list is currently empty: the full APIs.guru Swagger 2.0 population (975 testable documents) was swept during the 0.3.0 cycle, every converter defect it surfaced was fixed, and every sampled document passes all three oracles.

Project layout

src/spec2openapi/
  parser.py    WSDL parsing (zeep) + raw XSD scraping (facets/docs)
  schema.py    XSD -> JSON Schema (xml annotations, choice, simpleContent)
  openapi.py   OpenAPI 3.0/3.1 assembly + x-soap extensions
  swagger.py   Swagger 2.0 -> OpenAPI 3.x upgrader (x-s2o report)
  convert.py   core public API
  checks.py    structured verification (verify / VerifyReport)
  minify.py    optional shrink/enrich of the MCP tool payload
  agentize/    [llm-*] optional LLM-written descriptions (quarantined)
  cli.py       convert / upgrade / inspect / validate / agentize / serve
  bridge.py    [mcp] SOAP bridge (httpx2 transport)
  server.py    [mcp] FastMCP glue

Contributing

Contributions are welcome — see CONTRIBUTING.md. This project follows the Contributor Covenant Code of Conduct; by participating you agree to uphold it. Security issues should be reported privately per SECURITY.md.

License

Apache-2.0 © Seoyul Yoon

Metadata

Release files for spec2openapi 0.8.0

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

Source distribution (sdist)

Source distribution for spec2openapi 0.8.0
File Size Uploaded
spec2openapi-0.8.0.tar.gz 208.7 kB Details

Built distribution (wheel)

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

Total release size: 336.8 kB

Release files / spec2openapi-0.8.0.tar.gz

Download URL spec2openapi-0.8.0.tar.gz
Size 208.7 kB
Tags Source
SHA-256 checksum
How to use checksums
780c5a3b1727c03c7ba129eb48ad8d55ce66156951e467582decd28271457b5f
BLAKE2b-256 checksum
How to use checksums
e04d35fb811b5be481a60bb02e89853fc2fb507e784d85c165ff1a21442c7844
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release files / spec2openapi-0.8.0-py3-none-any.whl

Download URL spec2openapi-0.8.0-py3-none-any.whl
Size 128.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
882de5b7e3d8d3f9362046d065a71bc06acbe376b8194c0106809e8d06a0bc2d
BLAKE2b-256 checksum
How to use checksums
f5d3e2ea07ff002cd53b2c1ac84873063b01bf048414f96b005e5b4088e7bc03
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release history Release notifications | RSS feed

This release

0.8.0 This release

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

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