universal-mcp 0.1.14__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.
Files changed (34) hide show
  1. universal_mcp/analytics.py +7 -1
  2. universal_mcp/applications/README.md +122 -0
  3. universal_mcp/applications/__init__.py +48 -46
  4. universal_mcp/applications/application.py +249 -40
  5. universal_mcp/cli.py +49 -49
  6. universal_mcp/config.py +95 -22
  7. universal_mcp/exceptions.py +8 -0
  8. universal_mcp/integrations/integration.py +18 -2
  9. universal_mcp/logger.py +59 -8
  10. universal_mcp/servers/__init__.py +2 -2
  11. universal_mcp/stores/store.py +2 -12
  12. universal_mcp/tools/__init__.py +14 -2
  13. universal_mcp/tools/adapters.py +25 -0
  14. universal_mcp/tools/func_metadata.py +12 -2
  15. universal_mcp/tools/manager.py +236 -0
  16. universal_mcp/tools/tools.py +5 -249
  17. universal_mcp/utils/common.py +33 -0
  18. universal_mcp/utils/openapi/__inti__.py +0 -0
  19. universal_mcp/utils/{api_generator.py → openapi/api_generator.py} +1 -1
  20. universal_mcp/utils/openapi/openapi.py +930 -0
  21. universal_mcp/utils/openapi/preprocessor.py +1223 -0
  22. universal_mcp/utils/{readme.py → openapi/readme.py} +21 -31
  23. universal_mcp/utils/templates/README.md.j2 +17 -0
  24. {universal_mcp-0.1.14.dist-info → universal_mcp-0.1.15rc7.dist-info}/METADATA +6 -3
  25. universal_mcp-0.1.15rc7.dist-info/RECORD +44 -0
  26. universal_mcp-0.1.15rc7.dist-info/licenses/LICENSE +21 -0
  27. universal_mcp/templates/README.md.j2 +0 -93
  28. universal_mcp/utils/dump_app_tools.py +0 -78
  29. universal_mcp/utils/openapi.py +0 -697
  30. universal_mcp-0.1.14.dist-info/RECORD +0 -39
  31. /universal_mcp/utils/{docgen.py → openapi/docgen.py} +0 -0
  32. /universal_mcp/{templates → utils/templates}/api_client.py.j2 +0 -0
  33. {universal_mcp-0.1.14.dist-info → universal_mcp-0.1.15rc7.dist-info}/WHEEL +0 -0
  34. {universal_mcp-0.1.14.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)