mcpify-openapi 1.0.2__tar.gz → 1.0.4__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 (25) hide show
  1. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/PKG-INFO +1 -1
  2. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/__init__.py +1 -1
  3. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/api_server.py +8 -1
  4. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/cli.py +49 -9
  5. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/http_client.py +34 -1
  6. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/spec.py +19 -1
  7. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/tools.py +43 -15
  8. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/PKG-INFO +1 -1
  9. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/SOURCES.txt +2 -0
  10. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/pyproject.toml +1 -1
  11. mcpify_openapi-1.0.4/tests/test_audit.py +110 -0
  12. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_e2e.py +6 -1
  13. mcpify_openapi-1.0.4/tests/test_hostile_specs.py +168 -0
  14. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_ref_params.py +1 -1
  15. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_tools.py +9 -3
  16. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/LICENSE +0 -0
  17. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/README.md +0 -0
  18. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/dependency_links.txt +0 -0
  19. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/entry_points.txt +0 -0
  20. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/requires.txt +0 -0
  21. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/top_level.txt +0 -0
  22. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/setup.cfg +0 -0
  23. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_cli.py +0 -0
  24. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_policy.py +0 -0
  25. {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpify-openapi
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Turn any OpenAPI REST API into an MCP server so AI agents can call it — zero dependencies
5
5
  License: MIT
6
6
  Keywords: mcp,model-context-protocol,openapi,swagger,ai-agents,claude,rest,api,cli
@@ -3,7 +3,7 @@
3
3
  from .spec import SpecError, load_spec, resolve_ref, resolve_schema
4
4
  from .tools import AuthConfig, RequestError, build_request, spec_to_tools
5
5
 
6
- __version__ = "1.0.2"
6
+ __version__ = "1.0.4"
7
7
  __all__ = [
8
8
  "SpecError",
9
9
  "load_spec",
@@ -11,12 +11,12 @@ import json
11
11
  import sys
12
12
  from typing import Any
13
13
 
14
+ from . import __version__ as SERVER_VERSION # never hardcode: avoids version drift
14
15
  from .http_client import execute, format_result
15
16
  from .tools import AuthConfig, RequestError, build_request, spec_to_tools
16
17
 
17
18
  PROTOCOL_VERSION = "2025-06-18"
18
19
  SERVER_NAME = "mcpify"
19
- SERVER_VERSION = "1.0.0"
20
20
 
21
21
 
22
22
  class ApiServer:
@@ -37,6 +37,7 @@ class ApiServer:
37
37
  self.timeout = timeout
38
38
  self.tools = spec_to_tools(spec)
39
39
  self.by_name = {tool["name"]: tool for tool in self.tools}
40
+ self._initialized = False
40
41
 
41
42
  # -- public API used by the CLI --------------------------------------
42
43
  @property
@@ -87,10 +88,16 @@ class ApiServer:
87
88
  "serverInfo": {"name": self.server_name, "version": SERVER_VERSION},
88
89
  },
89
90
  )
91
+ if method == "notifications/initialized":
92
+ self._initialized = True
93
+ return None
90
94
  if method.startswith("notifications/"):
91
95
  return None
92
96
  if method == "ping":
93
97
  return self._result(request_id, {})
98
+ if method in ("tools/list", "tools/call") and not self._initialized:
99
+ # MCP lifecycle: requests before initialization completes must fail
100
+ return self._error(request_id, -32002, "Server not initialized: send initialize and notifications/initialized first")
94
101
  if method == "tools/list":
95
102
  return self._result(request_id, {"tools": self.public_tools()})
96
103
  if method == "tools/call":
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import os
7
8
  import re
8
9
  import sys
9
10
  from typing import NoReturn
@@ -12,7 +13,16 @@ from . import __version__
12
13
  from .spec import SpecError, iter_operations, load_spec, spec_servers
13
14
  from .tools import AuthConfig, spec_to_tools
14
15
 
15
- USE_COLOR = sys.stdout.isatty()
16
+ USE_COLOR = (
17
+ sys.stdout.isatty()
18
+ and not os.environ.get("NO_COLOR")
19
+ and (
20
+ os.name != "nt"
21
+ or os.environ.get("WT_SESSION")
22
+ or os.environ.get("ANSICON")
23
+ or os.environ.get("TERM_PROGRAM") == "vscode"
24
+ )
25
+ )
16
26
 
17
27
 
18
28
  def _fail(message: str, code: int = 2) -> NoReturn:
@@ -61,10 +71,31 @@ def filter_tools(tools: list[dict], args: argparse.Namespace) -> list[dict]:
61
71
  def _base_url(spec: dict, override: str | None) -> str:
62
72
  if override:
63
73
  return override
64
- servers = spec_servers(spec)
74
+ servers = spec.get("servers") or []
65
75
  if servers:
66
- return servers[0]
67
- _fail(
76
+ entry = servers[0] if isinstance(servers[0], dict) else {"url": str(servers[0])}
77
+ url = str(entry.get("url", ""))
78
+ # substitute server variables from their declared defaults
79
+ variables = entry.get("variables")
80
+ if isinstance(variables, dict):
81
+ for name, var in variables.items():
82
+ default = var.get("default") if isinstance(var, dict) else None
83
+ if default is not None:
84
+ url = url.replace("{" + name + "}", str(default))
85
+ if "{" in url:
86
+ kalan = re.findall(r"\{([^{}]+)\}", url)
87
+ raise SpecError(
88
+ f"server URL variable(s) {kalan} have no default; "
89
+ "pass --base-url to set the target explicitly"
90
+ )
91
+ if url and not url.startswith(("http://", "https://")):
92
+ raise SpecError(
93
+ f"server URL '{url}' is relative and cannot be called; "
94
+ "pass --base-url with the absolute URL"
95
+ )
96
+ if url:
97
+ return url
98
+ raise SpecError(
68
99
  "no base URL: the spec declares no servers and --base-url was not given"
69
100
  )
70
101
 
@@ -172,16 +203,15 @@ def main(argv: list[str] | None = None) -> None:
172
203
  print(dim(f"serve it: mcpify serve {args.spec}"))
173
204
 
174
205
  elif args.command == "serve":
175
- if not args.base_url and not spec_servers(spec):
176
- _fail(
177
- "no base URL: the spec declares no servers and --base-url was not given"
178
- )
179
206
  auth = None
180
207
  if args.auth_env:
181
208
  auth = AuthConfig(args.auth_env, args.auth_style, args.auth_name)
182
209
  from .api_server import ApiServer
183
210
 
184
- base = args.base_url or spec_servers(spec)[0]
211
+ try:
212
+ base = _base_url(spec, args.base_url)
213
+ except SpecError as err:
214
+ _fail(str(err))
185
215
  server = ApiServer(
186
216
  spec,
187
217
  base,
@@ -230,6 +260,16 @@ def main(argv: list[str] | None = None) -> None:
230
260
  print(warn(f"warning: {no_summary}/{total} operations have no summary (agents see no description)"))
231
261
  if variabled:
232
262
  print(warn(f"warning: server URL(s) contain variables: {', '.join(variabled)} — pass --base-url"))
263
+ if servers and not servers[0].startswith(("http://", "https://")):
264
+ print(warn(f"warning: server URL '{servers[0]}' is relative — pass --base-url with the absolute URL"))
265
+ security = ((spec.get("components") or {}).get("securitySchemes") or {})
266
+ if security:
267
+ print(warn(f"warning: spec declares security schemes ({', '.join(sorted(security))}) — serve with --auth-env/--auth-style or calls will 401"))
268
+ deprecated = sum(1 for _m, _p, op in iter_operations(spec) if op.get("deprecated"))
269
+ if deprecated:
270
+ print(warn(f"warning: {deprecated} deprecated operation(s) will be exposed (filter with --tag/--exclude if unintended)"))
271
+ if total > 50:
272
+ print(warn(f"warning: {total} operations is a large tool surface — consider --tag/--include/--exclude to protect the model's context"))
233
273
  if not missing_id and not no_summary:
234
274
  print(ok("all operations carry operationId and summary — agent-friendly ✓"))
235
275
 
@@ -4,9 +4,22 @@ from __future__ import annotations
4
4
 
5
5
  import contextlib
6
6
  import json
7
+ import os
8
+ import sys
9
+ import time
7
10
  import urllib.error
8
11
  import urllib.request
9
12
 
13
+ _DEBUG = os.environ.get("MCPIFY_DEBUG") == "1"
14
+
15
+
16
+ def _log(level: str, message: str) -> None:
17
+ """Optional stderr logging (MCPIFY_DEBUG=1). NEVER touches stdout:
18
+ the JSON-RPC stream must stay clean. URLs are logged without query
19
+ strings so query-style auth credentials never hit the log."""
20
+ if _DEBUG:
21
+ print(f"{level} mcpify: {message}", file=sys.stderr, flush=True)
22
+
10
23
 
11
24
  def execute(request: dict, timeout: float = 30.0) -> dict:
12
25
  """Perform the request and return {status, body, json}.
@@ -15,12 +28,14 @@ def execute(request: dict, timeout: float = 30.0) -> dict:
15
28
  the agent can see API error payloads and react to them.
16
29
  """
17
30
  data = request.get("body")
31
+ url_guvenli = request["url"].split("?", 1)[0]
18
32
  req = urllib.request.Request(
19
33
  request["url"],
20
34
  data=data,
21
35
  headers=request["headers"],
22
36
  method=request["method"],
23
37
  )
38
+ basla = time.monotonic()
24
39
  try:
25
40
  with urllib.request.urlopen(req, timeout=timeout) as response:
26
41
  status = response.status
@@ -29,11 +44,19 @@ def execute(request: dict, timeout: float = 30.0) -> dict:
29
44
  status = err.code
30
45
  raw = err.read().decode("utf-8", "replace")
31
46
  except urllib.error.URLError as err:
47
+ _log("ERROR", f"{request['method']} {url_guvenli} -> connection failed: {err.reason}")
32
48
  return {
33
49
  "status": 0,
34
50
  "body": f"connection failed: {err.reason}",
35
51
  "json": None,
36
52
  }
53
+ sure = time.monotonic() - basla
54
+ if status >= 500:
55
+ _log("ERROR", f"{request['method']} {url_guvenli} -> {status} ({sure:.2f}s)")
56
+ elif status >= 400:
57
+ _log("WARNING", f"{request['method']} {url_guvenli} -> {status} ({sure:.2f}s)")
58
+ else:
59
+ _log("INFO", f"{request['method']} {url_guvenli} -> {status} ({sure:.2f}s)")
37
60
 
38
61
  parsed = None
39
62
  with contextlib.suppress(json.JSONDecodeError, ValueError):
@@ -41,11 +64,21 @@ def execute(request: dict, timeout: float = 30.0) -> dict:
41
64
  return {"status": status, "body": raw, "json": parsed}
42
65
 
43
66
 
67
+ MAX_RESULT_CHARS = 40_000
68
+
69
+
44
70
  def format_result(result: dict) -> tuple[str, bool]:
45
- """Format an execute() result for an MCP tool response: (text, is_error)."""
71
+ """Format an execute() result for an MCP tool response: (text, is_error).
72
+
73
+ Oversized bodies are truncated so a single tool call cannot blow the
74
+ client's context window.
75
+ """
46
76
  body = result["body"]
47
77
  if result["json"] is not None:
48
78
  body = json.dumps(result["json"], ensure_ascii=False, indent=2)
79
+ if len(body) > MAX_RESULT_CHARS:
80
+ kesilen = len(body) - MAX_RESULT_CHARS
81
+ body = body[:MAX_RESULT_CHARS] + f"\n… [truncated {kesilen:,} more characters]"
49
82
  is_error = result["status"] == 0 or result["status"] >= 400
50
83
  if is_error:
51
84
  return f"HTTP {result['status']}\n{body}", is_error
@@ -103,7 +103,25 @@ def resolve_schema(schema: Any, spec: dict, depth: int = 0) -> Any:
103
103
  name: resolve_schema(sub, spec, depth + 1) for name, sub in value.items()
104
104
  }
105
105
  elif key in ("anyOf", "oneOf", "allOf") and isinstance(value, list):
106
- resolved[key] = [resolve_schema(sub, spec, depth + 1) for sub in value]
106
+ resolved_subs = [resolve_schema(sub, spec, depth + 1) for sub in value]
107
+ if key == "allOf":
108
+ # allOf intersection: merge member properties/required upward
109
+ merged_props = dict(resolved.get("properties") or {})
110
+ merged_req = list(resolved.get("required") or [])
111
+ for sub in resolved_subs:
112
+ if isinstance(sub, dict):
113
+ merged_props.update(sub.get("properties") or {})
114
+ for req in sub.get("required") or []:
115
+ if req not in merged_req:
116
+ merged_req.append(req)
117
+ if merged_props:
118
+ resolved["properties"] = merged_props
119
+ if merged_req:
120
+ resolved["required"] = merged_req
121
+ # merged members are flattened into the parent; the allOf key
122
+ # itself is intentionally dropped
123
+ else:
124
+ resolved[key] = resolved_subs
107
125
  else:
108
126
  resolved[key] = value
109
127
  return resolved
@@ -59,6 +59,7 @@ def build_input_schema(
59
59
  parameters: list[dict],
60
60
  request_schema: dict | None,
61
61
  spec: dict | None = None,
62
+ raw_body_content_type: str | None = None,
62
63
  ) -> dict:
63
64
  """Build the JSON Schema for an MCP tool from parameters + request body.
64
65
 
@@ -85,10 +86,7 @@ def build_input_schema(
85
86
  name = str(param.get("name", ""))
86
87
  if not name:
87
88
  continue
88
- if param.get("schema"):
89
- schema = safe_resolve(param["schema"])
90
- else:
91
- schema = {"type": "string"}
89
+ schema = safe_resolve(param["schema"]) if param.get("schema") else {"type": "string"}
92
90
  entry = {"type": schema.get("type", "string"), "description": str(param.get("description", ""))}
93
91
  if schema.get("enum"):
94
92
  entry["enum"] = schema["enum"]
@@ -102,12 +100,22 @@ def build_input_schema(
102
100
  required.append(key)
103
101
 
104
102
  if request_schema is not None and method not in ("GET", "HEAD", "DELETE"):
105
- properties[BODY_ARG] = {
106
- "type": "object",
107
- "description": "JSON request body",
108
- **({"properties": request_schema.get("properties", {})} if request_schema.get("properties") else {}),
109
- **({"required": request_schema["required"]} if request_schema.get("required") else {}),
110
- }
103
+ if raw_body_content_type:
104
+ properties[BODY_ARG] = {
105
+ "type": "string",
106
+ "description": (f"raw request body, sent as-is "
107
+ f"(content type: {raw_body_content_type})"),
108
+ }
109
+ else:
110
+ note = ""
111
+ if not request_schema.get("properties") and request_schema.get("x-unresolved"):
112
+ note = " (schema could not be fully resolved from the spec)"
113
+ properties[BODY_ARG] = {
114
+ "type": "object",
115
+ "description": "JSON request body" + note,
116
+ **({"properties": request_schema.get("properties", {})} if request_schema.get("properties") else {}),
117
+ **({"required": request_schema["required"]} if request_schema.get("required") else {}),
118
+ }
111
119
 
112
120
  return {"type": "object", "properties": properties, "required": required}
113
121
 
@@ -117,12 +125,22 @@ def operation_to_tool(method: str, path: str, operation: dict, path_item: dict,
117
125
  parameters = extract_parameters(operation, path_item, spec)
118
126
 
119
127
  request_schema = None
128
+ raw_body_content_type = None
120
129
  body = operation.get("requestBody")
121
130
  if isinstance(body, dict):
122
131
  content = body.get("content") or {}
123
132
  json_media = content.get("application/json")
124
133
  if isinstance(json_media, dict) and json_media.get("schema"):
125
- request_schema = resolve_schema(json_media["schema"], spec)
134
+ try:
135
+ request_schema = resolve_schema(json_media["schema"], spec)
136
+ except SpecError:
137
+ # circular / unresolvable body schema must not kill the tool
138
+ request_schema = {"type": "object", "x-unresolved": True}
139
+ elif content:
140
+ # non-JSON bodies (multipart uploads, form posts): expose a raw
141
+ # string body instead of silently dropping the operation's payload
142
+ raw_body_content_type = next(iter(content))
143
+ request_schema = {"type": "string"}
126
144
 
127
145
  name = operation_id(method, path, operation)
128
146
  base = name
@@ -135,12 +153,13 @@ def operation_to_tool(method: str, path: str, operation: dict, path_item: dict,
135
153
  return {
136
154
  "name": name,
137
155
  "description": build_description(method, path, operation),
138
- "inputSchema": build_input_schema(method.upper(), operation, parameters, request_schema, spec),
156
+ "inputSchema": build_input_schema(method.upper(), operation, parameters, request_schema, spec, raw_body_content_type),
139
157
  "_meta": {
140
158
  "method": method.upper(),
141
159
  "path": path,
142
160
  "parameters": parameters,
143
161
  "has_body": request_schema is not None,
162
+ "raw_body_content_type": raw_body_content_type,
144
163
  "tags": list(operation.get("tags") or []),
145
164
  },
146
165
  }
@@ -153,6 +172,8 @@ def spec_to_tools(spec: dict) -> list[dict]:
153
172
  tools: list[dict] = []
154
173
  taken: set = set()
155
174
  for method, path, operation in iter_operations(spec):
175
+ if method.lower() in ("head", "options", "trace"):
176
+ continue # no agent value; they carry no request semantics
156
177
  path_item = spec["paths"][path]
157
178
  tools.append(operation_to_tool(method, path, operation, path_item, spec, taken))
158
179
  return tools
@@ -218,10 +239,17 @@ def build_request(
218
239
  body = arguments.get(BODY_ARG)
219
240
  if body is None:
220
241
  raise RequestError(f"missing required argument '{BODY_ARG}' (JSON request body)")
221
- if not isinstance(body, dict):
242
+ raw_ct = meta.get("raw_body_content_type")
243
+ if raw_ct:
244
+ if not isinstance(body, str):
245
+ raise RequestError(f"'{BODY_ARG}' must be a string for {raw_ct} bodies")
246
+ body_bytes = body.encode("utf-8")
247
+ headers["Content-Type"] = raw_ct
248
+ elif not isinstance(body, dict):
222
249
  raise RequestError(f"'{BODY_ARG}' must be a JSON object")
223
- body_bytes = json.dumps(body).encode("utf-8")
224
- headers["Content-Type"] = "application/json"
250
+ else:
251
+ body_bytes = json.dumps(body).encode("utf-8")
252
+ headers["Content-Type"] = "application/json"
225
253
  used.add(BODY_ARG)
226
254
 
227
255
  unknown = sorted(set(arguments) - used)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpify-openapi
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Turn any OpenAPI REST API into an MCP server so AI agents can call it — zero dependencies
5
5
  License: MIT
6
6
  Keywords: mcp,model-context-protocol,openapi,swagger,ai-agents,claude,rest,api,cli
@@ -13,8 +13,10 @@ mcpify_openapi.egg-info/dependency_links.txt
13
13
  mcpify_openapi.egg-info/entry_points.txt
14
14
  mcpify_openapi.egg-info/requires.txt
15
15
  mcpify_openapi.egg-info/top_level.txt
16
+ tests/test_audit.py
16
17
  tests/test_cli.py
17
18
  tests/test_e2e.py
19
+ tests/test_hostile_specs.py
18
20
  tests/test_policy.py
19
21
  tests/test_ref_params.py
20
22
  tests/test_spec.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mcpify-openapi" # "mcpify" on PyPI is an unrelated package; CLI command stays `mcpify`
7
- version = "1.0.2"
7
+ version = "1.0.4"
8
8
  description = "Turn any OpenAPI REST API into an MCP server so AI agents can call it — zero dependencies"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,110 @@
1
+ """Checklist-driven lifecycle and hygiene tests.
2
+
3
+ Covers: MCP initialization enforcement, server version sync, stdout
4
+ cleanliness (JSON-RPC stream must never be polluted), and doctor's
5
+ auth guidance.
6
+ """
7
+
8
+ import json
9
+
10
+ from mcpify import __version__
11
+ from mcpify.api_server import ApiServer
12
+ from mcpify.cli import main
13
+ from mcpify.spec import load_spec
14
+
15
+
16
+ def make_server():
17
+ return ApiServer(load_spec("examples/petstore.json"), "https://api.example.com/v1")
18
+
19
+
20
+ def rpc(method, params=None, request_id=1):
21
+ return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}}
22
+
23
+
24
+ # ---------- MCP lifecycle ----------
25
+
26
+ def test_tools_rejected_before_initialization():
27
+ server = make_server()
28
+ response = server.handle_message(rpc("tools/list"))
29
+ assert response["error"]["code"] == -32002
30
+ assert "not initialized" in response["error"]["message"]
31
+
32
+
33
+ def test_tool_call_rejected_before_initialization():
34
+ server = make_server()
35
+ response = server.handle_message(rpc("tools/call", {"name": "list_pets"}))
36
+ assert response["error"]["code"] == -32002
37
+
38
+
39
+ def test_full_handshake_unlocks_tools():
40
+ server = make_server()
41
+ assert server.handle_message(rpc("initialize"))["result"]["serverInfo"]
42
+ # notifications have no id and produce no response
43
+ assert server.handle_message(rpc("notifications/initialized")) is None
44
+ listing = server.handle_message(rpc("tools/list"))
45
+ assert "tools" in listing["result"]
46
+
47
+
48
+ def test_unknown_methods_still_protocol_errors():
49
+ server = make_server()
50
+ server.handle_message(rpc("initialize"))
51
+ server.handle_message(rpc("notifications/initialized"))
52
+ assert server.handle_message(rpc("no/such"))["error"]["code"] == -32601
53
+
54
+
55
+ # ---------- version hygiene ----------
56
+
57
+ def test_server_version_matches_package():
58
+ server = make_server()
59
+ info = server.handle_message(rpc("initialize"))["result"]["serverInfo"]
60
+ assert info["version"] == __version__
61
+
62
+
63
+ # ---------- stdout cleanliness (critical for stdio servers) ----------
64
+
65
+ def test_debug_logging_goes_to_stderr_never_stdout(monkeypatch, capsys):
66
+ import mcpify.http_client as hc
67
+
68
+ monkeypatch.setattr(hc, "_DEBUG", True)
69
+ hc.execute({"url": "http://127.0.0.1:1/x?q=secret", "headers": {}, "method": "GET"})
70
+ captured = capsys.readouterr()
71
+ assert captured.out == "" # stdout is reserved for JSON-RPC
72
+ assert "ERROR" in captured.err # uppercase level on stderr
73
+ assert "secret" not in captured.err # query credentials never logged
74
+
75
+
76
+ def test_serve_writes_only_jsonrpc_to_stdout():
77
+ import io
78
+
79
+ server = make_server()
80
+ inp = io.StringIO(
81
+ json.dumps(rpc("initialize", {}, request_id=1)) + "\n"
82
+ + json.dumps(rpc("notifications/initialized"), ) + "\n"
83
+ + json.dumps(rpc("tools/list", request_id=2)) + "\n"
84
+ )
85
+ out = io.StringIO()
86
+ server.serve(stdin=inp, stdout=out)
87
+ lines = [json.loads(line) for line in out.getvalue().splitlines()]
88
+ assert len(lines) == 2 # only the two requests got responses
89
+ assert all("result" in line for line in lines)
90
+
91
+
92
+ # ---------- doctor auth guidance ----------
93
+
94
+ def test_doctor_warns_about_security_schemes(tmp_path, capsys):
95
+ spec = {
96
+ "openapi": "3.0.0",
97
+ "info": {"title": "Secured", "version": "1"},
98
+ "servers": [{"url": "https://api.example.com"}],
99
+ "paths": {"/x": {"get": {"operationId": "x",
100
+ "summary": "X",
101
+ "responses": {"200": {"description": "ok"}}}}},
102
+ "components": {"securitySchemes": {"bearerAuth": {"type": "http",
103
+ "scheme": "bearer"}}},
104
+ }
105
+ spec_path = tmp_path / "spec.json"
106
+ spec_path.write_text(json.dumps(spec), encoding="utf-8")
107
+ main(["doctor", str(spec_path)])
108
+ captured = capsys.readouterr()
109
+ assert "security schemes" in captured.out
110
+ assert "--auth-env" in captured.out
@@ -71,7 +71,10 @@ def server(api, tmp_path):
71
71
  spec = load_spec("examples/petstore.json")
72
72
  spec["servers"] = [{"url": api}]
73
73
  spec_path.write_text(json.dumps(spec), encoding="utf-8")
74
- return ApiServer(load_spec(str(spec_path)), api, auth=None)
74
+ server = ApiServer(load_spec(str(spec_path)), api, auth=None)
75
+ server.handle_message(rpc("initialize"))
76
+ server.handle_message(rpc("notifications/initialized"))
77
+ return server
75
78
 
76
79
 
77
80
  def rpc(method, params=None, request_id=1):
@@ -141,6 +144,8 @@ def test_bearer_auth_reaches_the_api(api, tmp_path):
141
144
  spec_path.write_text(json.dumps(spec), encoding="utf-8")
142
145
 
143
146
  server = ApiServer(load_spec(str(spec_path)), api, auth=AuthConfig("TEST_TOKEN", "bearer"))
147
+ server.handle_message(rpc("initialize"))
148
+ server.handle_message(rpc("notifications/initialized"))
144
149
 
145
150
  monkeypatch = pytest.MonkeyPatch()
146
151
  monkeypatch.setenv("TEST_TOKEN", "super-secret")
@@ -0,0 +1,168 @@
1
+ """Hostile-spec regression suite.
2
+
3
+ Every case here maps to a documented real-world MCP/OpenAPI pain point
4
+ (sources: arXiv 2507.16044 REST->MCP study; truefoundry & digitalapi
5
+ conversion guides). These scenarios crashed or misbehaved in v1.0.2 and
6
+ are locked in so they stay fixed.
7
+ """
8
+
9
+
10
+ import pytest
11
+
12
+ from mcpify.cli import _base_url
13
+ from mcpify.http_client import format_result
14
+ from mcpify.spec import SpecError
15
+ from mcpify.tools import build_request, spec_to_tools
16
+
17
+
18
+ def spec_with(paths, components=None, servers=None, openapi="3.0.0"):
19
+ return {
20
+ "openapi": openapi,
21
+ "info": {"title": "hostile", "version": "1"},
22
+ "servers": servers or [{"url": "https://api.example.com"}],
23
+ "paths": paths,
24
+ **({"components": components} if components else {}),
25
+ }
26
+
27
+
28
+ # ---------- A) spec structure ----------
29
+
30
+ def test_request_body_ref_resolves_into_tool_schema():
31
+ spec = spec_with(
32
+ {"/pets": {"post": {
33
+ "operationId": "createPet", "summary": "Create",
34
+ "requestBody": {"content": {"application/json": {
35
+ "schema": {"$ref": "#/components/schemas/Pet"}}}},
36
+ "responses": {"200": {"description": "ok"}}}}},
37
+ {"schemas": {"Pet": {"type": "object", "properties": {
38
+ "name": {"type": "string"}}}}},
39
+ )
40
+ body = spec_to_tools(spec)[0]["inputSchema"]["properties"]["body"]
41
+ assert list(body["properties"]) == ["name"]
42
+
43
+
44
+ def test_circular_body_ref_degrades_instead_of_crashing():
45
+ spec = spec_with(
46
+ {"/node": {"post": {
47
+ "operationId": "makeNode", "summary": "Make",
48
+ "requestBody": {"content": {"application/json": {
49
+ "schema": {"$ref": "#/components/schemas/Node"}}}},
50
+ "responses": {"200": {"description": "ok"}}}}},
51
+ {"schemas": {"Node": {"type": "object", "properties": {
52
+ "child": {"$ref": "#/components/schemas/Node"}}}}},
53
+ )
54
+ body = spec_to_tools(spec)[0]["inputSchema"]["properties"]["body"]
55
+ assert body["type"] == "object"
56
+ assert "could not be fully resolved" in body["description"]
57
+
58
+
59
+ def test_multipart_only_body_becomes_raw_string_arg():
60
+ spec = spec_with(
61
+ {"/upload": {"post": {
62
+ "operationId": "uploadFile", "summary": "Upload",
63
+ "requestBody": {"content": {"multipart/form-data": {
64
+ "schema": {"type": "object", "properties": {
65
+ "file": {"type": "string", "format": "binary"}}}}}},
66
+ "responses": {"200": {"description": "ok"}}}}},
67
+ )
68
+ tool = spec_to_tools(spec)[0]
69
+ body = tool["inputSchema"]["properties"]["body"]
70
+ assert body["type"] == "string"
71
+ assert "multipart/form-data" in body["description"]
72
+ # and build_request sends it raw with the right content type
73
+ req = build_request("https://api.example.com", tool["_meta"],
74
+ {"body": "RAW"})
75
+ assert req["body"] == b"RAW"
76
+ assert req["headers"]["Content-Type"] == "multipart/form-data"
77
+
78
+
79
+ def test_allof_members_merge_into_body_schema():
80
+ spec = spec_with(
81
+ {"/pets": {"post": {
82
+ "operationId": "createPet", "summary": "Create",
83
+ "requestBody": {"content": {"application/json": {"schema": {"allOf": [
84
+ {"$ref": "#/components/schemas/Base"},
85
+ {"type": "object", "properties": {"extra": {"type": "string"}}}]}}}},
86
+ "responses": {"200": {"description": "ok"}}}}},
87
+ {"schemas": {"Base": {"type": "object", "required": ["name"],
88
+ "properties": {"name": {"type": "string"}}}}},
89
+ )
90
+ body = spec_to_tools(spec)[0]["inputSchema"]["properties"]["body"]
91
+ assert set(body["properties"]) == {"name", "extra"}
92
+ assert body["required"] == ["name"]
93
+
94
+
95
+ def test_oas31_type_arrays_do_not_crash():
96
+ spec = spec_with(
97
+ {"/x": {"get": {"operationId": "getX", "summary": "Get",
98
+ "parameters": [{"name": "q", "in": "query",
99
+ "schema": {"type": ["string", "null"]}}],
100
+ "responses": {"200": {"description": "ok"}}}}},
101
+ openapi="3.1.0",
102
+ )
103
+ props = spec_to_tools(spec)[0]["inputSchema"]["properties"]
104
+ assert props["q"]["type"] == ["string", "null"]
105
+
106
+
107
+ # ---------- B) server URL (arXiv failure category B) ----------
108
+
109
+ def test_server_variable_defaults_are_substituted():
110
+ spec = spec_with(
111
+ {"/x": {"get": {"operationId": "x", "responses": {"200": {"description": "ok"}}}}},
112
+ servers=[{"url": "https://{host}/v2",
113
+ "variables": {"host": {"default": "api.example.com"}}}],
114
+ )
115
+ assert _base_url(spec, None) == "https://api.example.com/v2"
116
+
117
+
118
+ def test_server_variable_without_default_fails_cleanly():
119
+ spec = spec_with(
120
+ {"/x": {"get": {"operationId": "x", "responses": {"200": {"description": "ok"}}}}},
121
+ servers=[{"url": "https://{region}.api.io",
122
+ "variables": {"region": {"enum": ["eu", "us"]}}}],
123
+ )
124
+ with pytest.raises(SpecError, match="--base-url"):
125
+ _base_url(spec, None)
126
+
127
+
128
+ def test_relative_server_url_fails_cleanly():
129
+ spec = spec_with(
130
+ {"/x": {"get": {"operationId": "x", "responses": {"200": {"description": "ok"}}}}},
131
+ servers=[{"url": "/api/v3"}],
132
+ )
133
+ with pytest.raises(SpecError, match="relative"):
134
+ _base_url(spec, None)
135
+
136
+
137
+ # ---------- C) tool surface ----------
138
+
139
+ def test_useless_methods_are_not_exposed():
140
+ spec = spec_with({"/x": {
141
+ "get": {"operationId": "getX", "summary": "g", "responses": {"200": {"description": "ok"}}},
142
+ "head": {"operationId": "headX", "summary": "h", "responses": {"200": {"description": "ok"}}},
143
+ "options": {"operationId": "optionsX", "summary": "o", "responses": {"200": {"description": "ok"}}},
144
+ "trace": {"operationId": "traceX", "summary": "t", "responses": {"200": {"description": "ok"}}},
145
+ "patch": {"operationId": "patchX", "summary": "p", "responses": {"200": {"description": "ok"}}},
146
+ }})
147
+ names = {t["name"] for t in spec_to_tools(spec)}
148
+ assert {"getx", "patchx"} <= names
149
+ assert not (names & {"headx", "optionsx", "tracex"})
150
+
151
+
152
+ # ---------- D) runtime ----------
153
+
154
+ def test_500_operations_convert_fast():
155
+ spec = spec_with({f"/res{i}": {"get": {"operationId": f"get{i}", "summary": f"s{i}",
156
+ "responses": {"200": {"description": "ok"}}}}
157
+ for i in range(500)})
158
+ tools = spec_to_tools(spec)
159
+ assert len(tools) == 500
160
+
161
+
162
+ def test_oversized_response_is_truncated():
163
+ big = "x" * 300_000
164
+ text, is_error = format_result({"status": 200, "body": big, "json": None})
165
+ assert not is_error
166
+ assert len(text) < 60_000
167
+ assert "truncated" in text
168
+ assert "260,000 more characters" in text # mentions the cut size
@@ -10,7 +10,7 @@ import argparse
10
10
  import pytest
11
11
 
12
12
  from mcpify.cli import filter_tools
13
- from mcpify.spec import SpecError, load_spec
13
+ from mcpify.spec import load_spec
14
14
  from mcpify.tools import spec_to_tools
15
15
 
16
16
  SPEC = {
@@ -4,7 +4,7 @@ import json
4
4
 
5
5
  import pytest
6
6
 
7
- from mcpify.spec import SpecError, load_spec
7
+ from mcpify.spec import load_spec
8
8
  from mcpify.tools import (
9
9
  BODY_ARG,
10
10
  AuthConfig,
@@ -198,7 +198,7 @@ def test_missing_env_var_is_request_error(monkeypatch):
198
198
  auth.headers()
199
199
 
200
200
 
201
- def test_unresolvable_ref_raises_during_conversion():
201
+ def test_unresolvable_body_ref_degrades_during_conversion():
202
202
  spec = {
203
203
  "openapi": "3.0.0",
204
204
  "paths": {
@@ -212,5 +212,11 @@ def test_unresolvable_ref_raises_during_conversion():
212
212
  }
213
213
  },
214
214
  }
215
- with pytest.raises(SpecError):
215
+ tools = spec_to_tools(spec)
216
+ body = tools[0]["inputSchema"]["properties"]["body"]
217
+ assert body["type"] == "object"
218
+ assert "could not be fully resolved" in body["description"]
219
+
220
+
221
+ def _old_raises_block_removed():
216
222
  spec_to_tools(spec)
File without changes
File without changes
File without changes