universal-mcp 0.1.1__py3-none-any.whl → 0.1.2__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.
- universal_mcp/applications/__init__.py +23 -28
- universal_mcp/applications/application.py +13 -8
- universal_mcp/applications/e2b/app.py +74 -0
- universal_mcp/applications/firecrawl/app.py +381 -0
- universal_mcp/applications/github/README.md +35 -0
- universal_mcp/applications/github/app.py +133 -100
- universal_mcp/applications/google_calendar/app.py +170 -139
- universal_mcp/applications/google_mail/app.py +185 -160
- universal_mcp/applications/markitdown/app.py +32 -0
- universal_mcp/applications/notion/README.md +32 -0
- universal_mcp/applications/notion/__init__.py +0 -0
- universal_mcp/applications/notion/app.py +415 -0
- universal_mcp/applications/reddit/app.py +112 -71
- universal_mcp/applications/resend/app.py +3 -8
- universal_mcp/applications/serp/app.py +84 -0
- universal_mcp/applications/tavily/app.py +11 -10
- universal_mcp/applications/zenquotes/app.py +3 -3
- universal_mcp/cli.py +98 -16
- universal_mcp/config.py +20 -3
- universal_mcp/exceptions.py +1 -3
- universal_mcp/integrations/__init__.py +6 -2
- universal_mcp/integrations/agentr.py +26 -24
- universal_mcp/integrations/integration.py +72 -35
- universal_mcp/servers/__init__.py +21 -1
- universal_mcp/servers/server.py +77 -44
- universal_mcp/stores/__init__.py +15 -2
- universal_mcp/stores/store.py +123 -13
- universal_mcp/utils/__init__.py +1 -0
- universal_mcp/utils/api_generator.py +269 -0
- universal_mcp/utils/docgen.py +360 -0
- universal_mcp/utils/installation.py +17 -2
- universal_mcp/utils/openapi.py +216 -111
- {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.2.dist-info}/METADATA +23 -5
- universal_mcp-0.1.2.dist-info/RECORD +40 -0
- universal_mcp-0.1.1.dist-info/RECORD +0 -29
- {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.2.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.2.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":
|
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":
|
81
|
+
"command": get_uvx_path(),
|
67
82
|
"args": ["universal_mcp@latest", "run"],
|
68
83
|
"env": {"AGENTR_API_KEY": api_key},
|
69
84
|
}
|