universal-mcp 0.1.1__py3-none-any.whl → 0.1.1rc1__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/applications/__init__.py +23 -28
  2. universal_mcp/applications/application.py +13 -8
  3. universal_mcp/applications/e2b/app.py +74 -0
  4. universal_mcp/applications/firecrawl/app.py +381 -0
  5. universal_mcp/applications/github/README.md +35 -0
  6. universal_mcp/applications/github/app.py +133 -100
  7. universal_mcp/applications/google_calendar/app.py +170 -139
  8. universal_mcp/applications/google_mail/app.py +185 -160
  9. universal_mcp/applications/markitdown/app.py +32 -0
  10. universal_mcp/applications/reddit/app.py +112 -71
  11. universal_mcp/applications/resend/app.py +3 -8
  12. universal_mcp/applications/serp/app.py +84 -0
  13. universal_mcp/applications/tavily/app.py +11 -10
  14. universal_mcp/applications/zenquotes/app.py +3 -3
  15. universal_mcp/cli.py +98 -16
  16. universal_mcp/config.py +20 -3
  17. universal_mcp/exceptions.py +1 -3
  18. universal_mcp/integrations/__init__.py +6 -2
  19. universal_mcp/integrations/agentr.py +26 -24
  20. universal_mcp/integrations/integration.py +72 -35
  21. universal_mcp/servers/__init__.py +21 -1
  22. universal_mcp/servers/server.py +77 -44
  23. universal_mcp/stores/__init__.py +15 -2
  24. universal_mcp/stores/store.py +123 -13
  25. universal_mcp/utils/__init__.py +1 -0
  26. universal_mcp/utils/api_generator.py +269 -0
  27. universal_mcp/utils/docgen.py +360 -0
  28. universal_mcp/utils/installation.py +17 -2
  29. universal_mcp/utils/openapi.py +202 -104
  30. {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.1rc1.dist-info}/METADATA +22 -5
  31. universal_mcp-0.1.1rc1.dist-info/RECORD +37 -0
  32. universal_mcp-0.1.1.dist-info/RECORD +0 -29
  33. {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.1rc1.dist-info}/WHEEL +0 -0
  34. {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.1rc1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,360 @@
1
+ """Docstring generator using litellm with structured output.
2
+
3
+ This module provides a simple way to generate docstrings for Python functions
4
+ using LLMs with structured output
5
+ """
6
+
7
+ import ast
8
+ import os
9
+
10
+ import litellm
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ class DocstringOutput(BaseModel):
15
+ """Structure for the generated docstring output."""
16
+
17
+ summary: str = Field(
18
+ description="A clear, concise summary of what the function does"
19
+ )
20
+ args: dict[str, str] = Field(
21
+ description="Dictionary mapping parameter names to their descriptions"
22
+ )
23
+ returns: str = Field(description="Description of what the function returns")
24
+
25
+
26
+ class FunctionExtractor(ast.NodeVisitor):
27
+ """
28
+ An AST node visitor that collects the source code of all function
29
+ and method definitions within a Python script.
30
+ """
31
+
32
+ def __init__(self, source_code: str):
33
+ self.source_lines = source_code.splitlines(keepends=True)
34
+ self.functions: list[
35
+ tuple[str, str]
36
+ ] = [] # Store tuples of (function_name, function_source)
37
+
38
+ def _get_source_segment(self, node: ast.AST) -> str | None:
39
+ """Safely extracts the source segment for a node using ast.get_source_segment."""
40
+ try:
41
+ source_segment = ast.get_source_segment("".join(self.source_lines), node)
42
+ return source_segment
43
+ except Exception as e:
44
+ print(
45
+ f"Warning: Could not retrieve source for node {getattr(node, 'name', 'unknown')} at line {getattr(node, 'lineno', 'unknown')}: {e}"
46
+ )
47
+ return None
48
+
49
+ def visit_FunctionDef(self, node: ast.FunctionDef):
50
+ """Visits a regular function definition."""
51
+ source_code = self._get_source_segment(node)
52
+ if source_code:
53
+ self.functions.append((node.name, source_code))
54
+ self.generic_visit(node)
55
+
56
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
57
+ """Visits an asynchronous function definition."""
58
+ source_code = self._get_source_segment(node)
59
+ if source_code:
60
+ self.functions.append((node.name, source_code))
61
+ self.generic_visit(node)
62
+
63
+
64
+ def extract_functions_from_script(file_path: str) -> list[tuple[str, str]]:
65
+ """
66
+ Reads a Python script and extracts the source code of all functions.
67
+
68
+ Args:
69
+ file_path: The path to the Python (.py) script.
70
+
71
+ Returns:
72
+ A list of tuples, where each tuple contains the function name (str)
73
+ and its full source code (str), including decorators.
74
+ Returns an empty list if the file cannot be read, parsed, or contains no functions.
75
+
76
+ Raises:
77
+ FileNotFoundError: If the file_path does not exist.
78
+ SyntaxError: If the file contains invalid Python syntax.
79
+ Exception: For other potential I/O or AST processing errors.
80
+ """
81
+ try:
82
+ with open(file_path, encoding="utf-8") as f:
83
+ source_code = f.read()
84
+ except FileNotFoundError:
85
+ print(f"Error: File not found at {file_path}")
86
+ raise
87
+ except Exception as e:
88
+ print(f"Error reading file {file_path}: {e}")
89
+ raise
90
+
91
+ try:
92
+ tree = ast.parse(source_code, filename=file_path)
93
+ except SyntaxError as e:
94
+ print(
95
+ f"Error: Invalid Python syntax in {file_path} at line {e.lineno}, offset {e.offset}: {e.msg}"
96
+ )
97
+ raise
98
+ except Exception as e:
99
+ print(f"Error parsing {file_path} into AST: {e}")
100
+ raise
101
+
102
+ try:
103
+ extractor = FunctionExtractor(source_code)
104
+ extractor.visit(tree)
105
+
106
+ if not extractor.functions:
107
+ print("Warning: No functions found in the file.")
108
+
109
+ return extractor.functions
110
+ except Exception as e:
111
+ print(f"Error during function extraction: {e}")
112
+ import traceback
113
+
114
+ traceback.print_exc()
115
+ return []
116
+
117
+
118
+ def generate_docstring(
119
+ function_code: str, model: str = "google/gemini-flash"
120
+ ) -> DocstringOutput:
121
+ """
122
+ Generate a docstring for a Python function using litellm with structured output.
123
+
124
+ Args:
125
+ function_code: The source code of the function to document
126
+ model: The model to use for generating the docstring
127
+
128
+ Returns:
129
+ A DocstringOutput object containing the structured docstring components
130
+ """
131
+ system_prompt = """You are a helpful AI assistant specialized in writing high-quality Google-style Python docstrings.
132
+ You MUST ALWAYS include an Args section, even if there are no arguments (in which case mention 'None')."""
133
+
134
+ user_prompt = f"""Generate a high-quality Google-style docstring for the following Python function.
135
+ Analyze the function's name, parameters, return values, and functionality to create a comprehensive docstring.
136
+
137
+ The docstring MUST:
138
+ 1. Start with a clear, concise summary of what the function does
139
+ 2. ALWAYS include Args section with description of each parameter (or 'None' if no parameters)
140
+ 3. Include Returns section describing the return value
141
+ 4. Be formatted according to Google Python Style Guide
142
+
143
+ Here is the function:
144
+
145
+ {function_code}
146
+
147
+ Respond in JSON format with the following structure:
148
+ {{
149
+ "summary": "A clear, concise summary of what the function does",
150
+ "args": {{"param_name": "param description", "param_name2": "param description"}},
151
+ "returns": "Description of what the function returns"
152
+ }}
153
+ """
154
+
155
+ try:
156
+ # Use regular completion and parse the JSON ourselves instead of using response_model
157
+ response = litellm.completion(
158
+ model=model,
159
+ messages=[
160
+ {"role": "system", "content": system_prompt},
161
+ {"role": "user", "content": user_prompt},
162
+ ],
163
+ )
164
+
165
+ # Get the response content
166
+ response_text = response.choices[0].message.content
167
+
168
+ # Simple JSON extraction in case the model includes extra text
169
+ import json
170
+ import re
171
+
172
+ # Find JSON object in the response using regex
173
+ json_match = re.search(r"({.*})", response_text.replace("\n", " "), re.DOTALL)
174
+ if json_match:
175
+ json_str = json_match.group(1)
176
+ parsed_data = json.loads(json_str)
177
+ else:
178
+ # Try to parse the whole response as JSON
179
+ parsed_data = json.loads(response_text)
180
+
181
+ # Ensure args is never empty
182
+ if not parsed_data.get("args"):
183
+ parsed_data["args"] = {"None": "This function takes no arguments"}
184
+
185
+ # Create DocstringOutput from parsed data
186
+ return DocstringOutput(
187
+ summary=parsed_data.get("summary", ""),
188
+ args=parsed_data.get("args", {"None": "This function takes no arguments"}),
189
+ returns=parsed_data.get("returns", ""),
190
+ )
191
+
192
+ except Exception as e:
193
+ print(f"Error generating docstring: {e}")
194
+ # Return a docstring object with default values
195
+ return DocstringOutput(
196
+ summary="No documentation available",
197
+ args={"None": "This function takes no arguments"},
198
+ returns="None",
199
+ )
200
+
201
+
202
+ def format_docstring(docstring: DocstringOutput) -> str:
203
+ """
204
+ Format a DocstringOutput object into a properly formatted docstring string.
205
+
206
+ Args:
207
+ docstring: The DocstringOutput object to format
208
+
209
+ Returns:
210
+ A formatted docstring string ready to be inserted into code
211
+ """
212
+ formatted_docstring = f"{docstring.summary}\n\n"
213
+
214
+ if docstring.args:
215
+ formatted_docstring += "Args:\n"
216
+ for arg_name, arg_desc in docstring.args.items():
217
+ formatted_docstring += f" {arg_name}: {arg_desc}\n"
218
+ formatted_docstring += "\n"
219
+
220
+ if docstring.returns:
221
+ formatted_docstring += f"Returns:\n {docstring.returns}\n"
222
+
223
+ return formatted_docstring.strip()
224
+
225
+
226
+ def insert_docstring_into_function(function_code: str, docstring: str) -> str:
227
+ """
228
+ Insert a docstring into a function's code.
229
+
230
+ Args:
231
+ function_code: The source code of the function
232
+ docstring: The formatted docstring string to insert
233
+
234
+ Returns:
235
+ The updated function code with the docstring inserted
236
+ """
237
+ try:
238
+ function_ast = ast.parse(function_code)
239
+ if not function_ast.body or not hasattr(function_ast.body[0], "body"):
240
+ return function_code
241
+
242
+ function_lines = function_code.splitlines()
243
+
244
+ # Find the function definition line (ends with ':')
245
+ func_def_line = None
246
+ for i, line in enumerate(function_lines):
247
+ if "def " in line and line.strip().endswith(":"):
248
+ func_def_line = i
249
+ break
250
+
251
+ if func_def_line is None:
252
+ return function_code
253
+
254
+ # Determine indentation from the first non-empty line after the function definition
255
+ body_indent = ""
256
+ for line in function_lines[func_def_line + 1 :]:
257
+ if line.strip():
258
+ body_indent = " " * (len(line) - len(line.lstrip()))
259
+ break
260
+
261
+ # Check if the function already has a docstring
262
+ first_element = (
263
+ function_ast.body[0].body[0] if function_ast.body[0].body else None
264
+ )
265
+ has_docstring = (
266
+ isinstance(first_element, ast.Expr)
267
+ and isinstance(first_element.value, ast.Constant)
268
+ and isinstance(first_element.value.value, str)
269
+ )
270
+
271
+ docstring_lines = [
272
+ f'{body_indent}"""',
273
+ *[f"{body_indent}{line}" for line in docstring.split("\n")],
274
+ f'{body_indent}"""',
275
+ ]
276
+
277
+ if has_docstring:
278
+ # Find the existing docstring in the source and replace it
279
+ for i in range(func_def_line + 1, len(function_lines)):
280
+ if '"""' in function_lines[i] or "'''" in function_lines[i]:
281
+ docstring_start = i
282
+ # Find end of docstring
283
+ for j in range(docstring_start + 1, len(function_lines)):
284
+ if '"""' in function_lines[j] or "'''" in function_lines[j]:
285
+ docstring_end = j
286
+ # Replace the existing docstring
287
+ return "\n".join(
288
+ function_lines[:docstring_start]
289
+ + docstring_lines
290
+ + function_lines[docstring_end + 1 :]
291
+ )
292
+ else:
293
+ # Insert new docstring after function definition
294
+ return "\n".join(
295
+ function_lines[: func_def_line + 1]
296
+ + docstring_lines
297
+ + function_lines[func_def_line + 1 :]
298
+ )
299
+
300
+ # Default return if insertion logic fails
301
+ return function_code
302
+ except Exception as e:
303
+ print(f"Error inserting docstring: {e}")
304
+ return function_code
305
+
306
+
307
+ def process_file(file_path: str, model: str = "google/gemini-flash") -> int:
308
+ """
309
+ Process a Python file and add docstrings to all functions in it.
310
+
311
+ Args:
312
+ file_path: Path to the Python file to process
313
+ model: The model to use for generating docstrings
314
+
315
+ Returns:
316
+ Number of functions processed
317
+ """
318
+ if not os.path.exists(file_path):
319
+ raise FileNotFoundError(f"File not found: {file_path}")
320
+
321
+ # Read the original file
322
+ with open(file_path, encoding="utf-8") as f:
323
+ original_content = f.read()
324
+
325
+ # Extract functions
326
+ functions = extract_functions_from_script(file_path)
327
+ if not functions:
328
+ print(f"No functions found in {file_path}")
329
+ return 0
330
+
331
+ updated_content = original_content
332
+ count = 0
333
+
334
+ # Process each function
335
+ for function_name, function_code in functions:
336
+ print(f"Processing function: {function_name}")
337
+
338
+ # Generate docstring
339
+ docstring_output = generate_docstring(function_code, model)
340
+ formatted_docstring = format_docstring(docstring_output)
341
+
342
+ # Insert docstring into function
343
+ updated_function = insert_docstring_into_function(
344
+ function_code, formatted_docstring
345
+ )
346
+
347
+ # Replace the function in the file content
348
+ if updated_function != function_code:
349
+ updated_content = updated_content.replace(function_code, updated_function)
350
+ count += 1
351
+
352
+ # Write the updated content back to the file
353
+ if updated_content != original_content:
354
+ with open(file_path, "w", encoding="utf-8") as f:
355
+ f.write(updated_content)
356
+ print(f"Updated {count} functions in {file_path}")
357
+ else:
358
+ print(f"No changes made to {file_path}")
359
+
360
+ return count
@@ -1,7 +1,22 @@
1
1
  import json
2
+ import shutil
2
3
  import sys
3
4
  from pathlib import Path
4
5
 
6
+ from loguru import logger
7
+
8
+
9
+ def get_uvx_path() -> str:
10
+ """Get the full path to the uv executable."""
11
+ uvx_path = shutil.which("uvx")
12
+ if not uvx_path:
13
+ logger.error(
14
+ "uvx executable not found in PATH, falling back to 'uvx'. "
15
+ "Please ensure uvx is installed and in your PATH"
16
+ )
17
+ return "uvx" # Fall back to just "uvx" if not found
18
+ return uvx_path
19
+
5
20
 
6
21
  def create_file_if_not_exists(path: Path) -> None:
7
22
  """Create a file if it doesn't exist"""
@@ -39,7 +54,7 @@ def install_claude(api_key: str) -> None:
39
54
  if "mcpServers" not in config:
40
55
  config["mcpServers"] = {}
41
56
  config["mcpServers"]["universal_mcp"] = {
42
- "command": "uvx",
57
+ "command": get_uvx_path(),
43
58
  "args": ["universal_mcp@latest", "run"],
44
59
  "env": {"AGENTR_API_KEY": api_key},
45
60
  }
@@ -63,7 +78,7 @@ def install_cursor(api_key: str) -> None:
63
78
  if "mcpServers" not in config:
64
79
  config["mcpServers"] = {}
65
80
  config["mcpServers"]["universal_mcp"] = {
66
- "command": "uvx",
81
+ "command": get_uvx_path(),
67
82
  "args": ["universal_mcp@latest", "run"],
68
83
  "env": {"AGENTR_API_KEY": api_key},
69
84
  }