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.
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/PKG-INFO +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/__init__.py +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/api_server.py +8 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/cli.py +49 -9
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/http_client.py +34 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/spec.py +19 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify/tools.py +43 -15
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/PKG-INFO +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/SOURCES.txt +2 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/pyproject.toml +1 -1
- mcpify_openapi-1.0.4/tests/test_audit.py +110 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_e2e.py +6 -1
- mcpify_openapi-1.0.4/tests/test_hostile_specs.py +168 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_ref_params.py +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_tools.py +9 -3
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/LICENSE +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/README.md +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/dependency_links.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/entry_points.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/requires.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/mcpify_openapi.egg-info/top_level.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/setup.cfg +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_cli.py +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.4}/tests/test_policy.py +0 -0
- {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.
|
|
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
|
|
@@ -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 =
|
|
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 =
|
|
74
|
+
servers = spec.get("servers") or []
|
|
65
75
|
if servers:
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
|
-
from mcpify.spec import
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|