spec2openapi 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {spec2openapi-0.1.0/src/spec2openapi.egg-info → spec2openapi-0.2.0}/PKG-INFO +19 -8
  2. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/README.md +18 -7
  3. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/pyproject.toml +1 -1
  4. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/__init__.py +1 -1
  5. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/bridge.py +100 -14
  6. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/cli.py +39 -14
  7. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/convert.py +12 -5
  8. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/openapi.py +51 -10
  9. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/parser.py +6 -1
  10. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/schema.py +47 -21
  11. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/server.py +20 -0
  12. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/swagger.py +69 -10
  13. {spec2openapi-0.1.0 → spec2openapi-0.2.0/src/spec2openapi.egg-info}/PKG-INFO +19 -8
  14. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/SOURCES.txt +3 -0
  15. spec2openapi-0.2.0/tests/test_cli.py +24 -0
  16. spec2openapi-0.2.0/tests/test_edgecases.py +217 -0
  17. spec2openapi-0.2.0/tests/test_runtime.py +124 -0
  18. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/LICENSE +0 -0
  19. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/NOTICE +0 -0
  20. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/setup.cfg +0 -0
  21. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/dependency_links.txt +0 -0
  22. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/entry_points.txt +0 -0
  23. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/requires.txt +0 -0
  24. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/top_level.txt +0 -0
  25. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_advanced.py +0 -0
  26. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_bridge.py +0 -0
  27. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_convert.py +0 -0
  28. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_e2e.py +0 -0
  29. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_fastmcp_compat.py +0 -0
  30. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_stress.py +0 -0
  31. {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_swagger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spec2openapi
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Convert legacy API specs (SOAP/WSDL, Swagger 2.0) into FastMCP-ready OpenAPI 3.x documents
5
5
  Author-email: Seoyul Yoon <devops.reso@gmail.com>
6
6
  License: Apache-2.0
@@ -61,7 +61,14 @@ WSDL ─────────┐
61
61
  Swagger 2.0 ──┘
62
62
  ```
63
63
 
64
- The output is a single, ordinary OpenAPI document. It is designed for a **fixed-runtime deployment model**: build one FastMCP-based container image, then swap the spec (e.g. a Kubernetes ConfigMap) to mass-produce MCP servers without rebuilding anything.
64
+ The two inputs produce two kinds of output this distinction matters:
65
+
66
+ - **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.
67
+ - **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.
68
+
69
+ 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](#how-soap-calls-work-the-x-soap-contract) below.
70
+
71
+ 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.
65
72
 
66
73
  ## Features
67
74
 
@@ -70,15 +77,17 @@ The output is a single, ordinary OpenAPI document. It is designed for a **fixed-
70
77
  - **`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.
71
78
  - **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). 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`.
72
79
  - **FastMCP compatibility, guaranteed and verifiable** — operationIds are generated in FastMCP's tool-name alphabet (`[A-Za-z0-9_]`, unique, ≤64 chars) so *tool name == operationId*. `spec2openapi validate` proves it: static checks, `openapi-spec-validator`, and a real `FastMCP.from_openapi()` round-trip listing the resulting tools.
73
- - **Reference MCP runtime (optional)** — `pip install "spec2openapi[mcp]"` adds a verified SOAP bridge (custom httpx transport) + FastMCP glue, a fixed Dockerfile, and Kubernetes examples. SOAP faults map to MCP tool errors; plain REST specs are served by the same runtime.
80
+ - **SOAP bridge required to *serve* SOAP specs** — `pip install "spec2openapi[mcp]"` adds the bridge (custom httpx 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.
74
81
 
75
82
  ## Installation
76
83
 
77
84
  ```bash
78
- pip install spec2openapi # converter only (zeep, lxml, PyYAML)
79
- pip install "spec2openapi[mcp]" # + reference MCP runtime (fastmcp, httpx)
85
+ pip install spec2openapi # converter + CLI (zeep, lxml, PyYAML)
86
+ pip install "spec2openapi[mcp]" # + SOAP bridge & runtime required to serve SOAP specs
80
87
  ```
81
88
 
89
+ > 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).
90
+
82
91
  ## Quick start
83
92
 
84
93
  ### CLI
@@ -146,7 +155,9 @@ The generated paths (`/operations/...`) are *not* real REST endpoints — a SOAP
146
155
 
147
156
  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.
148
157
 
149
- The `[mcp]` extra contains a verified implementation of this contract (`src/spec2openapi/bridge.py`) — use it directly or as the reference for your own runtime.
158
+ 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.
159
+
160
+ > **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](https://github.com/Seo-yul/spec2openapi/issues)).
150
161
 
151
162
  ## Handling missing information (Swagger 2.0)
152
163
 
@@ -159,7 +170,7 @@ Upgrading is favorable: OpenAPI 3.x is a superset of Swagger 2.0, so almost noth
159
170
  ## Kubernetes: one image, many MCP servers
160
171
 
161
172
  ```bash
162
- docker build -t spec2openapi:0.1.0 .
173
+ docker build -t spec2openapi:0.2.0 .
163
174
  spec2openapi convert <wsdl> -o openapi.yaml
164
175
  kubectl create configmap my-mcp-spec --from-file=openapi.yaml
165
176
  kubectl apply -f k8s/example.yaml # Deployment mounts /config/openapi.yaml
@@ -184,7 +195,7 @@ pip install -e ".[dev]"
184
195
  python -m pytest tests/
185
196
  ```
186
197
 
187
- The suite (70 tests) 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 `$ref`s, deep nesting, large enums, cross-namespace name collisions, duplicate operation names across services, odd path characters, deep `allOf` chains). Generated samples live in [`examples/`](examples/).
198
+ The suite (103 tests) 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 `$ref`s, deep nesting, large enums, cross-namespace name collisions, duplicate operation names across services, odd path characters, deep `allOf` chains). Generated samples live in [`examples/`](examples/).
188
199
 
189
200
  ## Project layout
190
201
 
@@ -20,7 +20,14 @@ WSDL ─────────┐
20
20
  Swagger 2.0 ──┘
21
21
  ```
22
22
 
23
- The output is a single, ordinary OpenAPI document. It is designed for a **fixed-runtime deployment model**: build one FastMCP-based container image, then swap the spec (e.g. a Kubernetes ConfigMap) to mass-produce MCP servers without rebuilding anything.
23
+ The two inputs produce two kinds of output this distinction matters:
24
+
25
+ - **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.
26
+ - **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.
27
+
28
+ 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](#how-soap-calls-work-the-x-soap-contract) below.
29
+
30
+ 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.
24
31
 
25
32
  ## Features
26
33
 
@@ -29,15 +36,17 @@ The output is a single, ordinary OpenAPI document. It is designed for a **fixed-
29
36
  - **`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.
30
37
  - **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). 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`.
31
38
  - **FastMCP compatibility, guaranteed and verifiable** — operationIds are generated in FastMCP's tool-name alphabet (`[A-Za-z0-9_]`, unique, ≤64 chars) so *tool name == operationId*. `spec2openapi validate` proves it: static checks, `openapi-spec-validator`, and a real `FastMCP.from_openapi()` round-trip listing the resulting tools.
32
- - **Reference MCP runtime (optional)** — `pip install "spec2openapi[mcp]"` adds a verified SOAP bridge (custom httpx transport) + FastMCP glue, a fixed Dockerfile, and Kubernetes examples. SOAP faults map to MCP tool errors; plain REST specs are served by the same runtime.
39
+ - **SOAP bridge required to *serve* SOAP specs** — `pip install "spec2openapi[mcp]"` adds the bridge (custom httpx 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.
33
40
 
34
41
  ## Installation
35
42
 
36
43
  ```bash
37
- pip install spec2openapi # converter only (zeep, lxml, PyYAML)
38
- pip install "spec2openapi[mcp]" # + reference MCP runtime (fastmcp, httpx)
44
+ pip install spec2openapi # converter + CLI (zeep, lxml, PyYAML)
45
+ pip install "spec2openapi[mcp]" # + SOAP bridge & runtime required to serve SOAP specs
39
46
  ```
40
47
 
48
+ > 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).
49
+
41
50
  ## Quick start
42
51
 
43
52
  ### CLI
@@ -105,7 +114,9 @@ The generated paths (`/operations/...`) are *not* real REST endpoints — a SOAP
105
114
 
106
115
  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.
107
116
 
108
- The `[mcp]` extra contains a verified implementation of this contract (`src/spec2openapi/bridge.py`) — use it directly or as the reference for your own runtime.
117
+ 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.
118
+
119
+ > **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](https://github.com/Seo-yul/spec2openapi/issues)).
109
120
 
110
121
  ## Handling missing information (Swagger 2.0)
111
122
 
@@ -118,7 +129,7 @@ Upgrading is favorable: OpenAPI 3.x is a superset of Swagger 2.0, so almost noth
118
129
  ## Kubernetes: one image, many MCP servers
119
130
 
120
131
  ```bash
121
- docker build -t spec2openapi:0.1.0 .
132
+ docker build -t spec2openapi:0.2.0 .
122
133
  spec2openapi convert <wsdl> -o openapi.yaml
123
134
  kubectl create configmap my-mcp-spec --from-file=openapi.yaml
124
135
  kubectl apply -f k8s/example.yaml # Deployment mounts /config/openapi.yaml
@@ -143,7 +154,7 @@ pip install -e ".[dev]"
143
154
  python -m pytest tests/
144
155
  ```
145
156
 
146
- The suite (70 tests) 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 `$ref`s, deep nesting, large enums, cross-namespace name collisions, duplicate operation names across services, odd path characters, deep `allOf` chains). Generated samples live in [`examples/`](examples/).
157
+ The suite (103 tests) 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 `$ref`s, deep nesting, large enums, cross-namespace name collisions, duplicate operation names across services, odd path characters, deep `allOf` chains). Generated samples live in [`examples/`](examples/).
147
158
 
148
159
  ## Project layout
149
160
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spec2openapi"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Convert legacy API specs (SOAP/WSDL, Swagger 2.0) into FastMCP-ready OpenAPI 3.x documents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -7,7 +7,7 @@ Optional MCP runtime (pip install 'spec2openapi[mcp]'): from_openapi_spec,
7
7
  from_wsdl, BridgeOptions, SoapBridgeTransport.
8
8
  """
9
9
 
10
- __version__ = "0.1.0"
10
+ __version__ = "0.2.0"
11
11
 
12
12
  from .convert import convert_wsdl, load_spec, spec_has_soap # noqa: E402,F401
13
13
  from .openapi import build_spec, dump_spec, to_openapi_31 # noqa: E402,F401
@@ -37,6 +37,36 @@ PASSWORD_TEXT = (
37
37
  )
38
38
 
39
39
 
40
+ _FALSEY = {"0", "false", "no", "off", ""}
41
+ _TRUTHY = {"1", "true", "yes", "on"}
42
+
43
+
44
+ def _env_bool(name: str, default: bool) -> bool:
45
+ raw = os.getenv(name)
46
+ if raw is None:
47
+ return default
48
+ v = raw.strip().lower()
49
+ if v in _FALSEY:
50
+ return False
51
+ if v in _TRUTHY:
52
+ return True
53
+ logger.warning("env %s=%r not recognized as boolean; using %s",
54
+ name, raw, default)
55
+ return default
56
+
57
+
58
+ def _env_float(name: str, default: float) -> float:
59
+ raw = os.getenv(name)
60
+ if raw is None or not raw.strip():
61
+ return default
62
+ try:
63
+ return float(raw)
64
+ except ValueError:
65
+ logger.warning("env %s=%r is not a number; using %s",
66
+ name, raw, default)
67
+ return default
68
+
69
+
40
70
  @dataclasses.dataclass
41
71
  class BridgeOptions:
42
72
  """Runtime options, typically injected via env vars in the container."""
@@ -57,10 +87,9 @@ class BridgeOptions:
57
87
  username=os.getenv("SPEC2OPENAPI_USERNAME") or None,
58
88
  password=os.getenv("SPEC2OPENAPI_PASSWORD") or None,
59
89
  auth=os.getenv("SPEC2OPENAPI_AUTH") or None,
60
- timeout=float(os.getenv("SPEC2OPENAPI_TIMEOUT", "30")),
61
- verify=os.getenv("SPEC2OPENAPI_VERIFY", "1") not in ("0", "false", "False"),
62
- trust_env=os.getenv("SPEC2OPENAPI_TRUST_ENV", "1")
63
- not in ("0", "false", "False"),
90
+ timeout=_env_float("SPEC2OPENAPI_TIMEOUT", 30.0),
91
+ verify=_env_bool("SPEC2OPENAPI_VERIFY", True),
92
+ trust_env=_env_bool("SPEC2OPENAPI_TRUST_ENV", True),
64
93
  )
65
94
 
66
95
 
@@ -188,6 +217,24 @@ def _write_object(parent: etree._Element, schema: dict, data: Any,
188
217
  logger.debug("payload key %r not in schema; ignored", name)
189
218
 
190
219
 
220
+ def _choice_violations(schema: dict[str, Any], data: Any) -> list[str]:
221
+ """Enforce x-soap-choice on the payload: at most one member per group,
222
+ and exactly one when the group is required."""
223
+ if not isinstance(data, dict):
224
+ return []
225
+ errors: list[str] = []
226
+ for group in schema.get("x-soap-choice", []) or []:
227
+ members = group.get("members", [])
228
+ present = [m for m in members if data.get(m) is not None]
229
+ if len(present) > 1:
230
+ errors.append(
231
+ f"at most one of {members} may be set (got {present})"
232
+ )
233
+ elif group.get("required") and not present:
234
+ errors.append(f"exactly one of {members} is required")
235
+ return errors
236
+
237
+
191
238
  def build_envelope(op: dict[str, Any], payload: dict[str, Any],
192
239
  index: _SpecIndex, options: BridgeOptions) -> bytes:
193
240
  xsoap = op["x-soap"]
@@ -246,7 +293,12 @@ def _coerce(text: str | None, schema: dict[str, Any]) -> Any:
246
293
  if t == "number":
247
294
  return float(s)
248
295
  if t == "boolean":
249
- return s in ("true", "1")
296
+ low = s.lower()
297
+ if low in ("true", "1"):
298
+ return True
299
+ if low in ("false", "0"):
300
+ return False
301
+ return text # non-canonical: don't silently coerce to False
250
302
  except ValueError:
251
303
  return text
252
304
  return text if t == "string" else (text if s == "" else s)
@@ -328,17 +380,43 @@ def parse_fault(body: etree._Element, env_ns: str) -> dict[str, Any] | None:
328
380
  return {"faultcode": code, "faultstring": reason, "detail": detail}
329
381
 
330
382
 
383
+ def _http_error(http_status: int, content: bytes) -> tuple[int, dict[str, Any]]:
384
+ return 502, {
385
+ "faultcode": "spec2openapi.HTTPError",
386
+ "faultstring": f"endpoint returned HTTP {http_status}",
387
+ "detail": content[:2000].decode("utf-8", "replace"),
388
+ }
389
+
390
+
331
391
  def parse_response(content: bytes, op: dict[str, Any],
332
- index: _SpecIndex) -> tuple[int, dict[str, Any]]:
333
- """Returns (http_status, json_payload)."""
392
+ index: _SpecIndex,
393
+ http_status: int = 200) -> tuple[int, dict[str, Any]]:
394
+ """Returns (mapped_status, json_payload). http_status is the transport
395
+ status code, used to give sensible errors for empty/non-XML bodies."""
334
396
  xsoap = op["x-soap"]
397
+ is_one_way = not xsoap.get("output")
335
398
  env_ns = SOAP_ENV_NS.get(xsoap.get("soapVersion", "1.1"), SOAP_ENV_NS["1.1"])
399
+
400
+ if not content or not content.strip():
401
+ # empty body: a one-way call with a 2xx succeeded; otherwise the
402
+ # HTTP status carries the real story
403
+ if http_status >= 400:
404
+ return _http_error(http_status, content)
405
+ if is_one_way:
406
+ return 200, {}
407
+ return 502, {
408
+ "faultcode": "spec2openapi.EmptyResponse",
409
+ "faultstring": f"endpoint returned an empty body (HTTP {http_status})",
410
+ "detail": "",
411
+ }
336
412
  try:
337
413
  # endpoint responses are untrusted: no entities, DTDs, or network
338
414
  parser = etree.XMLParser(resolve_entities=False, load_dtd=False,
339
415
  no_network=True, huge_tree=False)
340
416
  tree = etree.fromstring(content, parser=parser)
341
417
  except Exception as exc:
418
+ if http_status >= 400:
419
+ return _http_error(http_status, content)
342
420
  return 502, {
343
421
  "faultcode": "spec2openapi.InvalidXML",
344
422
  "faultstring": f"endpoint returned non-XML response: {exc}",
@@ -353,6 +431,8 @@ def parse_response(content: bytes, op: dict[str, Any],
353
431
  env_ns = alt
354
432
  break
355
433
  if body is None:
434
+ if http_status >= 400:
435
+ return _http_error(http_status, content)
356
436
  return 502, {
357
437
  "faultcode": "spec2openapi.NoBody",
358
438
  "faultstring": "no SOAP Body found in response",
@@ -444,6 +524,16 @@ class SoapBridgeTransport(httpx.AsyncBaseTransport):
444
524
  "detail": ""},
445
525
  )
446
526
 
527
+ violations = _choice_violations(
528
+ self.index.deref(op.get("input", {})), payload
529
+ )
530
+ if violations:
531
+ return self._json_response(
532
+ request, 400,
533
+ {"faultcode": "spec2openapi.ChoiceViolation",
534
+ "faultstring": "; ".join(violations), "detail": ""},
535
+ )
536
+
447
537
  envelope = build_envelope(op, payload, self.index, self.options)
448
538
  action = xsoap.get("soapAction", "")
449
539
  if xsoap.get("soapVersion") == "1.2":
@@ -468,11 +558,7 @@ class SoapBridgeTransport(httpx.AsyncBaseTransport):
468
558
  "faultstring": f"SOAP endpoint unreachable: {exc}", "detail": ""},
469
559
  )
470
560
 
471
- status, data = parse_response(soap_resp.content, op, self.index)
472
- # a SOAP fault usually arrives as HTTP 500; trust envelope content
473
- if status == 200 and soap_resp.status_code >= 400 and not data:
474
- status = 502
475
- data = {"faultcode": "spec2openapi.HTTPError",
476
- "faultstring": f"endpoint returned HTTP {soap_resp.status_code}",
477
- "detail": soap_resp.text[:2000]}
561
+ status, data = parse_response(
562
+ soap_resp.content, op, self.index, soap_resp.status_code
563
+ )
478
564
  return self._json_response(request, status, data)
@@ -17,6 +17,10 @@ from . import __version__
17
17
 
18
18
  _MCP_HINT = "install the MCP runtime extras first: pip install 'spec2openapi[mcp]'"
19
19
 
20
+ _HTTP_METHODS = frozenset(
21
+ ("get", "put", "post", "delete", "options", "head", "patch", "trace")
22
+ )
23
+
20
24
 
21
25
  def _is_wsdl_source(src: str) -> bool:
22
26
  low = src.lower()
@@ -28,8 +32,12 @@ def _is_wsdl_source(src: str) -> bool:
28
32
  return True
29
33
  p = Path(src)
30
34
  if p.exists():
31
- head = p.read_text(encoding="utf-8", errors="replace")[:512].lstrip()
35
+ head = p.read_text(encoding="utf-8-sig", errors="replace")[:512].lstrip()
32
36
  return head.startswith("<")
37
+ # a remote URL without a spec extension: zeep can fetch WSDLs, and
38
+ # load_spec cannot read a URL, so treat it as WSDL
39
+ if low.startswith(("http://", "https://")):
40
+ return True
33
41
  return False
34
42
 
35
43
 
@@ -89,7 +97,7 @@ def cmd_convert(args) -> int:
89
97
  openapi_version=args.openapi_version,
90
98
  forbid_external=args.forbid_external, huge_tree=args.huge_tree,
91
99
  )
92
- fmt = args.format or ("json" if (args.output or "").endswith(".json") else "yaml")
100
+ fmt = args.format or ("json" if (args.output or "").lower().endswith(".json") else "yaml")
93
101
  text = dump_spec(spec, fmt)
94
102
  if args.output:
95
103
  Path(args.output).write_text(text, encoding="utf-8")
@@ -139,7 +147,7 @@ def cmd_upgrade(args) -> int:
139
147
  print(f"{kind[:-1] if kind.endswith('s') else kind}: {msg}",
140
148
  file=sys.stderr)
141
149
 
142
- fmt = args.format or ("json" if (args.output or "").endswith(".json") else "yaml")
150
+ fmt = args.format or ("json" if (args.output or "").lower().endswith(".json") else "yaml")
143
151
  text = dump_spec(upgraded, fmt)
144
152
  if args.output:
145
153
  Path(args.output).write_text(text, encoding="utf-8")
@@ -164,8 +172,11 @@ def cmd_validate(args) -> int:
164
172
  problems.append("spec has no paths")
165
173
  op_ids: list[str] = []
166
174
  for path, item in paths.items():
167
- for method, op in (item or {}).items():
168
- if not isinstance(op, dict):
175
+ if not isinstance(item, dict):
176
+ continue
177
+ for method, op in item.items():
178
+ # only HTTP methods are operations; skip parameters/$ref/x- keys
179
+ if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
169
180
  continue
170
181
  oid = op.get("operationId")
171
182
  if not oid:
@@ -174,7 +185,8 @@ def cmd_validate(args) -> int:
174
185
  op_ids.append(oid)
175
186
  if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", oid):
176
187
  problems.append(f"{oid}: not a safe MCP tool name")
177
- if op.get("x-soap") and not op["x-soap"].get("input", {}).get("element"):
188
+ xsoap = op.get("x-soap")
189
+ if isinstance(xsoap, dict) and not xsoap.get("input", {}).get("element"):
178
190
  problems.append(f"{oid}: x-soap.input.element missing")
179
191
  dupes = {o for o in op_ids if op_ids.count(o) > 1}
180
192
  if dupes:
@@ -195,9 +207,15 @@ def cmd_validate(args) -> int:
195
207
 
196
208
  try: # FastMCP round-trip: the compatibility this project guarantees
197
209
  import anyio
210
+ import httpx
198
211
  from fastmcp import Client, FastMCP
199
212
 
200
- mcp = FastMCP.from_openapi(openapi_spec=spec, name="validate")
213
+ # supply a dummy client so specs without a `servers` entry still
214
+ # convert — validate measures tool convertibility, not deployment
215
+ dummy = httpx.AsyncClient(base_url="http://spec2openapi.invalid")
216
+ mcp = FastMCP.from_openapi(
217
+ openapi_spec=spec, name="validate", client=dummy
218
+ )
201
219
 
202
220
  async def _tools():
203
221
  async with Client(mcp) as client:
@@ -238,17 +256,19 @@ def cmd_validate(args) -> int:
238
256
 
239
257
 
240
258
  def cmd_serve(args) -> int:
259
+ spec = _load_or_convert(args.source)
241
260
  try:
261
+ # everything [mcp]-flavored lives inside the guard: server, the
262
+ # bridge import in _bridge_options, and fastmcp's lazy imports
242
263
  from .server import from_openapi_spec
264
+
265
+ mcp = from_openapi_spec(
266
+ spec, options=_bridge_options(args),
267
+ validate_output=args.validate_output,
268
+ )
243
269
  except ImportError:
244
270
  print(f"error: {_MCP_HINT}", file=sys.stderr)
245
271
  return 2
246
-
247
- spec = _load_or_convert(args.source)
248
- mcp = from_openapi_spec(
249
- spec, options=_bridge_options(args),
250
- validate_output=args.validate_output,
251
- )
252
272
  if args.transport == "http":
253
273
  mcp.run(transport="http", host=args.host, port=args.port,
254
274
  path=args.path, show_banner=False)
@@ -323,7 +343,12 @@ def main(argv: list[str] | None = None) -> int:
323
343
  s.set_defaults(fn=cmd_serve)
324
344
 
325
345
  args = ap.parse_args(argv)
326
- return args.fn(args)
346
+ try:
347
+ return args.fn(args)
348
+ except (FileNotFoundError, ValueError, OSError) as exc:
349
+ # expected user-input errors: one-line message, not a traceback
350
+ print(f"error: {exc}", file=sys.stderr)
351
+ return 2
327
352
 
328
353
 
329
354
  if __name__ == "__main__":
@@ -44,14 +44,21 @@ def convert_wsdl(
44
44
 
45
45
 
46
46
  def load_spec(path: str | Path) -> dict[str, Any]:
47
- """Load an OpenAPI spec from a .yaml/.yml/.json file."""
47
+ """Load an OpenAPI/Swagger spec from a .yaml/.yml/.json file."""
48
48
  p = Path(path)
49
- text = p.read_text(encoding="utf-8")
49
+ text = p.read_text(encoding="utf-8-sig")
50
50
  if p.suffix.lower() == ".json" or text.lstrip().startswith("{"):
51
- return json.loads(text)
52
- import yaml
51
+ spec = json.loads(text)
52
+ else:
53
+ import yaml
53
54
 
54
- return yaml.safe_load(text)
55
+ spec = yaml.safe_load(text)
56
+ if not isinstance(spec, dict):
57
+ raise ValueError(
58
+ f"{p}: not a valid OpenAPI/Swagger document "
59
+ f"(parsed as {type(spec).__name__}, expected a mapping)"
60
+ )
61
+ return spec
55
62
 
56
63
 
57
64
  def spec_has_soap(spec: dict[str, Any]) -> bool:
@@ -25,6 +25,28 @@ import re
25
25
  from .schema import SchemaConverter, sanitize_name
26
26
 
27
27
  _TOOL_ID_RE = re.compile(r"[^A-Za-z0-9_]+")
28
+ _MAX_ID_LEN = 64
29
+
30
+
31
+ def _tool_id(raw: str) -> str:
32
+ """Normalize to FastMCP's tool-name alphabet, bounded to 64 chars."""
33
+ tid = _TOOL_ID_RE.sub("_", sanitize_name(raw)).strip("_")
34
+ return tid[:_MAX_ID_LEN] if tid else "op"
35
+
36
+
37
+ def _unique_id(base: str, used: set[str]) -> str:
38
+ """Make base unique within `used`, keeping the result <= 64 chars."""
39
+ if base not in used:
40
+ used.add(base)
41
+ return base
42
+ n = 2
43
+ while True:
44
+ suffix = f"_{n}"
45
+ candidate = base[: _MAX_ID_LEN - len(suffix)] + suffix
46
+ if candidate not in used:
47
+ used.add(candidate)
48
+ return candidate
49
+ n += 1
28
50
 
29
51
  SOAP_FAULT_SCHEMA = {
30
52
  "type": "object",
@@ -64,11 +86,20 @@ def build_spec(
64
86
  ) -> dict[str, Any]:
65
87
  conv = SchemaConverter(parsed.xsd_meta)
66
88
  paths: dict[str, Any] = {}
89
+ used_ids: set[str] = set()
90
+
91
+ # reserve the built-in fault schema name up front so a WSDL type also
92
+ # named "SoapFault" is deduped to another name instead of clobbering it
93
+ fault_ref_name = "SoapFault"
94
+ conv.components[fault_ref_name] = SOAP_FAULT_SCHEMA
95
+ fault_ref = f"#/components/schemas/{fault_ref_name}"
67
96
 
68
97
  for op in parsed.operations:
69
98
  # FastMCP normalizes tool names to [A-Za-z0-9_]; emit operationIds
70
- # in that alphabet so tool name == operationId after the round-trip
71
- op_id = _TOOL_ID_RE.sub("_", sanitize_name(op.op_id)).strip("_")
99
+ # in that alphabet, bounded to 64 chars, and re-checked for
100
+ # uniqueness *after* normalization/truncation so two operations
101
+ # never collide onto the same path (which would drop one).
102
+ op_id = _unique_id(_tool_id(op.op_id), used_ids)
72
103
  in_q = _element_qname(op.input_element)
73
104
  in_schema = conv.element_type_to_object_schema(
74
105
  op.input_element.type,
@@ -163,7 +194,7 @@ def build_spec(
163
194
  ),
164
195
  "content": {
165
196
  "application/json": {
166
- "schema": {"$ref": "#/components/schemas/SoapFault"}
197
+ "schema": {"$ref": fault_ref}
167
198
  }
168
199
  },
169
200
  },
@@ -172,8 +203,7 @@ def build_spec(
172
203
  paths[f"{base_path}/{op_id}"] = {"post": post}
173
204
 
174
205
  endpoint = parsed.operations[0].endpoint if parsed.operations else ""
175
- components = dict(conv.components)
176
- components["SoapFault"] = SOAP_FAULT_SCHEMA
206
+ components = dict(conv.components) # already includes the fault schema
177
207
 
178
208
  spec: dict[str, Any] = {
179
209
  "openapi": "3.0.3",
@@ -203,20 +233,31 @@ def build_spec(
203
233
  def to_openapi_31(spec: dict[str, Any]) -> dict[str, Any]:
204
234
  """Convert the generated 3.0 document to OpenAPI 3.1 JSON Schema style."""
205
235
 
236
+ # keywords whose values are data, not sub-schemas — don't descend
237
+ data_kw = ("example", "examples", "default", "enum")
238
+
206
239
  def walk(node: Any) -> Any:
207
240
  if isinstance(node, list):
208
241
  return [walk(v) for v in node]
209
242
  if not isinstance(node, dict):
210
243
  return node
211
- node = {k: walk(v) for k, v in node.items()}
244
+ node = {k: (v if k in data_kw else walk(v)) for k, v in node.items()}
212
245
  if node.pop("nullable", False):
213
246
  t = node.get("type")
214
247
  if isinstance(t, str):
215
248
  node["type"] = [t, "null"]
216
- if node.get("exclusiveMinimum") is True and "minimum" in node:
217
- node["exclusiveMinimum"] = node.pop("minimum")
218
- if node.get("exclusiveMaximum") is True and "maximum" in node:
219
- node["exclusiveMaximum"] = node.pop("maximum")
249
+ # 3.0 uses boolean exclusiveMinimum/Maximum alongside minimum/maximum;
250
+ # 2020-12 requires a number. Convert true+bound, and drop the boolean
251
+ # otherwise (false = inclusive default; true without a bound is
252
+ # malformed and cannot be represented).
253
+ for kw, bound in (("exclusiveMinimum", "minimum"),
254
+ ("exclusiveMaximum", "maximum")):
255
+ val = node.get(kw)
256
+ if isinstance(val, bool):
257
+ if val and bound in node:
258
+ node[kw] = node.pop(bound)
259
+ else:
260
+ node.pop(kw, None)
220
261
  return node
221
262
 
222
263
  out = walk(dict(spec))
@@ -303,7 +303,12 @@ def parse_wsdl(
303
303
  forbid_dtd=True, forbid_entities=True,
304
304
  forbid_external=forbid_external,
305
305
  )
306
- client = Client(source, settings=settings)
306
+ try:
307
+ client = Client(source, settings=settings)
308
+ except FileNotFoundError:
309
+ raise
310
+ except Exception as exc: # malformed/unfetchable WSDL -> clean error
311
+ raise ValueError(f"could not parse WSDL '{source}': {exc}") from exc
307
312
 
308
313
  svc_doc, op_docs = _extract_wsdl_docs(source)
309
314
  xsd_meta = _collect_xsd_meta(client, source, forbid_external=forbid_external)