universal-mcp 0.1.13rc7__py3-none-any.whl → 0.1.14__py3-none-any.whl
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.
- universal_mcp/applications/__init__.py +12 -2
- universal_mcp/cli.py +27 -11
- universal_mcp/utils/api_generator.py +3 -93
- universal_mcp/utils/docgen.py +2 -2
- universal_mcp/utils/installation.py +8 -8
- universal_mcp/utils/openapi.py +523 -297
- universal_mcp/utils/readme.py +92 -0
- {universal_mcp-0.1.13rc7.dist-info → universal_mcp-0.1.14.dist-info}/METADATA +2 -53
- {universal_mcp-0.1.13rc7.dist-info → universal_mcp-0.1.14.dist-info}/RECORD +11 -10
- {universal_mcp-0.1.13rc7.dist-info → universal_mcp-0.1.14.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.13rc7.dist-info → universal_mcp-0.1.14.dist-info}/entry_points.txt +0 -0
universal_mcp/utils/openapi.py
CHANGED
@@ -1,12 +1,65 @@
|
|
1
1
|
import json
|
2
2
|
import re
|
3
|
-
from
|
3
|
+
from functools import cache
|
4
4
|
from pathlib import Path
|
5
5
|
from typing import Any, Literal
|
6
6
|
|
7
7
|
import yaml
|
8
|
-
from
|
9
|
-
|
8
|
+
from pydantic import BaseModel
|
9
|
+
|
10
|
+
|
11
|
+
class Parameters(BaseModel):
|
12
|
+
name: str
|
13
|
+
identifier: str
|
14
|
+
description: str = ""
|
15
|
+
type: str = "string"
|
16
|
+
where: Literal["path", "query", "header", "body"]
|
17
|
+
required: bool
|
18
|
+
example: str | None = None
|
19
|
+
|
20
|
+
def __str__(self):
|
21
|
+
return f"{self.name}: ({self.type})"
|
22
|
+
|
23
|
+
|
24
|
+
class Method(BaseModel):
|
25
|
+
name: str
|
26
|
+
summary: str
|
27
|
+
tags: list[str]
|
28
|
+
path: str
|
29
|
+
method: str
|
30
|
+
path_params: list[Parameters]
|
31
|
+
query_params: list[Parameters]
|
32
|
+
body_params: list[Parameters]
|
33
|
+
return_type: str
|
34
|
+
|
35
|
+
def deduplicate_params(self):
|
36
|
+
"""
|
37
|
+
Deduplicate parameters by name.
|
38
|
+
Sometimes the same parameter is defined in multiple places, we only want to include it once.
|
39
|
+
"""
|
40
|
+
# TODO: Implement this
|
41
|
+
pass
|
42
|
+
|
43
|
+
def render(self, template_dir: str, template_name: str = "method.jinja2") -> str:
|
44
|
+
"""
|
45
|
+
Render this Method instance into source code using a Jinja2 template.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
template_dir (str): Directory where the Jinja2 templates are located.
|
49
|
+
template_name (str): Filename of the method template.
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
str: The rendered method source code.
|
53
|
+
"""
|
54
|
+
from jinja2 import Environment, FileSystemLoader
|
55
|
+
|
56
|
+
env = Environment(
|
57
|
+
loader=FileSystemLoader(template_dir),
|
58
|
+
trim_blocks=True,
|
59
|
+
lstrip_blocks=True,
|
60
|
+
)
|
61
|
+
template = env.get_template(template_name)
|
62
|
+
return template.render(method=self)
|
10
63
|
|
11
64
|
|
12
65
|
def convert_to_snake_case(identifier: str) -> str:
|
@@ -27,16 +80,68 @@ def convert_to_snake_case(identifier: str) -> str:
|
|
27
80
|
return result.lower()
|
28
81
|
|
29
82
|
|
30
|
-
|
83
|
+
@cache
|
84
|
+
def _resolve_schema_reference(reference, schema):
|
85
|
+
"""
|
86
|
+
Resolve a JSON schema reference to its target schema.
|
87
|
+
|
88
|
+
Args:
|
89
|
+
reference (str): The reference string (e.g., '#/components/schemas/User')
|
90
|
+
schema (dict): The complete OpenAPI schema that contains the reference
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
dict: The resolved schema, or None if not found
|
94
|
+
"""
|
95
|
+
if not reference.startswith("#/"):
|
96
|
+
return None
|
97
|
+
|
98
|
+
# Split the reference path and navigate through the schema
|
99
|
+
parts = reference[2:].split("/")
|
100
|
+
current = schema
|
101
|
+
|
102
|
+
for part in parts:
|
103
|
+
if part in current:
|
104
|
+
current = current[part]
|
105
|
+
else:
|
106
|
+
return None
|
107
|
+
|
108
|
+
return current
|
109
|
+
|
110
|
+
|
111
|
+
def _resolve_references(schema: dict[str, Any]):
|
112
|
+
"""
|
113
|
+
Recursively walk the OpenAPI schema and inline all JSON Schema $ref references.
|
114
|
+
"""
|
115
|
+
|
116
|
+
def _resolve(node):
|
117
|
+
if isinstance(node, dict):
|
118
|
+
# If this dict is a reference, replace it with the resolved schema
|
119
|
+
if "$ref" in node:
|
120
|
+
ref = node["$ref"]
|
121
|
+
resolved = _resolve_schema_reference(ref, schema)
|
122
|
+
# If resolution fails, leave the ref dict as-is
|
123
|
+
return _resolve(resolved) if resolved is not None else node
|
124
|
+
# Otherwise, recurse into each key/value
|
125
|
+
return {key: _resolve(value) for key, value in node.items()}
|
126
|
+
elif isinstance(node, list):
|
127
|
+
# Recurse into list elements
|
128
|
+
return [_resolve(item) for item in node]
|
129
|
+
# Primitive value, return as-is
|
130
|
+
return node
|
131
|
+
|
132
|
+
return _resolve(schema)
|
133
|
+
|
134
|
+
|
135
|
+
def _load_and_resolve_references(path: Path):
|
136
|
+
# Load the schema
|
31
137
|
type = "yaml" if path.suffix == ".yaml" else "json"
|
32
138
|
with open(path) as f:
|
33
|
-
if type == "yaml"
|
34
|
-
|
35
|
-
|
36
|
-
return json.load(f)
|
139
|
+
schema = yaml.safe_load(f) if type == "yaml" else json.load(f)
|
140
|
+
# Resolve references
|
141
|
+
return _resolve_references(schema)
|
37
142
|
|
38
143
|
|
39
|
-
def
|
144
|
+
def _determine_return_type(operation: dict[str, Any]) -> str:
|
40
145
|
"""
|
41
146
|
Determine the return type from the response schema.
|
42
147
|
|
@@ -73,339 +178,460 @@ def determine_return_type(operation: dict[str, Any]) -> str:
|
|
73
178
|
return "Any"
|
74
179
|
|
75
180
|
|
76
|
-
def
|
181
|
+
def _determine_function_name(operation: dict[str, Any], path: str, method: str) -> str:
|
77
182
|
"""
|
78
|
-
|
79
|
-
|
80
|
-
Args:
|
81
|
-
reference (str): The reference string (e.g., '#/components/schemas/User')
|
82
|
-
schema (dict): The complete OpenAPI schema that contains the reference
|
83
|
-
|
84
|
-
Returns:
|
85
|
-
dict: The resolved schema, or None if not found
|
183
|
+
Determine the function name from the operation.
|
86
184
|
"""
|
87
|
-
|
88
|
-
|
185
|
+
# Determine function name
|
186
|
+
if "operationId" in operation:
|
187
|
+
raw_name = operation["operationId"]
|
188
|
+
cleaned_name = raw_name.replace(".", "_").replace("-", "_")
|
189
|
+
func_name = convert_to_snake_case(cleaned_name)
|
190
|
+
else:
|
191
|
+
# Generate name from path and method
|
192
|
+
path_parts = path.strip("/").split("/")
|
193
|
+
name_parts = [method]
|
194
|
+
for part in path_parts:
|
195
|
+
if part.startswith("{") and part.endswith("}"):
|
196
|
+
name_parts.append("by_" + part[1:-1])
|
197
|
+
else:
|
198
|
+
name_parts.append(part)
|
199
|
+
func_name = "_".join(name_parts).replace("-", "_").lower()
|
200
|
+
|
201
|
+
# Only fix isolated 'a' and 'an' as articles, not when they're part of words
|
202
|
+
func_name = re.sub(
|
203
|
+
r"_a([^_a-z])", r"_a_\1", func_name
|
204
|
+
) # Fix for patterns like retrieve_ablock -> retrieve_a_block
|
205
|
+
func_name = re.sub(
|
206
|
+
r"_a$", r"_a", func_name
|
207
|
+
) # Don't change if 'a' is at the end of the name
|
208
|
+
func_name = re.sub(
|
209
|
+
r"_an([^_a-z])", r"_an_\1", func_name
|
210
|
+
) # Fix for patterns like create_anitem -> create_an_item
|
211
|
+
func_name = re.sub(
|
212
|
+
r"_an$", r"_an", func_name
|
213
|
+
) # Don't change if 'an' is at the end of the name
|
214
|
+
return func_name
|
215
|
+
|
216
|
+
|
217
|
+
def _generate_path_params(path: str) -> list[Parameters]:
|
218
|
+
path_params_in_url = re.findall(r"{([^}]+)}", path)
|
219
|
+
parameters = []
|
220
|
+
for param in path_params_in_url:
|
221
|
+
try:
|
222
|
+
parameters.append(
|
223
|
+
Parameters(
|
224
|
+
name=param.replace("-", "_"),
|
225
|
+
identifier=param,
|
226
|
+
description=param,
|
227
|
+
type="string",
|
228
|
+
where="path",
|
229
|
+
required=True,
|
230
|
+
)
|
231
|
+
)
|
232
|
+
except Exception as e:
|
233
|
+
print(f"Error generating path parameters {param}: {e}")
|
234
|
+
raise e
|
235
|
+
return parameters
|
89
236
|
|
90
|
-
# Split the reference path and navigate through the schema
|
91
|
-
parts = reference[2:].split("/")
|
92
|
-
current = schema
|
93
237
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
238
|
+
def _generate_url(path: str, path_params: list[Parameters]):
|
239
|
+
formatted_path = path
|
240
|
+
for param in path_params:
|
241
|
+
formatted_path = formatted_path.replace(
|
242
|
+
f"{{{param.identifier}}}", f"{{{param.name}}}"
|
243
|
+
)
|
244
|
+
return formatted_path
|
99
245
|
|
100
|
-
return current
|
101
246
|
|
247
|
+
def _generate_query_params(operation: dict[str, Any]) -> list[Parameters]:
|
248
|
+
query_params = []
|
249
|
+
for param in operation.get("parameters", []):
|
250
|
+
name = param.get("name")
|
251
|
+
description = param.get("description", "")
|
252
|
+
type = param.get("type")
|
253
|
+
where = param.get("in")
|
254
|
+
required = param.get("required")
|
255
|
+
if where == "query":
|
256
|
+
parameter = Parameters(
|
257
|
+
name=name.replace("-", "_"),
|
258
|
+
identifier=name,
|
259
|
+
description=description,
|
260
|
+
type=type,
|
261
|
+
where=where,
|
262
|
+
required=required,
|
263
|
+
)
|
264
|
+
query_params.append(parameter)
|
265
|
+
return query_params
|
266
|
+
|
267
|
+
|
268
|
+
def _generate_body_params(operation: dict[str, Any]) -> list[Parameters]:
|
269
|
+
body_params = []
|
270
|
+
request_body = operation.get("requestBody", {})
|
271
|
+
required = request_body.get("required", False)
|
272
|
+
content = request_body.get("content", {})
|
273
|
+
json_content = content.get("application/json", {})
|
274
|
+
schema = json_content.get("schema", {})
|
275
|
+
properties = schema.get("properties", {})
|
276
|
+
for param in properties:
|
277
|
+
body_params.append(
|
278
|
+
Parameters(
|
279
|
+
name=param,
|
280
|
+
identifier=param,
|
281
|
+
description=param,
|
282
|
+
type="string",
|
283
|
+
where="body",
|
284
|
+
required=required,
|
285
|
+
)
|
286
|
+
)
|
287
|
+
return body_params
|
102
288
|
|
103
|
-
|
289
|
+
|
290
|
+
def _generate_method_code(path, method, operation):
|
104
291
|
"""
|
105
|
-
Generate
|
292
|
+
Generate the code for a single API method.
|
106
293
|
|
107
294
|
Args:
|
108
|
-
|
295
|
+
path (str): The API path (e.g., '/users/{user_id}').
|
296
|
+
method (str): The HTTP method (e.g., 'get').
|
297
|
+
operation (dict): The operation details from the schema.
|
298
|
+
full_schema (dict): The complete OpenAPI schema, used for reference resolution.
|
299
|
+
tool_name (str, optional): The name of the tool/app to prefix the function name with.
|
109
300
|
|
110
301
|
Returns:
|
111
|
-
|
302
|
+
tuple: (method_code, func_name) - The Python code for the method and its name.
|
112
303
|
"""
|
113
|
-
# Extract API info for naming and base URL
|
114
|
-
info = schema.get("info", {})
|
115
|
-
api_title = info.get("title", "API")
|
116
304
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
305
|
+
func_name = _determine_function_name(operation, path, method)
|
306
|
+
operation.get("summary", "")
|
307
|
+
operation.get("tags", [])
|
308
|
+
# Extract path parameters from the URL path
|
309
|
+
path_params = _generate_path_params(path)
|
310
|
+
query_params = _generate_query_params(operation)
|
311
|
+
_generate_body_params(operation)
|
312
|
+
return_type = _determine_return_type(operation)
|
313
|
+
# gen_method = Method(name=func_name, summary=summary, tags=tags, path=path, method=method, path_params=path_params, query_params=query_params, body_params=body_params, return_type=return_type)
|
314
|
+
# logger.info(f"Generated method: {gen_method.model_dump()}")
|
315
|
+
# return method.render(template_dir="templates", template_name="method.jinja2")
|
122
316
|
|
123
|
-
|
124
|
-
|
125
|
-
# Convert API title to a clean class name
|
126
|
-
base_name = "".join(word.capitalize() for word in api_title.split())
|
127
|
-
clean_name = "".join(c for c in base_name if c.isalnum())
|
128
|
-
class_name = f"{clean_name}App"
|
129
|
-
else:
|
130
|
-
class_name = "APIClient"
|
131
|
-
|
132
|
-
# Collect all methods
|
133
|
-
methods = []
|
134
|
-
for path, path_info in schema.get("paths", {}).items():
|
135
|
-
for method in path_info:
|
136
|
-
if method in ["get", "post", "put", "delete", "patch", "options", "head"]:
|
137
|
-
operation = path_info[method]
|
138
|
-
function = generate_method_code(path, method, operation, schema)
|
139
|
-
methods.append(function)
|
140
|
-
|
141
|
-
# Set up Jinja2 environment
|
142
|
-
env = Environment(
|
143
|
-
loader=FileSystemLoader(Path(__file__).parent.parent / "templates"),
|
144
|
-
autoescape=select_autoescape(),
|
145
|
-
)
|
146
|
-
template = env.get_template("api_client.py.j2")
|
147
|
-
|
148
|
-
# Render the template
|
149
|
-
class_code = template.render(
|
150
|
-
class_name=class_name, base_url=base_url, methods=methods
|
151
|
-
)
|
152
|
-
|
153
|
-
return class_code
|
317
|
+
has_body = "requestBody" in operation
|
318
|
+
body_required = has_body and operation["requestBody"].get("required", False)
|
154
319
|
|
320
|
+
# Check if the requestBody has actual content or is empty
|
321
|
+
has_empty_body = False
|
322
|
+
if has_body:
|
323
|
+
request_body_content = operation["requestBody"].get("content", {})
|
324
|
+
if not request_body_content or all(
|
325
|
+
not content for content_type, content in request_body_content.items()
|
326
|
+
):
|
327
|
+
has_empty_body = True
|
328
|
+
else:
|
329
|
+
# Handle empty properties with additionalProperties:true
|
330
|
+
for content_type, content in request_body_content.items():
|
331
|
+
if content_type.startswith("application/json") and "schema" in content:
|
332
|
+
schema = content["schema"]
|
333
|
+
|
334
|
+
# Check if properties is empty and additionalProperties is true
|
335
|
+
if (
|
336
|
+
schema.get("type") == "object"
|
337
|
+
and schema.get("additionalProperties", False) is True
|
338
|
+
):
|
339
|
+
properties = schema.get("properties", {})
|
340
|
+
if not properties or len(properties) == 0:
|
341
|
+
has_empty_body = True
|
342
|
+
|
343
|
+
# Extract request body schema properties and required fields
|
344
|
+
required_fields = []
|
345
|
+
request_body_properties = {}
|
346
|
+
is_array_body = False
|
155
347
|
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
description: str
|
163
|
-
tags: list[str]
|
164
|
-
implementation: str
|
348
|
+
if has_body:
|
349
|
+
for content_type, content in (
|
350
|
+
operation["requestBody"].get("content", {}).items()
|
351
|
+
):
|
352
|
+
if content_type.startswith("application/json") and "schema" in content:
|
353
|
+
schema = content["schema"]
|
165
354
|
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
355
|
+
# Check if the schema is an array type
|
356
|
+
if schema.get("type") == "array":
|
357
|
+
is_array_body = True
|
358
|
+
schema.get("items", {})
|
170
359
|
|
360
|
+
else:
|
361
|
+
# Extract required fields from schema
|
362
|
+
if "required" in schema:
|
363
|
+
required_fields = schema["required"]
|
364
|
+
# Extract properties from schema
|
365
|
+
if "properties" in schema:
|
366
|
+
request_body_properties = schema["properties"]
|
367
|
+
|
368
|
+
# Handle schemas with empty properties but additionalProperties: true
|
369
|
+
# by treating them similar to empty bodies
|
370
|
+
if (
|
371
|
+
not request_body_properties or len(request_body_properties) == 0
|
372
|
+
) and schema.get("additionalProperties") is True:
|
373
|
+
has_empty_body = True
|
374
|
+
|
375
|
+
# Build function arguments
|
376
|
+
required_args = []
|
377
|
+
optional_args = []
|
378
|
+
|
379
|
+
# Add path parameters
|
380
|
+
for param in path_params:
|
381
|
+
if param.name not in required_args:
|
382
|
+
required_args.append(param.name)
|
383
|
+
|
384
|
+
for param in query_params:
|
385
|
+
param_name = param["name"]
|
386
|
+
# Handle parameters with square brackets and hyphens by converting to valid Python identifiers
|
387
|
+
param_identifier = (
|
388
|
+
param_name.replace("[", "_").replace("]", "").replace("-", "_")
|
389
|
+
)
|
390
|
+
if param_identifier not in required_args and param_identifier not in [
|
391
|
+
p.split("=")[0] for p in optional_args
|
392
|
+
]:
|
393
|
+
if param.get("required", False):
|
394
|
+
required_args.append(param_identifier)
|
395
|
+
else:
|
396
|
+
optional_args.append(f"{param_identifier}=None")
|
171
397
|
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
398
|
+
# Handle array type request body differently
|
399
|
+
request_body_params = []
|
400
|
+
if has_body:
|
401
|
+
if is_array_body:
|
402
|
+
# For array request bodies, add a single parameter for the entire array
|
403
|
+
array_param_name = "items"
|
404
|
+
# Try to get a better name from the operation or path
|
405
|
+
if func_name.endswith("_list_input"):
|
406
|
+
array_param_name = func_name.replace("_list_input", "")
|
407
|
+
elif "List" in func_name:
|
408
|
+
array_param_name = func_name.split("List")[0].lower() + "_list"
|
409
|
+
|
410
|
+
# Make the array parameter required if the request body is required
|
411
|
+
if body_required:
|
412
|
+
required_args.append(array_param_name)
|
413
|
+
else:
|
414
|
+
optional_args.append(f"{array_param_name}=None")
|
415
|
+
|
416
|
+
# Remember this is an array param
|
417
|
+
request_body_params = [array_param_name]
|
418
|
+
elif request_body_properties:
|
419
|
+
# For object request bodies, add individual properties as parameters
|
420
|
+
for prop_name in request_body_properties:
|
421
|
+
if prop_name in required_fields:
|
422
|
+
request_body_params.append(prop_name)
|
423
|
+
if prop_name not in required_args:
|
424
|
+
required_args.append(prop_name)
|
425
|
+
else:
|
426
|
+
request_body_params.append(prop_name)
|
427
|
+
if f"{prop_name}=None" not in optional_args:
|
428
|
+
optional_args.append(f"{prop_name}=None")
|
177
429
|
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
operation: The operation details from the schema.
|
182
|
-
full_schema: The complete OpenAPI schema, used for reference resolution.
|
430
|
+
# If request body is present but empty (content: {}), add a generic request_body parameter
|
431
|
+
if has_empty_body and "request_body=None" not in optional_args:
|
432
|
+
optional_args.append("request_body=None")
|
183
433
|
|
184
|
-
|
185
|
-
|
186
|
-
"""
|
187
|
-
logger.debug(f"Generating function for {method.upper()} {path}")
|
188
|
-
|
189
|
-
# Helper to map JSON schema types to Python types
|
190
|
-
def map_type(sch: dict[str, Any]) -> str:
|
191
|
-
t = sch.get("type")
|
192
|
-
if t == "integer":
|
193
|
-
return "int"
|
194
|
-
if t == "number":
|
195
|
-
return "float"
|
196
|
-
if t == "boolean":
|
197
|
-
return "bool"
|
198
|
-
if t == "array":
|
199
|
-
return "list[Any]"
|
200
|
-
if t == "object":
|
201
|
-
return "dict[str, Any]"
|
202
|
-
return "Any"
|
434
|
+
# Combine required and optional arguments
|
435
|
+
args = required_args + optional_args
|
203
436
|
|
204
|
-
# Determine
|
205
|
-
|
206
|
-
|
207
|
-
|
437
|
+
# Determine return type
|
438
|
+
return_type = _determine_return_type(operation)
|
439
|
+
if args:
|
440
|
+
signature = f" def {func_name}(self, {', '.join(args)}) -> {return_type}:"
|
208
441
|
else:
|
209
|
-
|
210
|
-
parts = [method.lower()]
|
211
|
-
for seg in segments:
|
212
|
-
if seg.startswith("{") and seg.endswith("}"):
|
213
|
-
parts.append(f"by_{seg[1:-1]}")
|
214
|
-
else:
|
215
|
-
parts.append(seg)
|
216
|
-
method_name = "_".join(parts)
|
217
|
-
logger.debug(f"Resolved function name={method_name}")
|
442
|
+
signature = f" def {func_name}(self) -> {return_type}:"
|
218
443
|
|
219
|
-
#
|
220
|
-
|
221
|
-
for param in operation.get("parameters", []):
|
222
|
-
if ref := param.get("$ref"):
|
223
|
-
target = resolve_schema_reference(ref, full_schema)
|
224
|
-
if target:
|
225
|
-
resolved_params.append(target)
|
226
|
-
else:
|
227
|
-
logger.warning(f"Unresolved parameter reference: {ref}")
|
228
|
-
else:
|
229
|
-
resolved_params.append(param)
|
444
|
+
# Build method body
|
445
|
+
body_lines = []
|
230
446
|
|
231
|
-
|
232
|
-
|
233
|
-
|
447
|
+
for param in path_params:
|
448
|
+
body_lines.append(f" if {param.name} is None:")
|
449
|
+
body_lines.append(
|
450
|
+
f" raise ValueError(\"Missing required parameter '{param.identifier}'\")" # Use original name in error
|
451
|
+
)
|
234
452
|
|
235
|
-
#
|
236
|
-
has_body = "requestBody" in operation
|
237
|
-
body_required = bool(has_body and operation["requestBody"].get("required"))
|
238
|
-
content = (
|
239
|
-
(operation.get("requestBody", {}) or {}).get("content", {}) if has_body else {}
|
240
|
-
)
|
241
|
-
is_array_body = False
|
242
|
-
request_props: dict[str, Any] = {}
|
243
|
-
required_fields: list[str] = []
|
244
|
-
if has_body and content:
|
245
|
-
for mime, info in content.items():
|
246
|
-
if not mime.startswith("application/json") or "schema" not in info:
|
247
|
-
continue
|
248
|
-
schema = info["schema"]
|
249
|
-
if ref := schema.get("$ref"):
|
250
|
-
schema = resolve_schema_reference(ref, full_schema) or schema
|
251
|
-
if schema.get("type") == "array":
|
252
|
-
is_array_body = True
|
253
|
-
else:
|
254
|
-
required_fields = schema.get("required", []) or []
|
255
|
-
request_props = schema.get("properties", {}) or {}
|
256
|
-
for name, prop_schema in list(request_props.items()):
|
257
|
-
if pre := prop_schema.get("$ref"):
|
258
|
-
request_props[name] = (
|
259
|
-
resolve_schema_reference(pre, full_schema) or prop_schema
|
260
|
-
)
|
261
|
-
|
262
|
-
# Build function arguments with Annotated[type, description]
|
263
|
-
arg_defs: dict[str, str] = {}
|
264
|
-
for p in path_params:
|
265
|
-
name = p["name"]
|
266
|
-
ty = map_type(p.get("schema", {}))
|
267
|
-
desc = p.get("description", "")
|
268
|
-
arg_defs[name] = f"Annotated[{ty}, {desc!r}]"
|
269
|
-
for p in query_params:
|
270
|
-
name = p["name"]
|
271
|
-
ty = map_type(p.get("schema", {}))
|
272
|
-
desc = p.get("description", "")
|
273
|
-
if p.get("required"):
|
274
|
-
arg_defs[name] = f"Annotated[{ty}, {desc!r}]"
|
275
|
-
else:
|
276
|
-
arg_defs[name] = f"Annotated[{ty}, {desc!r}] = None"
|
453
|
+
# Build request body (handle array and object types differently)
|
277
454
|
if has_body:
|
278
455
|
if is_array_body:
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
elif request_props:
|
285
|
-
for prop, schema in request_props.items():
|
286
|
-
ty = map_type(schema)
|
287
|
-
desc = schema.get("description", "")
|
288
|
-
if prop in required_fields:
|
289
|
-
arg_defs[prop] = f"Annotated[{ty}, {desc!r}]"
|
290
|
-
else:
|
291
|
-
arg_defs[prop] = f"Annotated[{ty}, {desc!r}] = None"
|
292
|
-
else:
|
293
|
-
desc = operation["requestBody"].get("description", "")
|
294
|
-
arg_defs["request_body"] = f"Annotated[Any, {desc!r}] = None"
|
295
|
-
|
296
|
-
# Sort and order arguments
|
297
|
-
required_keys = sorted(k for k, v in arg_defs.items() if "=" not in v)
|
298
|
-
optional_keys = sorted(k for k, v in arg_defs.items() if "=" in v)
|
299
|
-
ordered_args = {k: arg_defs[k] for k in required_keys + optional_keys}
|
300
|
-
|
301
|
-
return_type = determine_return_type(operation)
|
302
|
-
|
303
|
-
# Assemble description
|
304
|
-
summary = operation.get("summary", "")
|
305
|
-
operation_desc = operation.get("description", "")
|
306
|
-
desc_parts: list[str] = []
|
307
|
-
if summary:
|
308
|
-
desc_parts.append(summary)
|
309
|
-
if operation_desc:
|
310
|
-
desc_parts.append(operation_desc)
|
311
|
-
description_text = ". ".join(desc_parts)
|
312
|
-
|
313
|
-
tags = operation.get("tags", []) or []
|
314
|
-
|
315
|
-
# Generate implementation code
|
316
|
-
implementation_lines = []
|
317
|
-
|
318
|
-
# Add parameter validation for required fields
|
319
|
-
for param in path_params + query_params:
|
320
|
-
if param.get("required"):
|
321
|
-
name = param["name"]
|
322
|
-
implementation_lines.append(f"if {name} is None:")
|
323
|
-
implementation_lines.append(
|
324
|
-
f" raise ValueError(\"Missing required parameter '{name}'\")"
|
325
|
-
)
|
456
|
+
# For array request bodies, use the array parameter directly
|
457
|
+
body_lines.append(" # Use items array directly as request body")
|
458
|
+
body_lines.append(f" request_body = {request_body_params[0]}")
|
459
|
+
elif request_body_properties:
|
460
|
+
# For object request bodies, build the request body from individual parameters
|
326
461
|
|
327
|
-
|
328
|
-
if is_array_body:
|
329
|
-
implementation_lines.append("if items is None:")
|
330
|
-
implementation_lines.append(
|
331
|
-
" raise ValueError(\"Missing required parameter 'items'\")"
|
332
|
-
)
|
333
|
-
elif request_props:
|
334
|
-
for prop in required_fields:
|
335
|
-
implementation_lines.append(f"if {prop} is None:")
|
336
|
-
implementation_lines.append(
|
337
|
-
f" raise ValueError(\"Missing required parameter '{prop}'\")"
|
338
|
-
)
|
339
|
-
else:
|
340
|
-
implementation_lines.append("if request_body is None:")
|
341
|
-
implementation_lines.append(
|
342
|
-
" raise ValueError(\"Missing required parameter 'request_body'\")"
|
343
|
-
)
|
462
|
+
body_lines.append(" request_body = {")
|
344
463
|
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
implementation_lines.append("}")
|
354
|
-
implementation_lines.append(
|
355
|
-
"request_body = {k: v for k, v in request_body.items() if v is not None}"
|
464
|
+
for prop_name in request_body_params:
|
465
|
+
# Only include non-None values in the request body
|
466
|
+
body_lines.append(f" '{prop_name}': {prop_name},")
|
467
|
+
|
468
|
+
body_lines.append(" }")
|
469
|
+
|
470
|
+
body_lines.append(
|
471
|
+
" request_body = {k: v for k, v in request_body.items() if v is not None}"
|
356
472
|
)
|
357
|
-
else:
|
358
|
-
implementation_lines.append("request_body = request_body")
|
359
473
|
|
360
|
-
#
|
361
|
-
|
362
|
-
url =
|
363
|
-
|
364
|
-
implementation_lines.append(f"url = f{url}")
|
474
|
+
# Format URL directly with path parameters
|
475
|
+
url = _generate_url(path, path_params)
|
476
|
+
url_line = f' url = f"{{self.base_url}}{url}"'
|
477
|
+
body_lines.append(url_line)
|
365
478
|
|
366
|
-
# Build query parameters
|
479
|
+
# Build query parameters, handling square brackets in parameter names
|
367
480
|
if query_params:
|
368
|
-
|
481
|
+
query_params_items = []
|
369
482
|
for param in query_params:
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
"
|
483
|
+
param_name = param.name
|
484
|
+
param_identifier = (
|
485
|
+
param_name.replace("[", "_").replace("]", "").replace("-", "_")
|
486
|
+
)
|
487
|
+
query_params_items.append(f"('{param_name}', {param_identifier})")
|
488
|
+
body_lines.append(
|
489
|
+
f" query_params = {{k: v for k, v in [{', '.join(query_params_items)}] if v is not None}}"
|
375
490
|
)
|
376
491
|
else:
|
377
|
-
|
492
|
+
body_lines.append(" query_params = {}")
|
378
493
|
|
379
|
-
# Make
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
494
|
+
# Make HTTP request using the proper method
|
495
|
+
method_lower = method.lower()
|
496
|
+
|
497
|
+
# Determine what to use as the request body argument
|
498
|
+
if has_empty_body:
|
499
|
+
request_body_arg = "request_body"
|
500
|
+
elif not has_body:
|
501
|
+
request_body_arg = "{}"
|
502
|
+
else:
|
503
|
+
request_body_arg = "request_body"
|
504
|
+
|
505
|
+
if method_lower == "get":
|
506
|
+
body_lines.append(" response = self._get(url, params=query_params)")
|
507
|
+
elif method_lower == "post":
|
508
|
+
body_lines.append(
|
509
|
+
f" response = self._post(url, data={request_body_arg}, params=query_params)"
|
510
|
+
)
|
511
|
+
elif method_lower == "put":
|
512
|
+
body_lines.append(
|
513
|
+
f" response = self._put(url, data={request_body_arg}, params=query_params)"
|
384
514
|
)
|
515
|
+
elif method_lower == "patch":
|
516
|
+
body_lines.append(
|
517
|
+
f" response = self._patch(url, data={request_body_arg}, params=query_params)"
|
518
|
+
)
|
519
|
+
elif method_lower == "delete":
|
520
|
+
body_lines.append(" response = self._delete(url, params=query_params)")
|
385
521
|
else:
|
386
|
-
|
387
|
-
f"response = self._{
|
522
|
+
body_lines.append(
|
523
|
+
f" response = self._{method_lower}(url, data={request_body_arg}, params=query_params)"
|
388
524
|
)
|
389
525
|
|
390
526
|
# Handle response
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
527
|
+
body_lines.append(" response.raise_for_status()")
|
528
|
+
body_lines.append(" return response.json()")
|
529
|
+
|
530
|
+
method_code = signature + "\n" + "\n".join(body_lines)
|
531
|
+
return method_code, func_name
|
532
|
+
|
533
|
+
|
534
|
+
def load_schema(path: Path):
|
535
|
+
return _load_and_resolve_references(path)
|
536
|
+
|
537
|
+
|
538
|
+
def generate_api_client(schema, class_name: str | None = None):
|
539
|
+
"""
|
540
|
+
Generate a Python API client class from an OpenAPI schema.
|
541
|
+
|
542
|
+
Args:
|
543
|
+
schema (dict): The OpenAPI schema as a dictionary.
|
544
|
+
|
545
|
+
Returns:
|
546
|
+
str: A string containing the Python code for the API client class.
|
547
|
+
"""
|
548
|
+
methods = []
|
549
|
+
method_names = []
|
406
550
|
|
407
|
-
|
408
|
-
|
551
|
+
# Extract API info for naming and base URL
|
552
|
+
info = schema.get("info", {})
|
553
|
+
api_title = info.get("title", "API")
|
554
|
+
|
555
|
+
# Get base URL from servers array if available
|
556
|
+
base_url = ""
|
557
|
+
servers = schema.get("servers", [])
|
558
|
+
if servers and isinstance(servers, list) and "url" in servers[0]:
|
559
|
+
base_url = servers[0]["url"].rstrip("/")
|
560
|
+
|
561
|
+
# Create a clean class name from API title
|
562
|
+
if api_title:
|
563
|
+
# Convert API title to a clean class name
|
564
|
+
if class_name:
|
565
|
+
clean_name = (
|
566
|
+
class_name.capitalize()[:-3]
|
567
|
+
if class_name.endswith("App")
|
568
|
+
else class_name.capitalize()
|
569
|
+
)
|
570
|
+
else:
|
571
|
+
base_name = "".join(word.capitalize() for word in api_title.split())
|
572
|
+
clean_name = "".join(c for c in base_name if c.isalnum())
|
573
|
+
class_name = f"{clean_name}App"
|
574
|
+
|
575
|
+
# Extract tool name - remove spaces and convert to lowercase
|
576
|
+
tool_name = api_title.lower()
|
577
|
+
|
578
|
+
# Remove version numbers (like 3.0, v1, etc.)
|
579
|
+
tool_name = re.sub(r"\s*v?\d+(\.\d+)*", "", tool_name)
|
580
|
+
|
581
|
+
# Remove common words that aren't needed
|
582
|
+
common_words = ["api", "openapi", "open", "swagger", "spec", "specification"]
|
583
|
+
for word in common_words:
|
584
|
+
tool_name = tool_name.replace(word, "")
|
585
|
+
|
586
|
+
# Remove spaces, hyphens, underscores
|
587
|
+
tool_name = tool_name.replace(" ", "").replace("-", "").replace("_", "")
|
588
|
+
|
589
|
+
# Remove any non-alphanumeric characters
|
590
|
+
tool_name = "".join(c for c in tool_name if c.isalnum())
|
591
|
+
|
592
|
+
# If empty (after cleaning), use generic name
|
593
|
+
if not tool_name:
|
594
|
+
tool_name = "api"
|
595
|
+
else:
|
596
|
+
class_name = "APIClient"
|
597
|
+
tool_name = "api"
|
598
|
+
|
599
|
+
# Iterate over paths and their operations
|
600
|
+
for path, path_info in schema.get("paths", {}).items():
|
601
|
+
for method in path_info:
|
602
|
+
if method in ["get", "post", "put", "delete", "patch", "options", "head"]:
|
603
|
+
operation = path_info[method]
|
604
|
+
method_code, func_name = _generate_method_code(path, method, operation)
|
605
|
+
methods.append(method_code)
|
606
|
+
method_names.append(func_name)
|
607
|
+
|
608
|
+
# Generate list_tools method with all the function names
|
609
|
+
tools_list = ",\n ".join([f"self.{name}" for name in method_names])
|
610
|
+
list_tools_method = f""" def list_tools(self):
|
611
|
+
return [
|
612
|
+
{tools_list}
|
613
|
+
]"""
|
614
|
+
|
615
|
+
# Generate class imports
|
616
|
+
imports = [
|
617
|
+
"from typing import Any",
|
618
|
+
"from universal_mcp.applications import APIApplication",
|
619
|
+
"from universal_mcp.integrations import Integration",
|
620
|
+
]
|
621
|
+
|
622
|
+
# Construct the class code
|
623
|
+
class_code = (
|
624
|
+
"\n".join(imports) + "\n\n"
|
625
|
+
f"class {class_name}(APIApplication):\n"
|
626
|
+
f" def __init__(self, integration: Integration = None, **kwargs) -> None:\n"
|
627
|
+
f" super().__init__(name='{class_name.lower()}', integration=integration, **kwargs)\n"
|
628
|
+
f' self.base_url = "{base_url}"\n\n'
|
629
|
+
+ "\n\n".join(methods)
|
630
|
+
+ "\n\n"
|
631
|
+
+ list_tools_method
|
632
|
+
+ "\n"
|
633
|
+
)
|
634
|
+
return class_code
|
409
635
|
|
410
636
|
|
411
637
|
# Example usage
|