mcpify-openapi 1.0.2__tar.gz → 1.0.3__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.3}/PKG-INFO +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify/__init__.py +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify/cli.py +35 -8
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify/http_client.py +11 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify/spec.py +19 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify/tools.py +43 -15
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify_openapi.egg-info/PKG-INFO +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify_openapi.egg-info/SOURCES.txt +1 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/pyproject.toml +1 -1
- mcpify_openapi-1.0.3/tests/test_hostile_specs.py +168 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/tests/test_ref_params.py +1 -1
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/tests/test_tools.py +9 -3
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/LICENSE +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/README.md +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify/api_server.py +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify_openapi.egg-info/dependency_links.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify_openapi.egg-info/entry_points.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify_openapi.egg-info/requires.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/mcpify_openapi.egg-info/top_level.txt +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/setup.cfg +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/tests/test_cli.py +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/tests/test_e2e.py +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/tests/test_policy.py +0 -0
- {mcpify_openapi-1.0.2 → mcpify_openapi-1.0.3}/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.3
|
|
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
|
|
@@ -61,10 +61,31 @@ def filter_tools(tools: list[dict], args: argparse.Namespace) -> list[dict]:
|
|
|
61
61
|
def _base_url(spec: dict, override: str | None) -> str:
|
|
62
62
|
if override:
|
|
63
63
|
return override
|
|
64
|
-
servers =
|
|
64
|
+
servers = spec.get("servers") or []
|
|
65
65
|
if servers:
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
entry = servers[0] if isinstance(servers[0], dict) else {"url": str(servers[0])}
|
|
67
|
+
url = str(entry.get("url", ""))
|
|
68
|
+
# substitute server variables from their declared defaults
|
|
69
|
+
variables = entry.get("variables")
|
|
70
|
+
if isinstance(variables, dict):
|
|
71
|
+
for name, var in variables.items():
|
|
72
|
+
default = var.get("default") if isinstance(var, dict) else None
|
|
73
|
+
if default is not None:
|
|
74
|
+
url = url.replace("{" + name + "}", str(default))
|
|
75
|
+
if "{" in url:
|
|
76
|
+
kalan = re.findall(r"\{([^{}]+)\}", url)
|
|
77
|
+
raise SpecError(
|
|
78
|
+
f"server URL variable(s) {kalan} have no default; "
|
|
79
|
+
"pass --base-url to set the target explicitly"
|
|
80
|
+
)
|
|
81
|
+
if url and not url.startswith(("http://", "https://")):
|
|
82
|
+
raise SpecError(
|
|
83
|
+
f"server URL '{url}' is relative and cannot be called; "
|
|
84
|
+
"pass --base-url with the absolute URL"
|
|
85
|
+
)
|
|
86
|
+
if url:
|
|
87
|
+
return url
|
|
88
|
+
raise SpecError(
|
|
68
89
|
"no base URL: the spec declares no servers and --base-url was not given"
|
|
69
90
|
)
|
|
70
91
|
|
|
@@ -172,16 +193,15 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
172
193
|
print(dim(f"serve it: mcpify serve {args.spec}"))
|
|
173
194
|
|
|
174
195
|
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
196
|
auth = None
|
|
180
197
|
if args.auth_env:
|
|
181
198
|
auth = AuthConfig(args.auth_env, args.auth_style, args.auth_name)
|
|
182
199
|
from .api_server import ApiServer
|
|
183
200
|
|
|
184
|
-
|
|
201
|
+
try:
|
|
202
|
+
base = _base_url(spec, args.base_url)
|
|
203
|
+
except SpecError as err:
|
|
204
|
+
_fail(str(err))
|
|
185
205
|
server = ApiServer(
|
|
186
206
|
spec,
|
|
187
207
|
base,
|
|
@@ -230,6 +250,13 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
230
250
|
print(warn(f"warning: {no_summary}/{total} operations have no summary (agents see no description)"))
|
|
231
251
|
if variabled:
|
|
232
252
|
print(warn(f"warning: server URL(s) contain variables: {', '.join(variabled)} — pass --base-url"))
|
|
253
|
+
if servers and not servers[0].startswith(("http://", "https://")):
|
|
254
|
+
print(warn(f"warning: server URL '{servers[0]}' is relative — pass --base-url with the absolute URL"))
|
|
255
|
+
deprecated = sum(1 for _m, _p, op in iter_operations(spec) if op.get("deprecated"))
|
|
256
|
+
if deprecated:
|
|
257
|
+
print(warn(f"warning: {deprecated} deprecated operation(s) will be exposed (filter with --tag/--exclude if unintended)"))
|
|
258
|
+
if total > 50:
|
|
259
|
+
print(warn(f"warning: {total} operations is a large tool surface — consider --tag/--include/--exclude to protect the model's context"))
|
|
233
260
|
if not missing_id and not no_summary:
|
|
234
261
|
print(ok("all operations carry operationId and summary — agent-friendly ✓"))
|
|
235
262
|
|
|
@@ -41,11 +41,21 @@ def execute(request: dict, timeout: float = 30.0) -> dict:
|
|
|
41
41
|
return {"status": status, "body": raw, "json": parsed}
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
MAX_RESULT_CHARS = 40_000
|
|
45
|
+
|
|
46
|
+
|
|
44
47
|
def format_result(result: dict) -> tuple[str, bool]:
|
|
45
|
-
"""Format an execute() result for an MCP tool response: (text, is_error).
|
|
48
|
+
"""Format an execute() result for an MCP tool response: (text, is_error).
|
|
49
|
+
|
|
50
|
+
Oversized bodies are truncated so a single tool call cannot blow the
|
|
51
|
+
client's context window.
|
|
52
|
+
"""
|
|
46
53
|
body = result["body"]
|
|
47
54
|
if result["json"] is not None:
|
|
48
55
|
body = json.dumps(result["json"], ensure_ascii=False, indent=2)
|
|
56
|
+
if len(body) > MAX_RESULT_CHARS:
|
|
57
|
+
kesilen = len(body) - MAX_RESULT_CHARS
|
|
58
|
+
body = body[:MAX_RESULT_CHARS] + f"\n… [truncated {kesilen:,} more characters]"
|
|
49
59
|
is_error = result["status"] == 0 or result["status"] >= 400
|
|
50
60
|
if is_error:
|
|
51
61
|
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.3
|
|
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
|
|
@@ -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.3"
|
|
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,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
|
|
File without changes
|
|
File without changes
|