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