universal-mcp 0.1.13rc3__py3-none-any.whl → 0.1.13rc14__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 +32 -8
- universal_mcp/cli.py +41 -27
- universal_mcp/integrations/__init__.py +1 -1
- universal_mcp/servers/server.py +0 -2
- universal_mcp/stores/store.py +2 -0
- universal_mcp/tools/tools.py +1 -3
- universal_mcp/utils/agentr.py +8 -3
- universal_mcp/utils/api_generator.py +14 -101
- universal_mcp/utils/docgen.py +2 -2
- universal_mcp/utils/openapi.py +527 -285
- universal_mcp/utils/readme.py +92 -0
- {universal_mcp-0.1.13rc3.dist-info → universal_mcp-0.1.13rc14.dist-info}/METADATA +2 -53
- {universal_mcp-0.1.13rc3.dist-info → universal_mcp-0.1.13rc14.dist-info}/RECORD +15 -14
- {universal_mcp-0.1.13rc3.dist-info → universal_mcp-0.1.13rc14.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.13rc3.dist-info → universal_mcp-0.1.13rc14.dist-info}/entry_points.txt +0 -0
universal_mcp/utils/openapi.py
CHANGED
@@ -1,15 +1,69 @@
|
|
1
1
|
import json
|
2
2
|
import re
|
3
|
+
from functools import cache
|
3
4
|
from pathlib import Path
|
4
|
-
from typing import Any,
|
5
|
-
from loguru import logger
|
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)
|
63
|
+
|
10
64
|
|
11
65
|
def convert_to_snake_case(identifier: str) -> str:
|
12
|
-
"""
|
66
|
+
"""
|
13
67
|
Convert a camelCase or PascalCase identifier to snake_case.
|
14
68
|
|
15
69
|
Args:
|
@@ -26,16 +80,68 @@ def convert_to_snake_case(identifier: str) -> str:
|
|
26
80
|
return result.lower()
|
27
81
|
|
28
82
|
|
29
|
-
|
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
|
30
137
|
type = "yaml" if path.suffix == ".yaml" else "json"
|
31
138
|
with open(path) as f:
|
32
|
-
if type == "yaml"
|
33
|
-
|
34
|
-
|
35
|
-
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)
|
36
142
|
|
37
143
|
|
38
|
-
def
|
144
|
+
def _determine_return_type(operation: dict[str, Any]) -> str:
|
39
145
|
"""
|
40
146
|
Determine the return type from the response schema.
|
41
147
|
|
@@ -72,34 +178,364 @@ def determine_return_type(operation: dict[str, Any]) -> str:
|
|
72
178
|
return "Any"
|
73
179
|
|
74
180
|
|
75
|
-
def
|
181
|
+
def _determine_function_name(operation: dict[str, Any], path: str, method: str) -> str:
|
76
182
|
"""
|
77
|
-
|
183
|
+
Determine the function name from the operation.
|
184
|
+
"""
|
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
|
236
|
+
|
237
|
+
|
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
|
245
|
+
|
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
|
288
|
+
|
289
|
+
|
290
|
+
def _generate_method_code(path, method, operation):
|
291
|
+
"""
|
292
|
+
Generate the code for a single API method.
|
78
293
|
|
79
294
|
Args:
|
80
|
-
|
81
|
-
|
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.
|
82
300
|
|
83
301
|
Returns:
|
84
|
-
|
302
|
+
tuple: (method_code, func_name) - The Python code for the method and its name.
|
85
303
|
"""
|
86
|
-
if not reference.startswith("#/"):
|
87
|
-
return None
|
88
304
|
|
89
|
-
|
90
|
-
|
91
|
-
|
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")
|
92
316
|
|
93
|
-
|
94
|
-
|
95
|
-
|
317
|
+
has_body = "requestBody" in operation
|
318
|
+
body_required = has_body and operation["requestBody"].get("required", False)
|
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
|
96
328
|
else:
|
97
|
-
|
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
|
98
347
|
|
99
|
-
|
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"]
|
354
|
+
|
355
|
+
# Check if the schema is an array type
|
356
|
+
if schema.get("type") == "array":
|
357
|
+
is_array_body = True
|
358
|
+
schema.get("items", {})
|
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")
|
397
|
+
|
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")
|
429
|
+
|
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")
|
433
|
+
|
434
|
+
# Combine required and optional arguments
|
435
|
+
args = required_args + optional_args
|
436
|
+
|
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}:"
|
441
|
+
else:
|
442
|
+
signature = f" def {func_name}(self) -> {return_type}:"
|
100
443
|
|
444
|
+
# Build method body
|
445
|
+
body_lines = []
|
101
446
|
|
102
|
-
|
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
|
+
)
|
452
|
+
|
453
|
+
# Build request body (handle array and object types differently)
|
454
|
+
if has_body:
|
455
|
+
if is_array_body:
|
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
|
461
|
+
|
462
|
+
body_lines.append(" request_body = {")
|
463
|
+
|
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}"
|
472
|
+
)
|
473
|
+
|
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)
|
478
|
+
|
479
|
+
# Build query parameters, handling square brackets in parameter names
|
480
|
+
if query_params:
|
481
|
+
query_params_items = []
|
482
|
+
for param in query_params:
|
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}}"
|
490
|
+
)
|
491
|
+
else:
|
492
|
+
body_lines.append(" query_params = {}")
|
493
|
+
|
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)"
|
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)")
|
521
|
+
else:
|
522
|
+
body_lines.append(
|
523
|
+
f" response = self._{method_lower}(url, data={request_body_arg}, params=query_params)"
|
524
|
+
)
|
525
|
+
|
526
|
+
# Handle response
|
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):
|
103
539
|
"""
|
104
540
|
Generate a Python API client class from an OpenAPI schema.
|
105
541
|
|
@@ -109,6 +545,9 @@ def generate_api_client(schema):
|
|
109
545
|
Returns:
|
110
546
|
str: A string containing the Python code for the API client class.
|
111
547
|
"""
|
548
|
+
methods = []
|
549
|
+
method_names = []
|
550
|
+
|
112
551
|
# Extract API info for naming and base URL
|
113
552
|
info = schema.get("info", {})
|
114
553
|
api_title = info.get("title", "API")
|
@@ -122,275 +561,78 @@ def generate_api_client(schema):
|
|
122
561
|
# Create a clean class name from API title
|
123
562
|
if api_title:
|
124
563
|
# Convert API title to a clean class name
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
# Collect all methods
|
132
|
-
methods = []
|
133
|
-
for path, path_info in schema.get("paths", {}).items():
|
134
|
-
for method in path_info:
|
135
|
-
if method in ["get", "post", "put", "delete", "patch", "options", "head"]:
|
136
|
-
operation = path_info[method]
|
137
|
-
function = generate_method_code(path, method, operation, schema)
|
138
|
-
methods.append(function)
|
139
|
-
|
140
|
-
# Set up Jinja2 environment
|
141
|
-
env = Environment(
|
142
|
-
loader=FileSystemLoader(Path(__file__).parent.parent / "templates"),
|
143
|
-
autoescape=select_autoescape()
|
144
|
-
)
|
145
|
-
template = env.get_template("api_client.py.j2")
|
146
|
-
|
147
|
-
# Render the template
|
148
|
-
class_code = template.render(
|
149
|
-
class_name=class_name,
|
150
|
-
base_url=base_url,
|
151
|
-
methods=methods
|
152
|
-
)
|
153
|
-
|
154
|
-
return class_code
|
155
|
-
|
156
|
-
|
157
|
-
@dataclass
|
158
|
-
class Function:
|
159
|
-
name: str
|
160
|
-
type: Literal["get", "post", "put", "delete", "patch", "options", "head"]
|
161
|
-
args: Dict[str, str]
|
162
|
-
return_type: str
|
163
|
-
description: str
|
164
|
-
tags: List[str]
|
165
|
-
implementation: str
|
166
|
-
|
167
|
-
@property
|
168
|
-
def args_str(self) -> str:
|
169
|
-
"""Convert args dictionary to a string representation."""
|
170
|
-
return ", ".join(f"{arg}: {typ}" for arg, typ in self.args.items())
|
171
|
-
|
172
|
-
|
173
|
-
def generate_method_code(
|
174
|
-
path: str,
|
175
|
-
method: str,
|
176
|
-
operation: dict[str, Any],
|
177
|
-
full_schema: dict[str, Any]
|
178
|
-
) -> Function:
|
179
|
-
"""
|
180
|
-
Generate a Function object for a single API method.
|
181
|
-
|
182
|
-
Args:
|
183
|
-
path: The API path (e.g., '/users/{user_id}').
|
184
|
-
method: The HTTP method (e.g., 'get').
|
185
|
-
operation: The operation details from the schema.
|
186
|
-
full_schema: The complete OpenAPI schema, used for reference resolution.
|
187
|
-
|
188
|
-
Returns:
|
189
|
-
A Function object with metadata: name, args, return_type, description, tags.
|
190
|
-
"""
|
191
|
-
logger.debug(f"Generating function for {method.upper()} {path}")
|
192
|
-
|
193
|
-
# Helper to map JSON schema types to Python types
|
194
|
-
def map_type(sch: dict[str, Any]) -> str:
|
195
|
-
t = sch.get("type")
|
196
|
-
if t == "integer":
|
197
|
-
return "int"
|
198
|
-
if t == "number":
|
199
|
-
return "float"
|
200
|
-
if t == "boolean":
|
201
|
-
return "bool"
|
202
|
-
if t == "array":
|
203
|
-
return "list[Any]"
|
204
|
-
if t == "object":
|
205
|
-
return "dict[str, Any]"
|
206
|
-
return "Any"
|
207
|
-
|
208
|
-
|
209
|
-
# Determine function name
|
210
|
-
if op_id := operation.get("operationId"):
|
211
|
-
cleaned_id = op_id.replace(".", "_").replace("-", "_")
|
212
|
-
method_name = convert_to_snake_case(cleaned_id)
|
213
|
-
else:
|
214
|
-
segments = [seg.replace("-", "_") for seg in path.strip("/").split("/")]
|
215
|
-
parts = [method.lower()]
|
216
|
-
for seg in segments:
|
217
|
-
if seg.startswith("{") and seg.endswith("}"):
|
218
|
-
parts.append(f"by_{seg[1:-1]}")
|
219
|
-
else:
|
220
|
-
parts.append(seg)
|
221
|
-
method_name = "_".join(parts)
|
222
|
-
logger.debug(f"Resolved function name={method_name}")
|
223
|
-
|
224
|
-
# Resolve references and filter out header params
|
225
|
-
resolved_params = []
|
226
|
-
for param in operation.get("parameters", []):
|
227
|
-
if ref := param.get("$ref"):
|
228
|
-
target = resolve_schema_reference(ref, full_schema)
|
229
|
-
if target:
|
230
|
-
resolved_params.append(target)
|
231
|
-
else:
|
232
|
-
logger.warning(f"Unresolved parameter reference: {ref}")
|
564
|
+
if class_name:
|
565
|
+
clean_name = (
|
566
|
+
class_name.capitalize()[:-3]
|
567
|
+
if class_name.endswith("App")
|
568
|
+
else class_name.capitalize()
|
569
|
+
)
|
233
570
|
else:
|
234
|
-
|
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"
|
235
574
|
|
236
|
-
|
237
|
-
|
238
|
-
query_params = [p for p in resolved_params if p.get("in") == "query"]
|
575
|
+
# Extract tool name - remove spaces and convert to lowercase
|
576
|
+
tool_name = api_title.lower()
|
239
577
|
|
240
|
-
|
241
|
-
|
242
|
-
body_required = bool(has_body and operation["requestBody"].get("required"))
|
243
|
-
content = (operation.get("requestBody", {}) or {}).get("content", {}) if has_body else {}
|
244
|
-
is_array_body = False
|
245
|
-
request_props: Dict[str, Any] = {}
|
246
|
-
required_fields: List[str] = []
|
247
|
-
if has_body and content:
|
248
|
-
for mime, info in content.items():
|
249
|
-
if not mime.startswith("application/json") or "schema" not in info:
|
250
|
-
continue
|
251
|
-
schema = info["schema"]
|
252
|
-
if ref := schema.get("$ref"):
|
253
|
-
schema = resolve_schema_reference(ref, full_schema) or schema
|
254
|
-
if schema.get("type") == "array":
|
255
|
-
is_array_body = True
|
256
|
-
else:
|
257
|
-
required_fields = schema.get("required", []) or []
|
258
|
-
request_props = schema.get("properties", {}) or {}
|
259
|
-
for name, prop_schema in list(request_props.items()):
|
260
|
-
if pre := prop_schema.get("$ref"):
|
261
|
-
request_props[name] = resolve_schema_reference(pre, full_schema) or prop_schema
|
262
|
-
|
263
|
-
# Build function arguments with Annotated[type, description]
|
264
|
-
arg_defs: Dict[str, str] = {}
|
265
|
-
for p in path_params:
|
266
|
-
name = p["name"]
|
267
|
-
ty = map_type(p.get("schema", {}))
|
268
|
-
desc = p.get("description", "")
|
269
|
-
arg_defs[name] = f"Annotated[{ty}, {desc!r}]"
|
270
|
-
for p in query_params:
|
271
|
-
name = p["name"]
|
272
|
-
ty = map_type(p.get("schema", {}))
|
273
|
-
desc = p.get("description", "")
|
274
|
-
if p.get("required"):
|
275
|
-
arg_defs[name] = f"Annotated[{ty}, {desc!r}]"
|
276
|
-
else:
|
277
|
-
arg_defs[name] = f"Annotated[{ty}, {desc!r}] = None"
|
278
|
-
if has_body:
|
279
|
-
if is_array_body:
|
280
|
-
desc = operation["requestBody"].get("description", "")
|
281
|
-
if body_required:
|
282
|
-
arg_defs["items"] = f"Annotated[list[Any], {desc!r}]"
|
283
|
-
else:
|
284
|
-
arg_defs["items"] = f"Annotated[list[Any], {desc!r}] = None"
|
285
|
-
elif request_props:
|
286
|
-
for prop, schema in request_props.items():
|
287
|
-
ty = map_type(schema)
|
288
|
-
desc = schema.get("description", "")
|
289
|
-
if prop in required_fields:
|
290
|
-
arg_defs[prop] = f"Annotated[{ty}, {desc!r}]"
|
291
|
-
else:
|
292
|
-
arg_defs[prop] = f"Annotated[{ty}, {desc!r}] = None"
|
293
|
-
else:
|
294
|
-
desc = operation["requestBody"].get("description", "")
|
295
|
-
arg_defs["request_body"] = f"Annotated[Any, {desc!r}] = None"
|
296
|
-
|
297
|
-
# Sort and order arguments
|
298
|
-
required_keys = sorted(k for k, v in arg_defs.items() if "=" not in v)
|
299
|
-
optional_keys = sorted(k for k, v in arg_defs.items() if "=" in v)
|
300
|
-
ordered_args = {k: arg_defs[k] for k in required_keys + optional_keys}
|
301
|
-
|
302
|
-
return_type = determine_return_type(operation)
|
303
|
-
|
304
|
-
# Assemble description
|
305
|
-
summary = operation.get("summary", "")
|
306
|
-
operation_desc = operation.get("description", "")
|
307
|
-
desc_parts: List[str] = []
|
308
|
-
if summary:
|
309
|
-
desc_parts.append(summary)
|
310
|
-
if operation_desc:
|
311
|
-
desc_parts.append(operation_desc)
|
312
|
-
description_text = ". ".join(desc_parts)
|
313
|
-
|
314
|
-
tags = operation.get("tags", []) or []
|
315
|
-
|
316
|
-
# Generate implementation code
|
317
|
-
implementation_lines = []
|
318
|
-
|
319
|
-
# Add parameter validation for required fields
|
320
|
-
for param in path_params + query_params:
|
321
|
-
if param.get("required"):
|
322
|
-
name = param["name"]
|
323
|
-
implementation_lines.append(f"if {name} is None:")
|
324
|
-
implementation_lines.append(f" raise ValueError(\"Missing required parameter '{name}'\")")
|
325
|
-
|
326
|
-
if has_body and body_required:
|
327
|
-
if is_array_body:
|
328
|
-
implementation_lines.append("if items is None:")
|
329
|
-
implementation_lines.append(" raise ValueError(\"Missing required parameter 'items'\")")
|
330
|
-
elif request_props:
|
331
|
-
for prop in required_fields:
|
332
|
-
implementation_lines.append(f"if {prop} is None:")
|
333
|
-
implementation_lines.append(f" raise ValueError(\"Missing required parameter '{prop}'\")")
|
334
|
-
else:
|
335
|
-
implementation_lines.append("if request_body is None:")
|
336
|
-
implementation_lines.append(" raise ValueError(\"Missing required parameter 'request_body'\")")
|
578
|
+
# Remove version numbers (like 3.0, v1, etc.)
|
579
|
+
tool_name = re.sub(r"\s*v?\d+(\.\d+)*", "", tool_name)
|
337
580
|
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
elif request_props:
|
343
|
-
implementation_lines.append("request_body = {")
|
344
|
-
for prop in request_props:
|
345
|
-
implementation_lines.append(f" \"{prop}\": {prop},")
|
346
|
-
implementation_lines.append("}")
|
347
|
-
implementation_lines.append("request_body = {k: v for k, v in request_body.items() if v is not None}")
|
348
|
-
else:
|
349
|
-
implementation_lines.append("request_body = request_body")
|
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, "")
|
350
585
|
|
351
|
-
|
352
|
-
|
353
|
-
url = '\"{self.base_url}{path}\"'
|
354
|
-
implementation_lines.append(f'path = {path}')
|
355
|
-
implementation_lines.append(f'url = f{url}')
|
586
|
+
# Remove spaces, hyphens, underscores
|
587
|
+
tool_name = tool_name.replace(" ", "").replace("-", "").replace("_", "")
|
356
588
|
|
357
|
-
|
358
|
-
|
359
|
-
implementation_lines.append("query_params = {")
|
360
|
-
for param in query_params:
|
361
|
-
name = param["name"]
|
362
|
-
implementation_lines.append(f" \"{name}\": {name},")
|
363
|
-
implementation_lines.append(" }")
|
364
|
-
implementation_lines.append("query_params = {k: v for k, v in query_params.items() if v is not None}")
|
365
|
-
else:
|
366
|
-
implementation_lines.append("query_params = {}")
|
589
|
+
# Remove any non-alphanumeric characters
|
590
|
+
tool_name = "".join(c for c in tool_name if c.isalnum())
|
367
591
|
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
implementation_lines.append(f"response = self._{http_method}(url, data=request_body, params=query_params)")
|
592
|
+
# If empty (after cleaning), use generic name
|
593
|
+
if not tool_name:
|
594
|
+
tool_name = "api"
|
372
595
|
else:
|
373
|
-
|
596
|
+
class_name = "APIClient"
|
597
|
+
tool_name = "api"
|
374
598
|
|
375
|
-
#
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
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"
|
390
633
|
)
|
634
|
+
return class_code
|
391
635
|
|
392
|
-
logger.debug(f"Generated function: {function}")
|
393
|
-
return function
|
394
636
|
|
395
637
|
# Example usage
|
396
638
|
if __name__ == "__main__":
|