universal-mcp 0.1.13rc3__py3-none-any.whl → 0.1.13rc14__py3-none-any.whl

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