universal-mcp 0.1.15rc5__py3-none-any.whl → 0.1.15rc7__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/analytics.py +7 -1
- universal_mcp/applications/README.md +122 -0
- universal_mcp/applications/__init__.py +48 -46
- universal_mcp/applications/application.py +249 -40
- universal_mcp/cli.py +17 -14
- universal_mcp/exceptions.py +8 -0
- universal_mcp/integrations/integration.py +18 -2
- universal_mcp/stores/store.py +2 -12
- universal_mcp/tools/__init__.py +12 -1
- universal_mcp/tools/adapters.py +11 -0
- universal_mcp/tools/func_metadata.py +11 -1
- universal_mcp/tools/manager.py +165 -109
- universal_mcp/tools/tools.py +3 -3
- universal_mcp/utils/common.py +33 -0
- universal_mcp/utils/openapi/__inti__.py +0 -0
- universal_mcp/utils/{api_generator.py → openapi/api_generator.py} +1 -1
- universal_mcp/utils/openapi/openapi.py +930 -0
- universal_mcp/utils/openapi/preprocessor.py +1223 -0
- universal_mcp/utils/{readme.py → openapi/readme.py} +21 -31
- {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.15rc7.dist-info}/METADATA +5 -3
- universal_mcp-0.1.15rc7.dist-info/RECORD +44 -0
- universal_mcp-0.1.15rc7.dist-info/licenses/LICENSE +21 -0
- universal_mcp/utils/openapi.py +0 -646
- universal_mcp-0.1.15rc5.dist-info/RECORD +0 -39
- /universal_mcp/utils/{docgen.py → openapi/docgen.py} +0 -0
- /universal_mcp/{templates → utils/templates}/README.md.j2 +0 -0
- /universal_mcp/{templates → utils/templates}/api_client.py.j2 +0 -0
- {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.15rc7.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.15rc7.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,930 @@
|
|
1
|
+
import json
|
2
|
+
import re
|
3
|
+
import textwrap
|
4
|
+
from keyword import iskeyword
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Any, Literal
|
7
|
+
|
8
|
+
import yaml
|
9
|
+
from jsonref import replace_refs
|
10
|
+
from pydantic import BaseModel
|
11
|
+
|
12
|
+
|
13
|
+
class Parameters(BaseModel):
|
14
|
+
name: str
|
15
|
+
identifier: str
|
16
|
+
description: str = ""
|
17
|
+
type: str = "string"
|
18
|
+
where: Literal["path", "query", "header", "body"]
|
19
|
+
required: bool
|
20
|
+
example: str | None = None
|
21
|
+
|
22
|
+
def __str__(self):
|
23
|
+
return f"{self.name}: ({self.type})"
|
24
|
+
|
25
|
+
|
26
|
+
class Method(BaseModel):
|
27
|
+
name: str
|
28
|
+
summary: str
|
29
|
+
tags: list[str]
|
30
|
+
path: str
|
31
|
+
method: str
|
32
|
+
path_params: list[Parameters]
|
33
|
+
query_params: list[Parameters]
|
34
|
+
body_params: list[Parameters]
|
35
|
+
return_type: str
|
36
|
+
|
37
|
+
def deduplicate_params(self):
|
38
|
+
"""
|
39
|
+
Deduplicate parameters by name.
|
40
|
+
Sometimes the same parameter is defined in multiple places, we only want to include it once.
|
41
|
+
"""
|
42
|
+
# TODO: Implement this
|
43
|
+
pass
|
44
|
+
|
45
|
+
def render(self, template_dir: str, template_name: str = "method.jinja2") -> str:
|
46
|
+
"""
|
47
|
+
Render this Method instance into source code using a Jinja2 template.
|
48
|
+
|
49
|
+
Args:
|
50
|
+
template_dir (str): Directory where the Jinja2 templates are located.
|
51
|
+
template_name (str): Filename of the method template.
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
str: The rendered method source code.
|
55
|
+
"""
|
56
|
+
from jinja2 import Environment, FileSystemLoader
|
57
|
+
|
58
|
+
env = Environment(
|
59
|
+
loader=FileSystemLoader(template_dir),
|
60
|
+
trim_blocks=True,
|
61
|
+
lstrip_blocks=True,
|
62
|
+
)
|
63
|
+
template = env.get_template(template_name)
|
64
|
+
return template.render(method=self)
|
65
|
+
|
66
|
+
|
67
|
+
def convert_to_snake_case(identifier: str) -> str:
|
68
|
+
"""
|
69
|
+
Convert a string identifier to snake_case,
|
70
|
+
replacing non-alphanumeric, non-underscore characters (including ., -, spaces, [], etc.) with underscores.
|
71
|
+
Handles camelCase/PascalCase transitions.
|
72
|
+
"""
|
73
|
+
if not identifier:
|
74
|
+
return identifier
|
75
|
+
result = re.sub(r"[^a-zA-Z0-9_]+", "_", identifier)
|
76
|
+
result = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", result)
|
77
|
+
result = re.sub(r"__+", "_", result)
|
78
|
+
return result.strip("_").lower()
|
79
|
+
|
80
|
+
|
81
|
+
def _sanitize_identifier(name: str | None) -> str:
|
82
|
+
"""Cleans a string to be a valid Python identifier.
|
83
|
+
|
84
|
+
Replaces hyphens, dots, and square brackets with underscores.
|
85
|
+
Removes closing square brackets.
|
86
|
+
Removes leading underscores (e.g., `_param` becomes `param`),
|
87
|
+
unless the name consists only of underscores (e.g., `_` or `__` become `_`).
|
88
|
+
Appends an underscore if the name is a Python keyword (e.g., `for` becomes `for_`).
|
89
|
+
Converts `self` to `self_arg`.
|
90
|
+
Returns empty string if input is None.
|
91
|
+
"""
|
92
|
+
if name is None:
|
93
|
+
return ""
|
94
|
+
|
95
|
+
# Initial replacements for common non-alphanumeric characters
|
96
|
+
sanitized = name.replace("-", "_").replace(".", "_").replace("[", "_").replace("]", "").replace("$", "_")
|
97
|
+
|
98
|
+
# Remove leading underscores, but preserve a single underscore if the name (after initial replace)
|
99
|
+
# consisted only of underscores.
|
100
|
+
if sanitized.startswith('_'):
|
101
|
+
stripped_name = sanitized.lstrip('_')
|
102
|
+
sanitized = stripped_name if stripped_name else "_"
|
103
|
+
|
104
|
+
# Append underscore if the sanitized name is a Python keyword
|
105
|
+
if iskeyword(sanitized):
|
106
|
+
sanitized += "_"
|
107
|
+
|
108
|
+
# Special handling for "self" to avoid conflict with instance method's self argument
|
109
|
+
if sanitized == "self":
|
110
|
+
sanitized = "self_arg"
|
111
|
+
|
112
|
+
return sanitized
|
113
|
+
|
114
|
+
|
115
|
+
def _extract_properties_from_schema(
|
116
|
+
schema: dict[str, Any],
|
117
|
+
) -> tuple[dict[str, Any], list[str]]:
|
118
|
+
"""Extracts properties and required fields from a schema, handling 'allOf'."""
|
119
|
+
properties = {}
|
120
|
+
required_fields = []
|
121
|
+
|
122
|
+
if "allOf" in schema:
|
123
|
+
for sub_schema in schema["allOf"]:
|
124
|
+
sub_props, sub_required = _extract_properties_from_schema(sub_schema)
|
125
|
+
properties.update(sub_props)
|
126
|
+
required_fields.extend(sub_required)
|
127
|
+
|
128
|
+
# Combine with top-level properties and required fields, if any
|
129
|
+
properties.update(schema.get("properties", {}))
|
130
|
+
required_fields.extend(schema.get("required", []))
|
131
|
+
|
132
|
+
# Deduplicate required fields
|
133
|
+
required_fields = list(set(required_fields))
|
134
|
+
|
135
|
+
return properties, required_fields
|
136
|
+
|
137
|
+
|
138
|
+
def _load_and_resolve_references(path: Path):
|
139
|
+
# Load the schema
|
140
|
+
type = "yaml" if path.suffix == ".yaml" else "json"
|
141
|
+
with open(path) as f:
|
142
|
+
schema = yaml.safe_load(f) if type == "yaml" else json.load(f)
|
143
|
+
# Resolve references
|
144
|
+
return replace_refs(schema)
|
145
|
+
|
146
|
+
|
147
|
+
def _determine_return_type(operation: dict[str, Any]) -> str:
|
148
|
+
"""
|
149
|
+
Determine the return type from the response schema.
|
150
|
+
|
151
|
+
Args:
|
152
|
+
operation (dict): The operation details from the schema.
|
153
|
+
|
154
|
+
Returns:
|
155
|
+
str: The appropriate return type annotation (list[Any], dict[str, Any], or Any)
|
156
|
+
"""
|
157
|
+
responses = operation.get("responses", {})
|
158
|
+
# Find successful response (2XX)
|
159
|
+
success_response = None
|
160
|
+
for code in responses:
|
161
|
+
if code.startswith("2"):
|
162
|
+
success_response = responses[code]
|
163
|
+
break
|
164
|
+
|
165
|
+
if not success_response:
|
166
|
+
return "Any" # Default to Any if no success response
|
167
|
+
|
168
|
+
# Check if there's content with schema
|
169
|
+
if "content" in success_response:
|
170
|
+
for content_type, content_info in success_response["content"].items():
|
171
|
+
if content_type.startswith("application/json") and "schema" in content_info:
|
172
|
+
schema = content_info["schema"]
|
173
|
+
|
174
|
+
# Only determine if it's a list, dict, or unknown (Any)
|
175
|
+
if schema.get("type") == "array":
|
176
|
+
return "list[Any]"
|
177
|
+
elif schema.get("type") == "object" or "$ref" in schema:
|
178
|
+
return "dict[str, Any]"
|
179
|
+
|
180
|
+
# Default to Any if unable to determine
|
181
|
+
return "Any"
|
182
|
+
|
183
|
+
|
184
|
+
def _determine_function_name(operation: dict[str, Any], path: str, method: str) -> str:
|
185
|
+
"""
|
186
|
+
Determine the function name from the operation.
|
187
|
+
"""
|
188
|
+
# Determine function name
|
189
|
+
if "operationId" in operation:
|
190
|
+
raw_name = operation["operationId"]
|
191
|
+
cleaned_name = raw_name.replace(".", "_").replace("-", "_")
|
192
|
+
func_name = convert_to_snake_case(cleaned_name)
|
193
|
+
else:
|
194
|
+
# Generate name from path and method
|
195
|
+
path_parts = path.strip("/").split("/")
|
196
|
+
name_parts = [method]
|
197
|
+
for part in path_parts:
|
198
|
+
if part.startswith("{") and part.endswith("}"):
|
199
|
+
name_parts.append("by_" + part[1:-1])
|
200
|
+
else:
|
201
|
+
name_parts.append(part)
|
202
|
+
func_name = "_".join(name_parts).replace("-", "_").lower()
|
203
|
+
|
204
|
+
# Only fix isolated 'a' and 'an' as articles, not when they're part of words
|
205
|
+
func_name = re.sub(
|
206
|
+
r"_a([^_a-z])", r"_a_\1", func_name
|
207
|
+
) # Fix for patterns like retrieve_ablock -> retrieve_a_block
|
208
|
+
func_name = re.sub(
|
209
|
+
r"_a$", r"_a", func_name
|
210
|
+
) # Don't change if 'a' is at the end of the name
|
211
|
+
func_name = re.sub(
|
212
|
+
r"_an([^_a-z])", r"_an_\1", func_name
|
213
|
+
) # Fix for patterns like create_anitem -> create_an_item
|
214
|
+
func_name = re.sub(
|
215
|
+
r"_an$", r"_an", func_name
|
216
|
+
) # Don't change if 'an' is at the end of the name
|
217
|
+
return func_name
|
218
|
+
|
219
|
+
|
220
|
+
def _generate_path_params(path: str) -> list[Parameters]:
|
221
|
+
path_params_in_url = re.findall(r"{([^}]+)}", path)
|
222
|
+
parameters = []
|
223
|
+
for param_name in path_params_in_url:
|
224
|
+
try:
|
225
|
+
parameters.append(
|
226
|
+
Parameters(
|
227
|
+
name=_sanitize_identifier(param_name),
|
228
|
+
identifier=param_name,
|
229
|
+
description=param_name,
|
230
|
+
type="string",
|
231
|
+
where="path",
|
232
|
+
required=True,
|
233
|
+
example=None,
|
234
|
+
)
|
235
|
+
)
|
236
|
+
except Exception as e:
|
237
|
+
print(f"Error generating path parameters {param_name}: {e}")
|
238
|
+
raise e
|
239
|
+
return parameters
|
240
|
+
|
241
|
+
|
242
|
+
def _generate_url(path: str, path_params: list[Parameters]):
|
243
|
+
formatted_path = path
|
244
|
+
for param in path_params:
|
245
|
+
formatted_path = formatted_path.replace(
|
246
|
+
f"{{{param.identifier}}}", f"{{{param.name}}}"
|
247
|
+
)
|
248
|
+
return formatted_path
|
249
|
+
|
250
|
+
|
251
|
+
def _generate_query_params(operation: dict[str, Any]) -> list[Parameters]:
|
252
|
+
query_params = []
|
253
|
+
for param in operation.get("parameters", []):
|
254
|
+
name = param.get("name")
|
255
|
+
if name is None:
|
256
|
+
continue
|
257
|
+
|
258
|
+
# Clean the parameter name for use as a Python identifier
|
259
|
+
clean_name = _sanitize_identifier(name)
|
260
|
+
|
261
|
+
description = param.get("description", "")
|
262
|
+
|
263
|
+
# Extract type from schema if available
|
264
|
+
param_schema = param.get("schema", {})
|
265
|
+
type_value = param_schema.get("type") if param_schema else param.get("type")
|
266
|
+
# Default to string if type is not available
|
267
|
+
if type_value is None:
|
268
|
+
type_value = "string"
|
269
|
+
|
270
|
+
# Extract example
|
271
|
+
example_value = param.get("example", param_schema.get("example"))
|
272
|
+
|
273
|
+
where = param.get("in")
|
274
|
+
required = param.get("required", False)
|
275
|
+
if where == "query":
|
276
|
+
parameter = Parameters(
|
277
|
+
name=clean_name,
|
278
|
+
identifier=name,
|
279
|
+
description=description,
|
280
|
+
type=type_value,
|
281
|
+
where=where,
|
282
|
+
required=required,
|
283
|
+
example=str(example_value) if example_value is not None else None,
|
284
|
+
)
|
285
|
+
query_params.append(parameter)
|
286
|
+
return query_params
|
287
|
+
|
288
|
+
|
289
|
+
def _generate_body_params(operation: dict[str, Any]) -> list[Parameters]:
|
290
|
+
body_params = []
|
291
|
+
request_body = operation.get("requestBody", {})
|
292
|
+
if not request_body:
|
293
|
+
return [] # No request body defined
|
294
|
+
|
295
|
+
required_body = request_body.get("required", False)
|
296
|
+
content = request_body.get("content", {})
|
297
|
+
json_content = content.get("application/json", {})
|
298
|
+
if not json_content or "schema" not in json_content:
|
299
|
+
return [] # No JSON schema found
|
300
|
+
|
301
|
+
schema = json_content.get("schema", {})
|
302
|
+
properties, required_fields = _extract_properties_from_schema(schema)
|
303
|
+
|
304
|
+
for param_name, param_schema in properties.items():
|
305
|
+
param_type = param_schema.get("type", "string")
|
306
|
+
param_description = param_schema.get("description", param_name)
|
307
|
+
# Parameter is required if the body is required AND the field is in the schema's required list
|
308
|
+
param_required = required_body and param_name in required_fields
|
309
|
+
# Extract example
|
310
|
+
param_example = param_schema.get("example")
|
311
|
+
|
312
|
+
body_params.append(
|
313
|
+
Parameters(
|
314
|
+
name=_sanitize_identifier(param_name), # Clean name for Python
|
315
|
+
identifier=param_name, # Original name for API
|
316
|
+
description=param_description,
|
317
|
+
type=param_type,
|
318
|
+
where="body",
|
319
|
+
required=param_required,
|
320
|
+
example=str(param_example) if param_example is not None else None,
|
321
|
+
)
|
322
|
+
)
|
323
|
+
return body_params
|
324
|
+
|
325
|
+
|
326
|
+
def _generate_method_code(path, method, operation):
|
327
|
+
"""
|
328
|
+
Generate the code for a single API method.
|
329
|
+
|
330
|
+
|
331
|
+
Args:
|
332
|
+
path (str): The API path (e.g., '/users/{user_id}').
|
333
|
+
method (str): The HTTP method (e.g., 'get').
|
334
|
+
operation (dict): The operation details from the schema.
|
335
|
+
full_schema (dict): The complete OpenAPI schema, used for reference resolution.
|
336
|
+
tool_name (str, optional): The name of the tool/app to prefix the function name with.
|
337
|
+
|
338
|
+
Returns:
|
339
|
+
tuple: (method_code, func_name) - The Python code for the method and its name.
|
340
|
+
"""
|
341
|
+
print(f"--- Generating code for: {method.upper()} {path} ---") # Log endpoint being processed
|
342
|
+
|
343
|
+
func_name = _determine_function_name(operation, path, method)
|
344
|
+
operation.get("summary", "")
|
345
|
+
operation.get("tags", [])
|
346
|
+
# Extract path parameters from the URL path
|
347
|
+
path_params = _generate_path_params(path)
|
348
|
+
query_params = _generate_query_params(operation)
|
349
|
+
body_params = _generate_body_params(operation)
|
350
|
+
|
351
|
+
# --- Alias duplicate parameter names ---
|
352
|
+
# Path parameters have the highest priority and their names are not changed.
|
353
|
+
path_param_names = {p.name for p in path_params}
|
354
|
+
|
355
|
+
# Define the string that "self" sanitizes to. This name will be treated as reserved
|
356
|
+
# for query/body params to force suffixing.
|
357
|
+
self_sanitized_marker = _sanitize_identifier("self") # This will be "self_arg"
|
358
|
+
|
359
|
+
# Base names that will force query/body parameters to be suffixed.
|
360
|
+
# This includes actual path parameter names and the sanitized form of "self".
|
361
|
+
path_param_base_conflict_names = path_param_names | {self_sanitized_marker}
|
362
|
+
|
363
|
+
|
364
|
+
# Alias query parameters
|
365
|
+
current_query_param_names = set()
|
366
|
+
for q_param in query_params:
|
367
|
+
original_q_name = q_param.name
|
368
|
+
temp_q_name = original_q_name
|
369
|
+
# Check against path params AND the sanitized "self" marker
|
370
|
+
if temp_q_name in path_param_base_conflict_names:
|
371
|
+
temp_q_name = f"{original_q_name}_query"
|
372
|
+
# Ensure uniqueness among query params themselves after potential aliasing
|
373
|
+
# (though less common, if _sanitize_identifier produced same base for different originals)
|
374
|
+
# This step is more about ensuring the final suffixed name is unique if multiple query params mapped to same path param name
|
375
|
+
counter = 1
|
376
|
+
final_q_name = temp_q_name
|
377
|
+
while final_q_name in path_param_base_conflict_names or final_q_name in current_query_param_names : # Check against path/\"self_arg\" and already processed query params
|
378
|
+
if temp_q_name == original_q_name : # first conflict was with path_param_base_conflict_names
|
379
|
+
final_q_name = f"{original_q_name}_query" # try simple suffix first
|
380
|
+
if final_q_name in path_param_base_conflict_names or final_q_name in current_query_param_names:
|
381
|
+
final_q_name = f"{original_q_name}_query_{counter}" # then add counter
|
382
|
+
else: # conflict was with another query param after initial suffixing
|
383
|
+
final_q_name = f"{temp_q_name}_{counter}"
|
384
|
+
counter += 1
|
385
|
+
q_param.name = final_q_name
|
386
|
+
current_query_param_names.add(q_param.name)
|
387
|
+
|
388
|
+
|
389
|
+
# Alias body parameters
|
390
|
+
# Names to check against: path param names (including "self_arg" marker) and (now aliased) query param names
|
391
|
+
existing_param_names_for_body = path_param_base_conflict_names.union(current_query_param_names)
|
392
|
+
current_body_param_names = set()
|
393
|
+
|
394
|
+
for b_param in body_params:
|
395
|
+
original_b_name = b_param.name
|
396
|
+
temp_b_name = original_b_name
|
397
|
+
# Check against path, "self_arg" marker, and query params
|
398
|
+
if temp_b_name in existing_param_names_for_body:
|
399
|
+
temp_b_name = f"{original_b_name}_body"
|
400
|
+
|
401
|
+
# Ensure uniqueness among body params themselves or further conflicts
|
402
|
+
counter = 1
|
403
|
+
final_b_name = temp_b_name
|
404
|
+
while final_b_name in existing_param_names_for_body or final_b_name in current_body_param_names:
|
405
|
+
if temp_b_name == original_b_name: # first conflict was with existing_param_names_for_body
|
406
|
+
final_b_name = f"{original_b_name}_body"
|
407
|
+
if final_b_name in existing_param_names_for_body or final_b_name in current_body_param_names:
|
408
|
+
final_b_name = f"{original_b_name}_body_{counter}"
|
409
|
+
else: # conflict was with another body param after initial suffixing
|
410
|
+
final_b_name = f"{temp_b_name}_{counter}"
|
411
|
+
|
412
|
+
counter += 1
|
413
|
+
b_param.name = final_b_name
|
414
|
+
current_body_param_names.add(b_param.name)
|
415
|
+
# --- End Alias duplicate parameter names ---
|
416
|
+
|
417
|
+
|
418
|
+
return_type = _determine_return_type(operation)
|
419
|
+
|
420
|
+
has_body = "requestBody" in operation
|
421
|
+
body_required = has_body and operation["requestBody"].get("required", False)
|
422
|
+
has_empty_body = False
|
423
|
+
request_body_properties = {}
|
424
|
+
required_fields = []
|
425
|
+
is_array_body = False
|
426
|
+
|
427
|
+
if has_body:
|
428
|
+
request_body_content = operation.get("requestBody", {}).get("content", {})
|
429
|
+
json_content = request_body_content.get("application/json", {})
|
430
|
+
if json_content and "schema" in json_content:
|
431
|
+
schema = json_content["schema"]
|
432
|
+
if schema.get("type") == "array":
|
433
|
+
is_array_body = True
|
434
|
+
else:
|
435
|
+
request_body_properties, required_fields = (
|
436
|
+
_extract_properties_from_schema(schema)
|
437
|
+
)
|
438
|
+
if (
|
439
|
+
not request_body_properties or len(request_body_properties) == 0
|
440
|
+
) and schema.get("additionalProperties") is True:
|
441
|
+
has_empty_body = True
|
442
|
+
elif not request_body_content or all(
|
443
|
+
not c for _, c in request_body_content.items()
|
444
|
+
): # Check if content is truly empty
|
445
|
+
has_empty_body = True
|
446
|
+
|
447
|
+
# Build function arguments with deduplication (Priority: Path > Body > Query)
|
448
|
+
required_args = []
|
449
|
+
optional_args = []
|
450
|
+
# seen_clean_names = set() # No longer needed if logic below is correct
|
451
|
+
|
452
|
+
# 1. Process Path Parameters (Highest Priority)
|
453
|
+
for param in path_params:
|
454
|
+
# Path param names are sanitized but not suffixed by aliasing.
|
455
|
+
# They are the baseline.
|
456
|
+
if param.name not in required_args: # param.name is the sanitized name
|
457
|
+
required_args.append(param.name)
|
458
|
+
|
459
|
+
# 2. Process Query Parameters
|
460
|
+
for param in query_params: # param.name is the potentially aliased name (e.g., id_query)
|
461
|
+
arg_name_for_sig = param.name
|
462
|
+
current_arg_names_set = set(required_args) | {arg.split('=')[0] for arg in optional_args}
|
463
|
+
if arg_name_for_sig not in current_arg_names_set:
|
464
|
+
if param.required:
|
465
|
+
required_args.append(arg_name_for_sig)
|
466
|
+
else:
|
467
|
+
optional_args.append(f"{arg_name_for_sig}=None")
|
468
|
+
|
469
|
+
# 3. Process Body Parameters / Request Body
|
470
|
+
# This list tracks the *final* names of parameters in the signature that come from the request body,
|
471
|
+
# used later for docstring example placement.
|
472
|
+
final_request_body_arg_names_for_signature = []
|
473
|
+
final_empty_body_param_name = None # For the specific case of has_empty_body
|
474
|
+
|
475
|
+
if has_body:
|
476
|
+
current_arg_names_set = set(required_args) | {arg.split('=')[0] for arg in optional_args}
|
477
|
+
if is_array_body:
|
478
|
+
array_param_name_base = "items" # Default base name
|
479
|
+
if func_name.endswith("_list_input"):
|
480
|
+
array_param_name_base = func_name.replace("_list_input", "")
|
481
|
+
elif "List" in func_name:
|
482
|
+
array_param_name_base = func_name.split("List")[0].lower() + "_list"
|
483
|
+
|
484
|
+
final_array_param_name = array_param_name_base
|
485
|
+
counter = 1
|
486
|
+
is_first_suffix_attempt = True
|
487
|
+
while final_array_param_name in current_arg_names_set:
|
488
|
+
if is_first_suffix_attempt:
|
489
|
+
final_array_param_name = f"{array_param_name_base}_body"
|
490
|
+
is_first_suffix_attempt = False
|
491
|
+
else:
|
492
|
+
final_array_param_name = f"{array_param_name_base}_body_{counter}"
|
493
|
+
counter += 1
|
494
|
+
|
495
|
+
if body_required:
|
496
|
+
required_args.append(final_array_param_name)
|
497
|
+
else:
|
498
|
+
optional_args.append(f"{final_array_param_name}=None")
|
499
|
+
final_request_body_arg_names_for_signature.append(final_array_param_name)
|
500
|
+
|
501
|
+
elif request_body_properties: # Object body
|
502
|
+
for param in body_params: # Iterate ALIASED body_params
|
503
|
+
arg_name_for_sig = param.name # This is the final, aliased name (e.g., "id_body")
|
504
|
+
|
505
|
+
# Defensive check against already added args (should be covered by aliasing logic)
|
506
|
+
current_arg_names_set_loop = set(required_args) | {arg.split('=')[0] for arg in optional_args}
|
507
|
+
if arg_name_for_sig not in current_arg_names_set_loop:
|
508
|
+
if param.required:
|
509
|
+
required_args.append(arg_name_for_sig)
|
510
|
+
else:
|
511
|
+
# Parameters model does not store schema 'default'. Optional params default to None.
|
512
|
+
optional_args.append(f"{arg_name_for_sig}=None")
|
513
|
+
final_request_body_arg_names_for_signature.append(arg_name_for_sig)
|
514
|
+
|
515
|
+
# If request body is present but empty (e.g. content: {}), add a generic request_body parameter
|
516
|
+
# This is handled *after* specific body params, as it's a fallback.
|
517
|
+
if has_empty_body:
|
518
|
+
empty_body_param_name_base = "request_body"
|
519
|
+
current_arg_names_set = set(required_args) | {arg.split('=')[0] for arg in optional_args}
|
520
|
+
|
521
|
+
final_empty_body_param_name = empty_body_param_name_base
|
522
|
+
counter = 1
|
523
|
+
is_first_suffix_attempt = True
|
524
|
+
while final_empty_body_param_name in current_arg_names_set:
|
525
|
+
if is_first_suffix_attempt:
|
526
|
+
final_empty_body_param_name = f"{empty_body_param_name_base}_body"
|
527
|
+
is_first_suffix_attempt = False
|
528
|
+
else:
|
529
|
+
final_empty_body_param_name = f"{empty_body_param_name_base}_body_{counter}"
|
530
|
+
counter += 1
|
531
|
+
|
532
|
+
# Check if it was somehow added by other logic (e.g. if 'request_body' was an explicit param name)
|
533
|
+
# This check is mostly defensive.
|
534
|
+
if final_empty_body_param_name not in (set(required_args) | {arg.split('=')[0] for arg in optional_args}):
|
535
|
+
optional_args.append(f"{final_empty_body_param_name}=None")
|
536
|
+
# Track for docstring, even if it's just 'request_body' or 'request_body_body'
|
537
|
+
if final_empty_body_param_name not in final_request_body_arg_names_for_signature:
|
538
|
+
final_request_body_arg_names_for_signature.append(final_empty_body_param_name)
|
539
|
+
|
540
|
+
# Combine required and optional arguments
|
541
|
+
args = required_args + optional_args
|
542
|
+
|
543
|
+
# ----- Build Docstring -----
|
544
|
+
docstring_parts = []
|
545
|
+
return_type = _determine_return_type(operation)
|
546
|
+
|
547
|
+
# Summary
|
548
|
+
summary = operation.get("summary", "").strip()
|
549
|
+
if not summary:
|
550
|
+
summary = operation.get(
|
551
|
+
"description", f"Execute {method.upper()} {path}"
|
552
|
+
).strip()
|
553
|
+
summary = summary.split("\n")[0]
|
554
|
+
if summary:
|
555
|
+
docstring_parts.append(summary)
|
556
|
+
|
557
|
+
# Args
|
558
|
+
args_doc_lines = []
|
559
|
+
param_details = {}
|
560
|
+
all_params = path_params + query_params + body_params
|
561
|
+
signature_arg_names = {a.split("=")[0] for a in args}
|
562
|
+
|
563
|
+
for param in all_params:
|
564
|
+
if param.name in signature_arg_names and param.name not in param_details:
|
565
|
+
param_details[param.name] = param
|
566
|
+
|
567
|
+
# Fetch request body example
|
568
|
+
request_body_example_str = None
|
569
|
+
if has_body:
|
570
|
+
try:
|
571
|
+
json_content = operation["requestBody"]["content"]["application/json"]
|
572
|
+
example_data = None
|
573
|
+
if "example" in json_content:
|
574
|
+
example_data = json_content["example"]
|
575
|
+
elif "examples" in json_content and json_content["examples"]:
|
576
|
+
first_example_key = list(json_content["examples"].keys())[0]
|
577
|
+
example_data = json_content["examples"][first_example_key].get("value")
|
578
|
+
|
579
|
+
if example_data is not None:
|
580
|
+
try:
|
581
|
+
example_json = json.dumps(example_data, indent=2)
|
582
|
+
indented_example = textwrap.indent(
|
583
|
+
example_json, " " * 8
|
584
|
+
) # 8 spaces
|
585
|
+
request_body_example_str = f"\n Example:\n ```json\n{indented_example}\n ```"
|
586
|
+
except TypeError:
|
587
|
+
request_body_example_str = f"\n Example: {example_data}"
|
588
|
+
except KeyError:
|
589
|
+
pass # No example found
|
590
|
+
|
591
|
+
# Identify the last argument related to the request body
|
592
|
+
last_body_arg_name = None
|
593
|
+
# request_body_params contains the names as they appear in the signature
|
594
|
+
if final_request_body_arg_names_for_signature: # Use the new list with final aliased names
|
595
|
+
# Find which of these appears last in the combined args list
|
596
|
+
body_args_in_signature = [
|
597
|
+
a.split("=")[0] for a in args if a.split("=")[0] in final_request_body_arg_names_for_signature
|
598
|
+
]
|
599
|
+
if body_args_in_signature:
|
600
|
+
last_body_arg_name = body_args_in_signature[-1]
|
601
|
+
|
602
|
+
if signature_arg_names:
|
603
|
+
args_doc_lines.append("Args:")
|
604
|
+
for arg_signature_str in args:
|
605
|
+
arg_name = arg_signature_str.split("=")[0]
|
606
|
+
example_str = None # Initialize example_str here
|
607
|
+
detail = param_details.get(arg_name)
|
608
|
+
if detail:
|
609
|
+
desc = detail.description or "No description provided."
|
610
|
+
type_hint = detail.type if detail.type else "Any"
|
611
|
+
arg_line = f" {arg_name} ({type_hint}): {desc}"
|
612
|
+
if detail.example:
|
613
|
+
example_str = repr(detail.example)
|
614
|
+
arg_line += f" Example: {example_str}."
|
615
|
+
|
616
|
+
# Append the full body example after the last body-related argument
|
617
|
+
if arg_name == last_body_arg_name and request_body_example_str:
|
618
|
+
# Remove the simple Example: if it exists before adding the detailed one
|
619
|
+
if example_str and (
|
620
|
+
f" Example: {example_str}." in arg_line
|
621
|
+
or f" Example: {example_str} ." in arg_line
|
622
|
+
):
|
623
|
+
arg_line = arg_line.replace(
|
624
|
+
f" Example: {example_str}.", ""
|
625
|
+
) # Remove with or without trailing period
|
626
|
+
arg_line += (
|
627
|
+
request_body_example_str # Append the formatted JSON example
|
628
|
+
)
|
629
|
+
|
630
|
+
args_doc_lines.append(arg_line)
|
631
|
+
elif arg_name == final_empty_body_param_name and has_empty_body: # Use potentially suffixed name
|
632
|
+
args_doc_lines.append(
|
633
|
+
f" {arg_name} (dict | None): Optional dictionary for arbitrary request body data."
|
634
|
+
)
|
635
|
+
# Also append example here if this is the designated body arg
|
636
|
+
if (
|
637
|
+
arg_name == last_body_arg_name and request_body_example_str
|
638
|
+
):
|
639
|
+
args_doc_lines[-1] += (
|
640
|
+
request_body_example_str
|
641
|
+
)
|
642
|
+
|
643
|
+
if args_doc_lines:
|
644
|
+
docstring_parts.append("\n".join(args_doc_lines))
|
645
|
+
|
646
|
+
# Returns - Use the pre-calculated return_type variable
|
647
|
+
success_desc = ""
|
648
|
+
responses = operation.get("responses", {})
|
649
|
+
for code, resp_info in responses.items():
|
650
|
+
if code.startswith("2"):
|
651
|
+
success_desc = resp_info.get("description", "").strip()
|
652
|
+
break
|
653
|
+
docstring_parts.append(
|
654
|
+
f"Returns:\n {return_type}: {success_desc or 'API response data.'}"
|
655
|
+
) # Use return_type
|
656
|
+
|
657
|
+
# Tags Section
|
658
|
+
operation_tags = operation.get("tags", [])
|
659
|
+
if operation_tags:
|
660
|
+
tags_string = ", ".join(operation_tags)
|
661
|
+
docstring_parts.append(f"Tags:\n {tags_string}")
|
662
|
+
|
663
|
+
# Combine and Format docstring
|
664
|
+
docstring_content = "\n\n".join(docstring_parts)
|
665
|
+
|
666
|
+
def_indent = " "
|
667
|
+
doc_indent = def_indent + " "
|
668
|
+
indented_docstring_content = textwrap.indent(docstring_content, doc_indent)
|
669
|
+
|
670
|
+
# Wrap in triple quotes
|
671
|
+
formatted_docstring = (
|
672
|
+
f'\n{doc_indent}"""\n{indented_docstring_content}\n{doc_indent}"""'
|
673
|
+
)
|
674
|
+
# ----- End Build Docstring -----
|
675
|
+
|
676
|
+
if args:
|
677
|
+
signature = f" def {func_name}(self, {', '.join(args)}) -> {return_type}:"
|
678
|
+
else:
|
679
|
+
signature = f" def {func_name}(self) -> {return_type}:"
|
680
|
+
|
681
|
+
# Build method body
|
682
|
+
body_lines = []
|
683
|
+
|
684
|
+
# Path parameter validation (uses aliased name for signature, original identifier for error)
|
685
|
+
for param in path_params:
|
686
|
+
body_lines.append(f" if {param.name} is None:")
|
687
|
+
body_lines.append(
|
688
|
+
f' raise ValueError("Missing required parameter \'{param.identifier}\'")' # Use original name in error
|
689
|
+
)
|
690
|
+
|
691
|
+
# Build request body (handle array and object types differently)
|
692
|
+
if has_body:
|
693
|
+
if is_array_body:
|
694
|
+
# For array request bodies, use the array parameter directly
|
695
|
+
body_lines.append(" # Use items array directly as request body")
|
696
|
+
body_lines.append(f" request_body = {final_request_body_arg_names_for_signature[0]}")
|
697
|
+
elif request_body_properties:
|
698
|
+
# For object request bodies, build the request body from individual parameters
|
699
|
+
body_lines.append(" request_body = {")
|
700
|
+
for b_param in body_params: # Iterate through original body_params list
|
701
|
+
# Use b_param.identifier for the key in the request_body dictionary
|
702
|
+
# and b_param.name for the variable name from the function signature
|
703
|
+
body_lines.append(f" '{b_param.identifier}': {b_param.name},")
|
704
|
+
body_lines.append(" }")
|
705
|
+
body_lines.append(
|
706
|
+
" request_body = {k: v for k, v in request_body.items() if v is not None}"
|
707
|
+
)
|
708
|
+
|
709
|
+
# Format URL directly with path parameters
|
710
|
+
url = _generate_url(path, path_params)
|
711
|
+
url_line = f' url = f"{{self.base_url}}{url}"'
|
712
|
+
body_lines.append(url_line)
|
713
|
+
|
714
|
+
# Build query parameters dictionary for the request
|
715
|
+
if query_params:
|
716
|
+
query_params_items = []
|
717
|
+
for param in query_params: # Iterate through original query_params list
|
718
|
+
# Use the original param.identifier for the key, and the (potentially aliased) param.name for the value variable
|
719
|
+
query_params_items.append(f"('{param.identifier}', {param.name})")
|
720
|
+
body_lines.append(
|
721
|
+
f" query_params = {{k: v for k, v in [{', '.join(query_params_items)}] if v is not None}}"
|
722
|
+
)
|
723
|
+
else:
|
724
|
+
body_lines.append(" query_params = {}")
|
725
|
+
|
726
|
+
# Make HTTP request using the proper method
|
727
|
+
method_lower = method.lower()
|
728
|
+
|
729
|
+
# Determine what to use as the request body argument
|
730
|
+
if has_empty_body:
|
731
|
+
request_body_arg = "request_body"
|
732
|
+
elif not has_body:
|
733
|
+
request_body_arg = "{}"
|
734
|
+
else:
|
735
|
+
request_body_arg = "request_body"
|
736
|
+
|
737
|
+
if method_lower == "get":
|
738
|
+
body_lines.append(" response = self._get(url, params=query_params)")
|
739
|
+
elif method_lower == "post":
|
740
|
+
body_lines.append(
|
741
|
+
f" response = self._post(url, data={request_body_arg}, params=query_params)"
|
742
|
+
)
|
743
|
+
elif method_lower == "put":
|
744
|
+
body_lines.append(
|
745
|
+
f" response = self._put(url, data={request_body_arg}, params=query_params)"
|
746
|
+
)
|
747
|
+
elif method_lower == "patch":
|
748
|
+
body_lines.append(
|
749
|
+
f" response = self._patch(url, data={request_body_arg}, params=query_params)"
|
750
|
+
)
|
751
|
+
elif method_lower == "delete":
|
752
|
+
body_lines.append(" response = self._delete(url, params=query_params)")
|
753
|
+
else:
|
754
|
+
body_lines.append(
|
755
|
+
f" response = self._{method_lower}(url, data={request_body_arg}, params=query_params)"
|
756
|
+
)
|
757
|
+
|
758
|
+
# Handle response
|
759
|
+
body_lines.append(" response.raise_for_status()")
|
760
|
+
body_lines.append(" return response.json()")
|
761
|
+
|
762
|
+
# Combine signature, docstring, and body
|
763
|
+
method_code = signature + formatted_docstring + "\n" + "\n".join(body_lines)
|
764
|
+
return method_code, func_name
|
765
|
+
|
766
|
+
|
767
|
+
def load_schema(path: Path):
|
768
|
+
return _load_and_resolve_references(path)
|
769
|
+
|
770
|
+
|
771
|
+
def generate_api_client(schema, class_name: str | None = None):
|
772
|
+
"""
|
773
|
+
Generate a Python API client class from an OpenAPI schema.
|
774
|
+
|
775
|
+
Args:
|
776
|
+
schema (dict): The OpenAPI schema as a dictionary.
|
777
|
+
|
778
|
+
Returns:
|
779
|
+
str: A string containing the Python code for the API client class.
|
780
|
+
"""
|
781
|
+
methods = []
|
782
|
+
method_names = []
|
783
|
+
|
784
|
+
# Extract API info for naming and base URL
|
785
|
+
info = schema.get("info", {})
|
786
|
+
api_title = info.get("title", "API")
|
787
|
+
|
788
|
+
# Get base URL from servers array if available
|
789
|
+
base_url = ""
|
790
|
+
servers = schema.get("servers", [])
|
791
|
+
if servers and isinstance(servers, list) and "url" in servers[0]:
|
792
|
+
base_url = servers[0]["url"].rstrip("/")
|
793
|
+
|
794
|
+
# Create a clean class name from API title
|
795
|
+
if api_title:
|
796
|
+
# Convert API title to a clean class name
|
797
|
+
if class_name:
|
798
|
+
clean_name = (
|
799
|
+
class_name.capitalize()[:-3]
|
800
|
+
if class_name.endswith("App")
|
801
|
+
else class_name.capitalize()
|
802
|
+
)
|
803
|
+
else:
|
804
|
+
base_name = "".join(word.capitalize() for word in api_title.split())
|
805
|
+
clean_name = "".join(c for c in base_name if c.isalnum())
|
806
|
+
class_name = f"{clean_name}App"
|
807
|
+
|
808
|
+
# Extract tool name - remove spaces and convert to lowercase
|
809
|
+
tool_name = api_title.lower()
|
810
|
+
|
811
|
+
# Remove version numbers (like 3.0, v1, etc.)
|
812
|
+
tool_name = re.sub(r"\s*v?\d+(\.\d+)*", "", tool_name)
|
813
|
+
|
814
|
+
# Remove common words that aren't needed
|
815
|
+
common_words = ["api", "openapi", "open", "swagger", "spec", "specification"]
|
816
|
+
for word in common_words:
|
817
|
+
tool_name = tool_name.replace(word, "")
|
818
|
+
|
819
|
+
# Remove spaces, hyphens, underscores
|
820
|
+
tool_name = tool_name.replace(" ", "").replace("-", "").replace("_", "")
|
821
|
+
|
822
|
+
# Remove any non-alphanumeric characters
|
823
|
+
tool_name = "".join(c for c in tool_name if c.isalnum())
|
824
|
+
|
825
|
+
# If empty (after cleaning), use generic name
|
826
|
+
if not tool_name:
|
827
|
+
tool_name = "api"
|
828
|
+
else:
|
829
|
+
class_name = "APIClient"
|
830
|
+
tool_name = "api"
|
831
|
+
|
832
|
+
# Iterate over paths and their operations
|
833
|
+
for path, path_info in schema.get("paths", {}).items():
|
834
|
+
for method in path_info:
|
835
|
+
if method in ["get", "post", "put", "delete", "patch", "options", "head"]:
|
836
|
+
operation = path_info[method]
|
837
|
+
method_code, func_name = _generate_method_code(path, method, operation)
|
838
|
+
methods.append(method_code)
|
839
|
+
method_names.append(func_name)
|
840
|
+
|
841
|
+
# Generate list_tools method with all the function names
|
842
|
+
tools_list = ",\n ".join([f"self.{name}" for name in method_names])
|
843
|
+
list_tools_method = f""" def list_tools(self):
|
844
|
+
return [
|
845
|
+
{tools_list}
|
846
|
+
]"""
|
847
|
+
|
848
|
+
# Generate class imports
|
849
|
+
imports = [
|
850
|
+
"from typing import Any",
|
851
|
+
"from universal_mcp.applications import APIApplication",
|
852
|
+
"from universal_mcp.integrations import Integration",
|
853
|
+
]
|
854
|
+
|
855
|
+
# Construct the class code
|
856
|
+
class_code = (
|
857
|
+
"\n".join(imports) + "\n\n"
|
858
|
+
f"class {class_name}(APIApplication):\n"
|
859
|
+
f" def __init__(self, integration: Integration = None, **kwargs) -> None:\n"
|
860
|
+
f" super().__init__(name='{class_name.lower()}', integration=integration, **kwargs)\n"
|
861
|
+
f' self.base_url = "{base_url}"\n\n'
|
862
|
+
+ "\n\n".join(methods)
|
863
|
+
+ "\n\n"
|
864
|
+
+ list_tools_method
|
865
|
+
+ "\n"
|
866
|
+
)
|
867
|
+
return class_code
|
868
|
+
|
869
|
+
|
870
|
+
# Example usage
|
871
|
+
if __name__ == "__main__":
|
872
|
+
# Sample OpenAPI schema
|
873
|
+
schema = {
|
874
|
+
"paths": {
|
875
|
+
"/users": {
|
876
|
+
"get": {
|
877
|
+
"summary": "Get a list of users",
|
878
|
+
"parameters": [
|
879
|
+
{
|
880
|
+
"name": "limit",
|
881
|
+
"in": "query",
|
882
|
+
"required": False,
|
883
|
+
"schema": {"type": "integer"},
|
884
|
+
}
|
885
|
+
],
|
886
|
+
"responses": {
|
887
|
+
"200": {
|
888
|
+
"description": "A list of users",
|
889
|
+
"content": {
|
890
|
+
"application/json": {"schema": {"type": "array"}}
|
891
|
+
},
|
892
|
+
}
|
893
|
+
},
|
894
|
+
},
|
895
|
+
"post": {
|
896
|
+
"summary": "Create a user",
|
897
|
+
"requestBody": {
|
898
|
+
"required": True,
|
899
|
+
"content": {
|
900
|
+
"application/json": {
|
901
|
+
"schema": {
|
902
|
+
"type": "object",
|
903
|
+
"properties": {"name": {"type": "string"}},
|
904
|
+
}
|
905
|
+
}
|
906
|
+
},
|
907
|
+
},
|
908
|
+
"responses": {"201": {"description": "User created"}},
|
909
|
+
},
|
910
|
+
},
|
911
|
+
"/users/{user_id}": {
|
912
|
+
"get": {
|
913
|
+
"summary": "Get a user by ID",
|
914
|
+
"parameters": [
|
915
|
+
{
|
916
|
+
"name": "user_id",
|
917
|
+
"in": "path",
|
918
|
+
"required": True,
|
919
|
+
"schema": {"type": "string"},
|
920
|
+
}
|
921
|
+
],
|
922
|
+
"responses": {"200": {"description": "User details"}},
|
923
|
+
}
|
924
|
+
},
|
925
|
+
}
|
926
|
+
}
|
927
|
+
|
928
|
+
schema = load_schema("openapi.yaml")
|
929
|
+
code = generate_api_client(schema)
|
930
|
+
print(code)
|