universal-mcp 0.1.2__py3-none-any.whl → 0.1.3rc1__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/firecrawl/app.py +74 -190
- universal_mcp/applications/markitdown/app.py +17 -6
- universal_mcp/applications/notion/README.md +43 -20
- universal_mcp/applications/notion/app.py +122 -130
- universal_mcp/applications/perplexity/app.py +79 -0
- universal_mcp/cli.py +20 -13
- universal_mcp/integrations/integration.py +9 -3
- universal_mcp/logger.py +74 -0
- universal_mcp/servers/server.py +28 -21
- universal_mcp/utils/docgen.py +2 -2
- universal_mcp/utils/installation.py +15 -0
- universal_mcp/utils/openapi.py +22 -29
- universal_mcp-0.1.3rc1.dist-info/METADATA +252 -0
- {universal_mcp-0.1.2.dist-info → universal_mcp-0.1.3rc1.dist-info}/RECORD +16 -14
- universal_mcp-0.1.2.dist-info/METADATA +0 -208
- {universal_mcp-0.1.2.dist-info → universal_mcp-0.1.3rc1.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.2.dist-info → universal_mcp-0.1.3rc1.dist-info}/entry_points.txt +0 -0
universal_mcp/cli.py
CHANGED
@@ -3,6 +3,8 @@ import os
|
|
3
3
|
from pathlib import Path
|
4
4
|
|
5
5
|
import typer
|
6
|
+
from rich import print as rprint
|
7
|
+
from rich.panel import Panel
|
6
8
|
|
7
9
|
from universal_mcp.utils.installation import (
|
8
10
|
get_supported_apps,
|
@@ -29,7 +31,7 @@ def generate(
|
|
29
31
|
"""Generate API client from OpenAPI schema with optional docstring generation.
|
30
32
|
|
31
33
|
The output filename should match the name of the API in the schema (e.g., 'twitter.py' for Twitter API).
|
32
|
-
This name will be used for the folder in applications
|
34
|
+
This name will be used for the folder in applications/.
|
33
35
|
"""
|
34
36
|
# Import here to avoid circular imports
|
35
37
|
from universal_mcp.utils.api_generator import generate_api_from_schema
|
@@ -97,9 +99,6 @@ def docgen(
|
|
97
99
|
typer.echo(f"Successfully processed {processed} functions")
|
98
100
|
except Exception as e:
|
99
101
|
typer.echo(f"Error: {e}", err=True)
|
100
|
-
import traceback
|
101
|
-
|
102
|
-
traceback.print_exc()
|
103
102
|
raise typer.Exit(1) from e
|
104
103
|
|
105
104
|
|
@@ -111,8 +110,11 @@ def run(
|
|
111
110
|
):
|
112
111
|
"""Run the MCP server"""
|
113
112
|
from universal_mcp.config import ServerConfig
|
113
|
+
from universal_mcp.logger import setup_logger
|
114
114
|
from universal_mcp.servers import server_from_config
|
115
115
|
|
116
|
+
setup_logger()
|
117
|
+
|
116
118
|
if config_path:
|
117
119
|
config = ServerConfig.model_validate_json(config_path.read_text())
|
118
120
|
else:
|
@@ -135,18 +137,23 @@ def install(app_name: str = typer.Argument(..., help="Name of app to install")):
|
|
135
137
|
raise typer.Exit(1)
|
136
138
|
|
137
139
|
# Print instructions before asking for API key
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
140
|
+
|
141
|
+
rprint(
|
142
|
+
Panel(
|
143
|
+
"API key is required. Visit [link]https://agentr.dev[/link] to create an API key.",
|
144
|
+
title="Instruction",
|
145
|
+
border_style="blue",
|
146
|
+
padding=(1, 2),
|
147
|
+
)
|
146
148
|
)
|
147
149
|
|
148
150
|
# Prompt for API key
|
149
|
-
api_key = typer.prompt(
|
151
|
+
api_key = typer.prompt(
|
152
|
+
"Enter your AgentR API key",
|
153
|
+
hide_input=False,
|
154
|
+
show_default=False,
|
155
|
+
type=str,
|
156
|
+
)
|
150
157
|
try:
|
151
158
|
if app_name == "claude":
|
152
159
|
typer.echo(f"Installing mcp server for: {app_name}")
|
@@ -7,6 +7,13 @@ from universal_mcp.exceptions import NotAuthorizedError
|
|
7
7
|
from universal_mcp.stores.store import Store
|
8
8
|
|
9
9
|
|
10
|
+
def sanitize_api_key_name(name: str) -> str:
|
11
|
+
suffix = "_API_KEY"
|
12
|
+
if name.endswith(suffix) or name.endswith(suffix.lower()):
|
13
|
+
return name.upper()
|
14
|
+
else:
|
15
|
+
return f"{name.upper()}{suffix}"
|
16
|
+
|
10
17
|
class Integration(ABC):
|
11
18
|
"""Abstract base class for handling application integrations and authentication.
|
12
19
|
|
@@ -75,9 +82,8 @@ class ApiKeyIntegration(Integration):
|
|
75
82
|
"""
|
76
83
|
|
77
84
|
def __init__(self, name: str, store: Store = None, **kwargs):
|
78
|
-
|
79
|
-
|
80
|
-
self.name = f"{name}_api_key"
|
85
|
+
sanitized_name = sanitize_api_key_name(name)
|
86
|
+
super().__init__(sanitized_name, store, **kwargs)
|
81
87
|
logger.info(f"Initializing API Key Integration: {name} with store: {store}")
|
82
88
|
|
83
89
|
def get_credentials(self):
|
universal_mcp/logger.py
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import uuid
|
4
|
+
from functools import lru_cache
|
5
|
+
|
6
|
+
from loguru import logger
|
7
|
+
|
8
|
+
|
9
|
+
@lru_cache(maxsize=1)
|
10
|
+
def get_version():
|
11
|
+
"""
|
12
|
+
Get the version of the Universal MCP
|
13
|
+
"""
|
14
|
+
try:
|
15
|
+
from importlib.metadata import version
|
16
|
+
|
17
|
+
print(version("universal_mcp"))
|
18
|
+
return version("universal_mcp")
|
19
|
+
except ImportError:
|
20
|
+
return "unknown"
|
21
|
+
|
22
|
+
|
23
|
+
def get_user_id():
|
24
|
+
"""
|
25
|
+
Generate a unique user ID for the current session
|
26
|
+
"""
|
27
|
+
return "universal_" + str(uuid.uuid4())[:8]
|
28
|
+
|
29
|
+
|
30
|
+
def posthog_sink(message, user_id=get_user_id()):
|
31
|
+
"""
|
32
|
+
Custom sink for sending logs to PostHog
|
33
|
+
"""
|
34
|
+
try:
|
35
|
+
import posthog
|
36
|
+
|
37
|
+
posthog.host = "https://us.i.posthog.com"
|
38
|
+
posthog.api_key = "phc_6HXMDi8CjfIW0l04l34L7IDkpCDeOVz9cOz1KLAHXh8"
|
39
|
+
|
40
|
+
record = message.record
|
41
|
+
properties = {
|
42
|
+
"level": record["level"].name,
|
43
|
+
"module": record["name"],
|
44
|
+
"function": record["function"],
|
45
|
+
"line": record["line"],
|
46
|
+
"message": record["message"],
|
47
|
+
"version": get_version(),
|
48
|
+
}
|
49
|
+
posthog.capture(user_id, "universal_mcp", properties)
|
50
|
+
except Exception:
|
51
|
+
# Silently fail if PostHog capture fails - don't want logging to break the app
|
52
|
+
pass
|
53
|
+
|
54
|
+
|
55
|
+
def setup_logger():
|
56
|
+
logger.remove()
|
57
|
+
logger.add(
|
58
|
+
sink=sys.stdout,
|
59
|
+
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
60
|
+
level="INFO",
|
61
|
+
colorize=True,
|
62
|
+
)
|
63
|
+
logger.add(
|
64
|
+
sink=sys.stderr,
|
65
|
+
format="<red>{time:YYYY-MM-DD HH:mm:ss}</red> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
66
|
+
level="ERROR",
|
67
|
+
colorize=True,
|
68
|
+
)
|
69
|
+
telemetry_enabled = os.getenv("TELEMETRY_ENABLED", "true").lower() == "true"
|
70
|
+
if telemetry_enabled:
|
71
|
+
logger.add(posthog_sink, level="INFO") # PostHog telemetry
|
72
|
+
|
73
|
+
|
74
|
+
setup_logger()
|
universal_mcp/servers/server.py
CHANGED
@@ -47,14 +47,20 @@ class Server(FastMCP, ABC):
|
|
47
47
|
|
48
48
|
async def call_tool(self, name: str, arguments: dict[str, Any]):
|
49
49
|
"""Call a tool by name with arguments."""
|
50
|
+
logger.info(f"Calling tool: {name} with arguments: {arguments}")
|
50
51
|
try:
|
51
52
|
result = await super().call_tool(name, arguments)
|
53
|
+
logger.info(f"Tool {name} completed successfully")
|
52
54
|
return result
|
53
55
|
except ToolError as e:
|
54
56
|
raised_error = e.__cause__
|
55
57
|
if isinstance(raised_error, NotAuthorizedError):
|
58
|
+
logger.warning(
|
59
|
+
f"Not authorized to call tool {name}: {raised_error.message}"
|
60
|
+
)
|
56
61
|
return [TextContent(type="text", text=raised_error.message)]
|
57
62
|
else:
|
63
|
+
logger.error(f"Error calling tool {name}: {str(e)}")
|
58
64
|
raise e
|
59
65
|
|
60
66
|
|
@@ -101,22 +107,18 @@ class LocalServer(Server):
|
|
101
107
|
def _load_apps(self):
|
102
108
|
logger.info(f"Loading apps: {self.apps_list}")
|
103
109
|
for app_config in self.apps_list:
|
104
|
-
|
105
|
-
|
106
|
-
|
110
|
+
try:
|
111
|
+
app = self._load_app(app_config)
|
112
|
+
if app:
|
113
|
+
tools = app.list_tools()
|
107
114
|
for tool in tools:
|
108
|
-
|
115
|
+
tool_name = tool.__name__
|
116
|
+
name = app.name + "_" + tool_name
|
109
117
|
description = tool.__doc__
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
):
|
115
|
-
should_add_tool = True
|
116
|
-
if should_add_tool:
|
117
|
-
self.add_tool(
|
118
|
-
tool, name=full_tool_name, description=description
|
119
|
-
)
|
118
|
+
if app_config.actions is None or tool_name in app_config.actions:
|
119
|
+
self.add_tool(tool, name=name, description=description)
|
120
|
+
except Exception as e:
|
121
|
+
logger.error(f"Error loading app {app_config.name}: {e}")
|
120
122
|
|
121
123
|
|
122
124
|
class AgentRServer(Server):
|
@@ -143,7 +145,7 @@ class AgentRServer(Server):
|
|
143
145
|
app = app_from_slug(name)(integration=integration)
|
144
146
|
return app
|
145
147
|
|
146
|
-
def _list_apps_with_integrations(self):
|
148
|
+
def _list_apps_with_integrations(self) -> list[AppConfig]:
|
147
149
|
# TODO: get this from the API
|
148
150
|
response = httpx.get(
|
149
151
|
f"{self.base_url}/api/apps/", headers={"X-API-KEY": self.api_key}
|
@@ -156,11 +158,16 @@ class AgentRServer(Server):
|
|
156
158
|
|
157
159
|
def _load_apps(self):
|
158
160
|
apps = self._list_apps_with_integrations()
|
159
|
-
for
|
160
|
-
|
161
|
-
|
162
|
-
|
161
|
+
for app_config in apps:
|
162
|
+
try:
|
163
|
+
app = self._load_app(app_config)
|
164
|
+
if app:
|
165
|
+
tools = app.list_tools()
|
163
166
|
for tool in tools:
|
164
|
-
|
167
|
+
tool_name = tool.__name__
|
168
|
+
name = app.name + "_" + tool_name
|
165
169
|
description = tool.__doc__
|
166
|
-
|
170
|
+
if app_config.actions is None or tool_name in app_config.actions:
|
171
|
+
self.add_tool(tool, name=name, description=description)
|
172
|
+
except Exception as e:
|
173
|
+
logger.error(f"Error loading app {app_config.name}: {e}")
|
universal_mcp/utils/docgen.py
CHANGED
@@ -116,7 +116,7 @@ def extract_functions_from_script(file_path: str) -> list[tuple[str, str]]:
|
|
116
116
|
|
117
117
|
|
118
118
|
def generate_docstring(
|
119
|
-
function_code: str, model: str = "
|
119
|
+
function_code: str, model: str = "openai/gpt-4o"
|
120
120
|
) -> DocstringOutput:
|
121
121
|
"""
|
122
122
|
Generate a docstring for a Python function using litellm with structured output.
|
@@ -304,7 +304,7 @@ def insert_docstring_into_function(function_code: str, docstring: str) -> str:
|
|
304
304
|
return function_code
|
305
305
|
|
306
306
|
|
307
|
-
def process_file(file_path: str, model: str = "
|
307
|
+
def process_file(file_path: str, model: str = "openai/gpt-4o") -> int:
|
308
308
|
"""
|
309
309
|
Process a Python file and add docstrings to all functions in it.
|
310
310
|
|
@@ -4,6 +4,7 @@ import sys
|
|
4
4
|
from pathlib import Path
|
5
5
|
|
6
6
|
from loguru import logger
|
7
|
+
from rich import print
|
7
8
|
|
8
9
|
|
9
10
|
def get_uvx_path() -> str:
|
@@ -21,6 +22,7 @@ def get_uvx_path() -> str:
|
|
21
22
|
def create_file_if_not_exists(path: Path) -> None:
|
22
23
|
"""Create a file if it doesn't exist"""
|
23
24
|
if not path.exists():
|
25
|
+
print(f"[yellow]Creating config file at {path}[/yellow]")
|
24
26
|
with open(path, "w") as f:
|
25
27
|
json.dump({}, f)
|
26
28
|
|
@@ -32,6 +34,7 @@ def get_supported_apps() -> list[str]:
|
|
32
34
|
|
33
35
|
def install_claude(api_key: str) -> None:
|
34
36
|
"""Install Claude"""
|
37
|
+
print("[bold blue]Installing Claude configuration...[/bold blue]")
|
35
38
|
# Determine platform-specific config path
|
36
39
|
if sys.platform == "darwin": # macOS
|
37
40
|
config_path = (
|
@@ -50,6 +53,9 @@ def install_claude(api_key: str) -> None:
|
|
50
53
|
try:
|
51
54
|
config = json.loads(config_path.read_text())
|
52
55
|
except json.JSONDecodeError:
|
56
|
+
print(
|
57
|
+
"[yellow]Config file was empty or invalid, creating new configuration[/yellow]"
|
58
|
+
)
|
53
59
|
config = {}
|
54
60
|
if "mcpServers" not in config:
|
55
61
|
config["mcpServers"] = {}
|
@@ -60,10 +66,12 @@ def install_claude(api_key: str) -> None:
|
|
60
66
|
}
|
61
67
|
with open(config_path, "w") as f:
|
62
68
|
json.dump(config, f, indent=4)
|
69
|
+
print("[green]✓[/green] Claude configuration installed successfully")
|
63
70
|
|
64
71
|
|
65
72
|
def install_cursor(api_key: str) -> None:
|
66
73
|
"""Install Cursor"""
|
74
|
+
print("[bold blue]Installing Cursor configuration...[/bold blue]")
|
67
75
|
# Set up Cursor config path
|
68
76
|
config_path = Path.home() / ".cursor/mcp.json"
|
69
77
|
|
@@ -73,6 +81,9 @@ def install_cursor(api_key: str) -> None:
|
|
73
81
|
try:
|
74
82
|
config = json.loads(config_path.read_text())
|
75
83
|
except json.JSONDecodeError:
|
84
|
+
print(
|
85
|
+
"[yellow]Config file was empty or invalid, creating new configuration[/yellow]"
|
86
|
+
)
|
76
87
|
config = {}
|
77
88
|
|
78
89
|
if "mcpServers" not in config:
|
@@ -85,15 +96,18 @@ def install_cursor(api_key: str) -> None:
|
|
85
96
|
|
86
97
|
with open(config_path, "w") as f:
|
87
98
|
json.dump(config, f, indent=4)
|
99
|
+
print("[green]✓[/green] Cursor configuration installed successfully")
|
88
100
|
|
89
101
|
|
90
102
|
def install_windsurf() -> None:
|
91
103
|
"""Install Windsurf"""
|
104
|
+
print("[yellow]Windsurf installation not yet implemented[/yellow]")
|
92
105
|
pass
|
93
106
|
|
94
107
|
|
95
108
|
def install_app(app_name: str) -> None:
|
96
109
|
"""Install an app"""
|
110
|
+
print(f"[bold]Installing {app_name}...[/bold]")
|
97
111
|
if app_name == "claude":
|
98
112
|
install_claude()
|
99
113
|
elif app_name == "cursor":
|
@@ -101,4 +115,5 @@ def install_app(app_name: str) -> None:
|
|
101
115
|
elif app_name == "windsurf":
|
102
116
|
install_windsurf()
|
103
117
|
else:
|
118
|
+
print(f"[red]Error: App '{app_name}' not supported[/red]")
|
104
119
|
raise ValueError(f"App '{app_name}' not supported")
|
universal_mcp/utils/openapi.py
CHANGED
@@ -41,7 +41,7 @@ def determine_return_type(operation: dict[str, Any]) -> str:
|
|
41
41
|
operation (dict): The operation details from the schema.
|
42
42
|
|
43
43
|
Returns:
|
44
|
-
str: The appropriate return type annotation (
|
44
|
+
str: The appropriate return type annotation (list[Any], dict[str, Any], or Any)
|
45
45
|
"""
|
46
46
|
responses = operation.get("responses", {})
|
47
47
|
# Find successful response (2XX)
|
@@ -62,9 +62,9 @@ def determine_return_type(operation: dict[str, Any]) -> str:
|
|
62
62
|
|
63
63
|
# Only determine if it's a list, dict, or unknown (Any)
|
64
64
|
if schema.get("type") == "array":
|
65
|
-
return "
|
65
|
+
return "list[Any]"
|
66
66
|
elif schema.get("type") == "object" or "$ref" in schema:
|
67
|
-
return "
|
67
|
+
return "dict[str, Any]"
|
68
68
|
|
69
69
|
# Default to Any if unable to determine
|
70
70
|
return "Any"
|
@@ -144,9 +144,9 @@ def generate_api_client(schema):
|
|
144
144
|
|
145
145
|
# Generate class imports
|
146
146
|
imports = [
|
147
|
+
"from typing import Any",
|
147
148
|
"from universal_mcp.applications import APIApplication",
|
148
149
|
"from universal_mcp.integrations import Integration",
|
149
|
-
"from typing import Any, Dict, List",
|
150
150
|
]
|
151
151
|
|
152
152
|
# Construct the class code
|
@@ -195,10 +195,11 @@ def generate_method_code(path, method, operation, tool_name=None):
|
|
195
195
|
else:
|
196
196
|
name_parts.append(part)
|
197
197
|
func_name = "_".join(name_parts).replace("-", "_").lower()
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
198
|
+
|
199
|
+
|
200
|
+
|
201
|
+
func_name = re.sub(r'_a([^_])', r'_a_\1', func_name) # Fix for patterns like retrieve_ablock
|
202
|
+
func_name = re.sub(r'_an([^_])', r'_an_\1', func_name) # Fix for patterns like create_anitem
|
202
203
|
|
203
204
|
# Get parameters and request body
|
204
205
|
# Filter out header parameters
|
@@ -273,40 +274,32 @@ def generate_method_code(path, method, operation, tool_name=None):
|
|
273
274
|
else:
|
274
275
|
body_lines.append(" query_params = {}")
|
275
276
|
|
276
|
-
# Request body handling for JSON
|
277
|
-
if has_body:
|
278
|
-
body_lines.append(
|
279
|
-
" json_body = request_body if request_body is not None else None"
|
280
|
-
)
|
281
|
-
|
282
277
|
# Make HTTP request using the proper method
|
283
278
|
method_lower = method.lower()
|
284
279
|
if method_lower == "get":
|
285
280
|
body_lines.append(" response = self._get(url, params=query_params)")
|
286
281
|
elif method_lower == "post":
|
287
282
|
if has_body:
|
288
|
-
body_lines.append(
|
289
|
-
" response = self._post(url, data=json_body, params=query_params)"
|
290
|
-
)
|
283
|
+
body_lines.append(" response = self._post(url, data=request_body, params=query_params)")
|
291
284
|
else:
|
292
|
-
body_lines.append(
|
293
|
-
" response = self._post(url, data={}, params=query_params)"
|
294
|
-
)
|
285
|
+
body_lines.append(" response = self._post(url, data={}, params=query_params)")
|
295
286
|
elif method_lower == "put":
|
296
287
|
if has_body:
|
297
|
-
body_lines.append(
|
298
|
-
" response = self._put(url, data=json_body, params=query_params)"
|
299
|
-
)
|
288
|
+
body_lines.append(" response = self._put(url, data=request_body, params=query_params)")
|
300
289
|
else:
|
301
|
-
body_lines.append(
|
302
|
-
|
303
|
-
|
290
|
+
body_lines.append(" response = self._put(url, data={}, params=query_params)")
|
291
|
+
elif method_lower == "patch":
|
292
|
+
if has_body:
|
293
|
+
body_lines.append(" response = self._patch(url, data=request_body, params=query_params)")
|
294
|
+
else:
|
295
|
+
body_lines.append(" response = self._patch(url, data={}, params=query_params)")
|
304
296
|
elif method_lower == "delete":
|
305
297
|
body_lines.append(" response = self._delete(url, params=query_params)")
|
306
298
|
else:
|
307
|
-
|
308
|
-
f" response = self._{method_lower}(url, data=
|
309
|
-
|
299
|
+
if has_body:
|
300
|
+
body_lines.append(f" response = self._{method_lower}(url, data=request_body, params=query_params)")
|
301
|
+
else:
|
302
|
+
body_lines.append(f" response = self._{method_lower}(url, data={{}}, params=query_params)")
|
310
303
|
|
311
304
|
# Handle response
|
312
305
|
body_lines.append(" response.raise_for_status()")
|