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