universal-mcp 0.1.23rc2__py3-none-any.whl → 0.1.24rc3__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/agentr/__init__.py +6 -0
- universal_mcp/agentr/agentr.py +30 -0
- universal_mcp/{utils/agentr.py → agentr/client.py} +22 -7
- universal_mcp/agentr/integration.py +104 -0
- universal_mcp/agentr/registry.py +91 -0
- universal_mcp/agentr/server.py +51 -0
- universal_mcp/agents/__init__.py +6 -0
- universal_mcp/agents/auto.py +576 -0
- universal_mcp/agents/base.py +88 -0
- universal_mcp/agents/cli.py +27 -0
- universal_mcp/agents/codeact/__init__.py +243 -0
- universal_mcp/agents/codeact/sandbox.py +27 -0
- universal_mcp/agents/codeact/test.py +15 -0
- universal_mcp/agents/codeact/utils.py +61 -0
- universal_mcp/agents/hil.py +104 -0
- universal_mcp/agents/llm.py +10 -0
- universal_mcp/agents/react.py +58 -0
- universal_mcp/agents/simple.py +40 -0
- universal_mcp/agents/utils.py +111 -0
- universal_mcp/analytics.py +44 -14
- universal_mcp/applications/__init__.py +42 -75
- universal_mcp/applications/application.py +187 -133
- universal_mcp/applications/sample/app.py +245 -0
- universal_mcp/cli.py +14 -231
- universal_mcp/client/oauth.py +122 -18
- universal_mcp/client/token_store.py +62 -3
- universal_mcp/client/{client.py → transport.py} +127 -48
- universal_mcp/config.py +189 -49
- universal_mcp/exceptions.py +54 -6
- universal_mcp/integrations/__init__.py +0 -18
- universal_mcp/integrations/integration.py +185 -168
- universal_mcp/servers/__init__.py +2 -14
- universal_mcp/servers/server.py +84 -258
- universal_mcp/stores/store.py +126 -93
- universal_mcp/tools/__init__.py +3 -0
- universal_mcp/tools/adapters.py +20 -11
- universal_mcp/tools/func_metadata.py +1 -1
- universal_mcp/tools/manager.py +38 -53
- universal_mcp/tools/registry.py +41 -0
- universal_mcp/tools/tools.py +24 -3
- universal_mcp/types.py +10 -0
- universal_mcp/utils/common.py +245 -0
- universal_mcp/utils/installation.py +3 -4
- universal_mcp/utils/openapi/api_generator.py +71 -17
- universal_mcp/utils/openapi/api_splitter.py +0 -1
- universal_mcp/utils/openapi/cli.py +669 -0
- universal_mcp/utils/openapi/filters.py +114 -0
- universal_mcp/utils/openapi/openapi.py +315 -23
- universal_mcp/utils/openapi/postprocessor.py +275 -0
- universal_mcp/utils/openapi/preprocessor.py +63 -8
- universal_mcp/utils/openapi/test_generator.py +287 -0
- universal_mcp/utils/prompts.py +634 -0
- universal_mcp/utils/singleton.py +4 -1
- universal_mcp/utils/testing.py +196 -8
- universal_mcp-0.1.24rc3.dist-info/METADATA +68 -0
- universal_mcp-0.1.24rc3.dist-info/RECORD +70 -0
- universal_mcp/applications/README.md +0 -122
- universal_mcp/client/__main__.py +0 -30
- universal_mcp/client/agent.py +0 -96
- universal_mcp/integrations/README.md +0 -25
- universal_mcp/servers/README.md +0 -79
- universal_mcp/stores/README.md +0 -74
- universal_mcp/tools/README.md +0 -86
- universal_mcp-0.1.23rc2.dist-info/METADATA +0 -283
- universal_mcp-0.1.23rc2.dist-info/RECORD +0 -51
- /universal_mcp/{utils → tools}/docstring_parser.py +0 -0
- {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/entry_points.txt +0 -0
- {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/licenses/LICENSE +0 -0
universal_mcp/tools/manager.py
CHANGED
@@ -5,14 +5,14 @@ from loguru import logger
|
|
5
5
|
|
6
6
|
from universal_mcp.analytics import analytics
|
7
7
|
from universal_mcp.applications.application import BaseApplication
|
8
|
-
from universal_mcp.exceptions import
|
8
|
+
from universal_mcp.exceptions import ToolNotFoundError
|
9
9
|
from universal_mcp.tools.adapters import (
|
10
|
-
ToolFormat,
|
11
10
|
convert_tool_to_langchain_tool,
|
12
11
|
convert_tool_to_mcp_tool,
|
13
12
|
convert_tool_to_openai_tool,
|
14
13
|
)
|
15
14
|
from universal_mcp.tools.tools import Tool
|
15
|
+
from universal_mcp.types import ToolFormat
|
16
16
|
|
17
17
|
# Constants
|
18
18
|
DEFAULT_IMPORTANT_TAG = "important"
|
@@ -20,6 +20,17 @@ TOOL_NAME_SEPARATOR = "_"
|
|
20
20
|
DEFAULT_APP_NAME = "common"
|
21
21
|
|
22
22
|
|
23
|
+
def _get_app_and_tool_name(tool_name: str) -> tuple[str, str]:
|
24
|
+
"""Get the app name from a tool name."""
|
25
|
+
if TOOL_NAME_SEPARATOR in tool_name:
|
26
|
+
app_name = tool_name.split(TOOL_NAME_SEPARATOR, 1)[0]
|
27
|
+
tool_name_without_app_name = tool_name.split(TOOL_NAME_SEPARATOR, 1)[1]
|
28
|
+
else:
|
29
|
+
app_name = DEFAULT_APP_NAME
|
30
|
+
tool_name_without_app_name = tool_name
|
31
|
+
return app_name, tool_name_without_app_name
|
32
|
+
|
33
|
+
|
23
34
|
def _filter_by_name(tools: list[Tool], tool_names: list[str] | None) -> list[Tool]:
|
24
35
|
"""Filter tools by name using simple string matching.
|
25
36
|
|
@@ -81,22 +92,23 @@ def _filter_by_tags(tools: list[Tool], tags: list[str] | None) -> list[Tool]:
|
|
81
92
|
|
82
93
|
|
83
94
|
class ToolManager:
|
84
|
-
"""
|
95
|
+
"""
|
96
|
+
Manages tools
|
85
97
|
|
86
98
|
This class provides functionality for registering, managing, and executing tools.
|
87
99
|
It supports multiple tool formats and provides filtering capabilities based on names and tags.
|
88
100
|
Tools are organized by their source application for better management.
|
89
101
|
"""
|
90
102
|
|
91
|
-
def __init__(self, warn_on_duplicate_tools: bool = True):
|
103
|
+
def __init__(self, warn_on_duplicate_tools: bool = True, default_format: ToolFormat = ToolFormat.MCP):
|
92
104
|
"""Initialize the ToolManager.
|
93
105
|
|
94
106
|
Args:
|
95
107
|
warn_on_duplicate_tools: Whether to warn when duplicate tool names are detected.
|
96
108
|
"""
|
97
|
-
self._tools_by_app: dict[str, dict[str, Tool]] = {}
|
98
109
|
self._all_tools: dict[str, Tool] = {}
|
99
110
|
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
111
|
+
self.default_format = default_format
|
100
112
|
|
101
113
|
def get_tool(self, name: str) -> Tool | None:
|
102
114
|
"""Get tool by name.
|
@@ -109,25 +121,10 @@ class ToolManager:
|
|
109
121
|
"""
|
110
122
|
return self._all_tools.get(name)
|
111
123
|
|
112
|
-
def get_tools_by_app(self, app_name: str | None = None) -> list[Tool]:
|
113
|
-
"""Get all tools from a specific application.
|
114
|
-
|
115
|
-
Args:
|
116
|
-
app_name: The name of the application to get tools from.
|
117
|
-
|
118
|
-
Returns:
|
119
|
-
List of tools from the specified application.
|
120
|
-
"""
|
121
|
-
if app_name:
|
122
|
-
return list(self._tools_by_app.get(app_name, {}).values())
|
123
|
-
else:
|
124
|
-
return list(self._all_tools.values())
|
125
|
-
|
126
124
|
def list_tools(
|
127
125
|
self,
|
128
|
-
format: ToolFormat =
|
126
|
+
format: ToolFormat | None = None,
|
129
127
|
tags: list[str] | None = None,
|
130
|
-
app_name: str | None = None,
|
131
128
|
tool_names: list[str] | None = None,
|
132
129
|
) -> list:
|
133
130
|
"""List all registered tools in the specified format.
|
@@ -144,13 +141,18 @@ class ToolManager:
|
|
144
141
|
Raises:
|
145
142
|
ValueError: If an invalid format is provided.
|
146
143
|
"""
|
144
|
+
if format is None:
|
145
|
+
format = self.default_format
|
146
|
+
|
147
147
|
# Start with app-specific tools or all tools
|
148
|
-
tools = self.
|
148
|
+
tools = list(self._all_tools.values())
|
149
149
|
# Apply filters
|
150
150
|
tools = _filter_by_tags(tools, tags)
|
151
151
|
tools = _filter_by_name(tools, tool_names)
|
152
152
|
|
153
153
|
# Convert to requested format
|
154
|
+
if format == ToolFormat.NATIVE:
|
155
|
+
return [tool.fn for tool in tools]
|
154
156
|
if format == ToolFormat.MCP:
|
155
157
|
return [convert_tool_to_mcp_tool(tool) for tool in tools]
|
156
158
|
elif format == ToolFormat.LANGCHAIN:
|
@@ -160,15 +162,12 @@ class ToolManager:
|
|
160
162
|
else:
|
161
163
|
raise ValueError(f"Invalid format: {format}")
|
162
164
|
|
163
|
-
def add_tool(
|
164
|
-
self, fn: Callable[..., Any] | Tool, name: str | None = None, app_name: str = DEFAULT_APP_NAME
|
165
|
-
) -> Tool:
|
165
|
+
def add_tool(self, fn: Callable[..., Any] | Tool, name: str | None = None) -> Tool:
|
166
166
|
"""Add a tool to the manager.
|
167
167
|
|
168
168
|
Args:
|
169
169
|
fn: The tool function or Tool instance to add.
|
170
170
|
name: Optional name override for the tool.
|
171
|
-
app_name: Application name to group the tool under.
|
172
171
|
|
173
172
|
Returns:
|
174
173
|
The registered Tool instance.
|
@@ -189,17 +188,11 @@ class ToolManager:
|
|
189
188
|
logger.debug(f"Tool '{tool.name}' with the same function already exists.")
|
190
189
|
return existing
|
191
190
|
|
192
|
-
logger.debug(f"Adding tool: {tool.name}
|
191
|
+
logger.debug(f"Adding tool: {tool.name}")
|
193
192
|
self._all_tools[tool.name] = tool
|
194
|
-
|
195
|
-
# Group tool by application
|
196
|
-
if app_name not in self._tools_by_app:
|
197
|
-
self._tools_by_app[app_name] = {}
|
198
|
-
self._tools_by_app[app_name][tool.name] = tool
|
199
|
-
|
200
193
|
return tool
|
201
194
|
|
202
|
-
def register_tools(self, tools: list[Tool]
|
195
|
+
def register_tools(self, tools: list[Tool]) -> None:
|
203
196
|
"""Register a list of tools.
|
204
197
|
|
205
198
|
Args:
|
@@ -207,7 +200,12 @@ class ToolManager:
|
|
207
200
|
app_name: Application name to group the tools under.
|
208
201
|
"""
|
209
202
|
for tool in tools:
|
210
|
-
|
203
|
+
app_name, tool_name = _get_app_and_tool_name(tool.name)
|
204
|
+
|
205
|
+
# Add prefix to tool name, if not already present
|
206
|
+
tool.name = f"{app_name}{TOOL_NAME_SEPARATOR}{tool_name}"
|
207
|
+
tool.tags.append(app_name)
|
208
|
+
self.add_tool(tool)
|
211
209
|
|
212
210
|
def remove_tool(self, name: str) -> bool:
|
213
211
|
"""Remove a tool by name.
|
@@ -219,23 +217,13 @@ class ToolManager:
|
|
219
217
|
True if the tool was removed, False if it didn't exist.
|
220
218
|
"""
|
221
219
|
if name in self._all_tools:
|
222
|
-
self._all_tools[name]
|
223
220
|
del self._all_tools[name]
|
224
|
-
|
225
|
-
# Remove from app-specific grouping if present
|
226
|
-
for app_tools in self._tools_by_app.values():
|
227
|
-
if name in app_tools:
|
228
|
-
del app_tools[name]
|
229
|
-
# PERFORMANCE: Break after finding and removing to avoid unnecessary iterations
|
230
|
-
break
|
231
|
-
|
232
221
|
return True
|
233
222
|
return False
|
234
223
|
|
235
224
|
def clear_tools(self) -> None:
|
236
225
|
"""Remove all registered tools."""
|
237
226
|
self._all_tools.clear()
|
238
|
-
self._tools_by_app.clear()
|
239
227
|
|
240
228
|
def register_tools_from_app(
|
241
229
|
self,
|
@@ -272,7 +260,6 @@ class ToolManager:
|
|
272
260
|
try:
|
273
261
|
tool_instance = Tool.from_function(function)
|
274
262
|
tool_instance.name = f"{app.name}{TOOL_NAME_SEPARATOR}{tool_instance.name}"
|
275
|
-
# BUG FIX: Avoid duplicate tags - check if app.name is already in tags before adding
|
276
263
|
if app.name not in tool_instance.tags:
|
277
264
|
tool_instance.tags.append(app.name)
|
278
265
|
tools.append(tool_instance)
|
@@ -280,19 +267,16 @@ class ToolManager:
|
|
280
267
|
tool_name = getattr(function, "__name__", "unknown")
|
281
268
|
logger.error(f"Failed to create Tool from '{tool_name}' in {app.name}: {e}")
|
282
269
|
|
283
|
-
# BUG FIX: Apply filtering logic correctly - if both tool_names and tags are provided,
|
284
|
-
# we should filter by both, not use default important tag
|
285
270
|
if tags:
|
286
271
|
tools = _filter_by_tags(tools, tags)
|
287
272
|
|
288
273
|
if tool_names:
|
289
274
|
tools = _filter_by_name(tools, tool_names)
|
290
275
|
|
291
|
-
# BUG FIX: Only use default important tag if NO filters are provided at all
|
292
276
|
if not tool_names and not tags:
|
293
277
|
tools = _filter_by_tags(tools, [DEFAULT_IMPORTANT_TAG])
|
294
278
|
|
295
|
-
self.register_tools(tools
|
279
|
+
self.register_tools(tools)
|
296
280
|
|
297
281
|
async def call_tool(
|
298
282
|
self,
|
@@ -314,14 +298,15 @@ class ToolManager:
|
|
314
298
|
ToolError: If the tool is not found or execution fails.
|
315
299
|
"""
|
316
300
|
logger.debug(f"Calling tool: {name} with arguments: {arguments}")
|
317
|
-
app_name = name
|
301
|
+
app_name, _ = _get_app_and_tool_name(name)
|
318
302
|
tool = self.get_tool(name)
|
319
303
|
if not tool:
|
320
|
-
|
304
|
+
logger.error(f"Unknown tool: {name}")
|
305
|
+
raise ToolNotFoundError(f"Unknown tool: {name}")
|
321
306
|
try:
|
322
307
|
result = await tool.run(arguments, context)
|
323
308
|
analytics.track_tool_called(name, app_name, "success")
|
324
309
|
return result
|
325
310
|
except Exception as e:
|
326
311
|
analytics.track_tool_called(name, app_name, "error", str(e))
|
327
|
-
raise
|
312
|
+
raise e
|
@@ -0,0 +1,41 @@
|
|
1
|
+
from abc import ABC, abstractmethod
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
|
5
|
+
class ToolRegistry(ABC):
|
6
|
+
"""Abstract base class for platform-specific functionality.
|
7
|
+
|
8
|
+
This class abstracts away platform-specific operations like fetching apps,
|
9
|
+
loading actions, and managing integrations. This allows the AutoAgent to
|
10
|
+
work with different platforms without being tightly coupled to any specific one.
|
11
|
+
"""
|
12
|
+
|
13
|
+
@abstractmethod
|
14
|
+
async def list_apps(self) -> list[dict[str, Any]]:
|
15
|
+
"""Get list of available apps from the platform.
|
16
|
+
|
17
|
+
Returns:
|
18
|
+
Return a list of apps with their details
|
19
|
+
"""
|
20
|
+
pass
|
21
|
+
|
22
|
+
@abstractmethod
|
23
|
+
async def get_app_details(self, app_id: str) -> dict[str, Any]:
|
24
|
+
"""Get detailed information about a specific app.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
app_id: The ID of the app to get details for
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
Dictionary containing app details
|
31
|
+
"""
|
32
|
+
pass
|
33
|
+
|
34
|
+
@abstractmethod
|
35
|
+
async def load_tools(self, tools: list[str]) -> None:
|
36
|
+
"""Load tools from the platform and register them as tools.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
tools: The list of tools to load
|
40
|
+
"""
|
41
|
+
pass
|
universal_mcp/tools/tools.py
CHANGED
@@ -3,14 +3,30 @@ from collections.abc import Callable
|
|
3
3
|
from typing import Any
|
4
4
|
|
5
5
|
import httpx
|
6
|
-
from pydantic import BaseModel, Field
|
6
|
+
from pydantic import BaseModel, Field, create_model
|
7
7
|
|
8
8
|
from universal_mcp.exceptions import NotAuthorizedError, ToolError
|
9
|
-
from universal_mcp.
|
9
|
+
from universal_mcp.tools.docstring_parser import parse_docstring
|
10
10
|
|
11
11
|
from .func_metadata import FuncMetadata
|
12
12
|
|
13
13
|
|
14
|
+
def _get_return_type_schema(return_annotation: Any) -> dict[str, Any] | None:
|
15
|
+
"""Convert return type annotation to JSON schema using Pydantic."""
|
16
|
+
if return_annotation == inspect.Signature.empty or return_annotation == Any:
|
17
|
+
return None
|
18
|
+
|
19
|
+
try:
|
20
|
+
temp_model = create_model("ReturnTypeModel", return_value=(return_annotation, ...))
|
21
|
+
|
22
|
+
full_schema = temp_model.model_json_schema()
|
23
|
+
return_field_schema = full_schema.get("properties", {}).get("return_value")
|
24
|
+
|
25
|
+
return return_field_schema
|
26
|
+
except Exception:
|
27
|
+
return None
|
28
|
+
|
29
|
+
|
14
30
|
class Tool(BaseModel):
|
15
31
|
"""Internal tool registration info."""
|
16
32
|
|
@@ -27,6 +43,7 @@ class Tool(BaseModel):
|
|
27
43
|
)
|
28
44
|
tags: list[str] = Field(default_factory=list, description="Tags for categorizing the tool")
|
29
45
|
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
|
46
|
+
output_schema: dict[str, Any] | None = Field(default=None, description="JSON schema for tool output")
|
30
47
|
fn_metadata: FuncMetadata = Field(
|
31
48
|
description="Metadata about the function including a pydantic model for tool arguments"
|
32
49
|
)
|
@@ -53,6 +70,9 @@ class Tool(BaseModel):
|
|
53
70
|
func_arg_metadata = FuncMetadata.func_metadata(fn, arg_description=parsed_doc["args"])
|
54
71
|
parameters = func_arg_metadata.arg_model.model_json_schema()
|
55
72
|
|
73
|
+
sig = inspect.signature(fn)
|
74
|
+
output_schema = _get_return_type_schema(sig.return_annotation)
|
75
|
+
|
56
76
|
simple_args_descriptions: dict[str, str] = {}
|
57
77
|
if parsed_doc.get("args"):
|
58
78
|
for arg_name, arg_details in parsed_doc["args"].items():
|
@@ -68,6 +88,7 @@ class Tool(BaseModel):
|
|
68
88
|
raises_description=parsed_doc["raises"],
|
69
89
|
tags=parsed_doc["tags"],
|
70
90
|
parameters=parameters,
|
91
|
+
output_schema=output_schema,
|
71
92
|
fn_metadata=func_arg_metadata,
|
72
93
|
is_async=is_async,
|
73
94
|
)
|
@@ -87,7 +108,7 @@ class Tool(BaseModel):
|
|
87
108
|
return message
|
88
109
|
except httpx.HTTPStatusError as e:
|
89
110
|
error_body = e.response.text or "<empty response>"
|
90
|
-
message = f"HTTP {e.response.status_code}: {error_body}"
|
111
|
+
message = f"HTTP Error, status code: {e.response.status_code}, error body: {error_body}"
|
91
112
|
raise ToolError(message) from e
|
92
113
|
except ValueError as e:
|
93
114
|
message = f"Invalid arguments for tool {self.name}: {e}"
|
universal_mcp/types.py
ADDED
universal_mcp/utils/common.py
CHANGED
@@ -1,3 +1,38 @@
|
|
1
|
+
import hashlib
|
2
|
+
import importlib
|
3
|
+
import importlib.util
|
4
|
+
import inspect
|
5
|
+
import io
|
6
|
+
import os
|
7
|
+
import subprocess
|
8
|
+
import sys
|
9
|
+
import zipfile
|
10
|
+
from pathlib import Path
|
11
|
+
|
12
|
+
import httpx
|
13
|
+
from loguru import logger
|
14
|
+
|
15
|
+
from universal_mcp.applications.application import BaseApplication
|
16
|
+
from universal_mcp.config import AppConfig
|
17
|
+
|
18
|
+
# --- Global Constants and Setup ---
|
19
|
+
|
20
|
+
UNIVERSAL_MCP_HOME = Path.home() / ".universal-mcp" / "packages"
|
21
|
+
REMOTE_CACHE_DIR = UNIVERSAL_MCP_HOME / "remote_cache"
|
22
|
+
|
23
|
+
if not UNIVERSAL_MCP_HOME.exists():
|
24
|
+
UNIVERSAL_MCP_HOME.mkdir(parents=True, exist_ok=True)
|
25
|
+
if not REMOTE_CACHE_DIR.exists():
|
26
|
+
REMOTE_CACHE_DIR.mkdir(exist_ok=True)
|
27
|
+
|
28
|
+
# set python path to include the universal-mcp home directory
|
29
|
+
if str(UNIVERSAL_MCP_HOME) not in sys.path:
|
30
|
+
sys.path.append(str(UNIVERSAL_MCP_HOME))
|
31
|
+
|
32
|
+
|
33
|
+
# --- Default Name Generators ---
|
34
|
+
|
35
|
+
|
1
36
|
def get_default_repository_path(slug: str) -> str:
|
2
37
|
"""
|
3
38
|
Convert a repository slug to a repository URL.
|
@@ -31,3 +66,213 @@ def get_default_class_name(slug: str) -> str:
|
|
31
66
|
slug = slug.strip().lower()
|
32
67
|
class_name = "".join(part.capitalize() for part in slug.split("-")) + "App"
|
33
68
|
return class_name
|
69
|
+
|
70
|
+
|
71
|
+
# --- Installation and Loading Helpers ---
|
72
|
+
|
73
|
+
|
74
|
+
def install_or_upgrade_package(package_name: str, repository_path: str):
|
75
|
+
"""
|
76
|
+
Helper to install a package via pip from the universal-mcp GitHub repository.
|
77
|
+
"""
|
78
|
+
uv_path = os.getenv("UV_PATH")
|
79
|
+
uv_executable = str(Path(uv_path) / "uv") if uv_path else "uv"
|
80
|
+
logger.info(f"Using uv executable: {uv_executable}")
|
81
|
+
cmd = [
|
82
|
+
uv_executable,
|
83
|
+
"pip",
|
84
|
+
"install",
|
85
|
+
"--upgrade",
|
86
|
+
repository_path,
|
87
|
+
"--target",
|
88
|
+
str(UNIVERSAL_MCP_HOME),
|
89
|
+
]
|
90
|
+
logger.debug(f"Installing package '{package_name}' with command: {' '.join(cmd)}")
|
91
|
+
try:
|
92
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
93
|
+
if result.stdout:
|
94
|
+
logger.info(f"Command stdout: {result.stdout}")
|
95
|
+
if result.stderr:
|
96
|
+
logger.warning(f"Command stderr: {result.stderr}")
|
97
|
+
except subprocess.CalledProcessError as e:
|
98
|
+
logger.error(f"Installation failed for '{package_name}': {e}")
|
99
|
+
logger.error(f"Command stdout:\n{e.stdout}")
|
100
|
+
logger.error(f"Command stderr:\n{e.stderr}")
|
101
|
+
raise ModuleNotFoundError(f"Installation failed for package '{package_name}'") from e
|
102
|
+
else:
|
103
|
+
logger.debug(f"Package {package_name} installed successfully")
|
104
|
+
|
105
|
+
|
106
|
+
def install_dependencies_from_path(project_root: Path, target_install_dir: Path):
|
107
|
+
"""
|
108
|
+
Installs dependencies from pyproject.toml or requirements.txt found in project_root.
|
109
|
+
"""
|
110
|
+
uv_path = os.getenv("UV_PATH")
|
111
|
+
uv_executable = str(Path(uv_path) / "uv") if uv_path else "uv"
|
112
|
+
cmd = []
|
113
|
+
|
114
|
+
if (project_root / "pyproject.toml").exists():
|
115
|
+
logger.info(f"Found pyproject.toml in {project_root}, installing dependencies.")
|
116
|
+
cmd = [uv_executable, "pip", "install", ".", "--target", str(target_install_dir)]
|
117
|
+
elif (project_root / "requirements.txt").exists():
|
118
|
+
logger.info(f"Found requirements.txt in {project_root}, installing dependencies.")
|
119
|
+
cmd = [
|
120
|
+
uv_executable,
|
121
|
+
"pip",
|
122
|
+
"install",
|
123
|
+
"-r",
|
124
|
+
"requirements.txt",
|
125
|
+
"--target",
|
126
|
+
str(target_install_dir),
|
127
|
+
]
|
128
|
+
else:
|
129
|
+
logger.debug(f"No dependency file found in {project_root}. Skipping dependency installation.")
|
130
|
+
return
|
131
|
+
|
132
|
+
try:
|
133
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True, cwd=project_root)
|
134
|
+
if result.stdout:
|
135
|
+
logger.info(f"Dependency installation stdout:\n{result.stdout}")
|
136
|
+
if result.stderr:
|
137
|
+
logger.warning(f"Dependency installation stderr:\n{result.stderr}")
|
138
|
+
except subprocess.CalledProcessError as e:
|
139
|
+
logger.error(f"Dependency installation failed for project at '{project_root}': {e}")
|
140
|
+
logger.error(f"Command stdout:\n{e.stdout}")
|
141
|
+
logger.error(f"Command stderr:\n{e.stderr}")
|
142
|
+
raise RuntimeError(f"Failed to install dependencies for {project_root}") from e
|
143
|
+
|
144
|
+
|
145
|
+
def _load_class_from_project_root(project_root: Path, config: AppConfig) -> type[BaseApplication]:
|
146
|
+
"""Internal helper to load an application class from a local project directory."""
|
147
|
+
logger.debug(f"Attempting to load '{config.name}' from project root: {project_root}")
|
148
|
+
src_path = project_root / "src"
|
149
|
+
if not src_path.is_dir():
|
150
|
+
raise FileNotFoundError(f"Required 'src' directory not found in project at {project_root}")
|
151
|
+
|
152
|
+
install_dependencies_from_path(project_root, UNIVERSAL_MCP_HOME)
|
153
|
+
|
154
|
+
if str(src_path) not in sys.path:
|
155
|
+
sys.path.insert(0, str(src_path))
|
156
|
+
logger.debug(f"Added to sys.path: {src_path}")
|
157
|
+
|
158
|
+
module_path_str = get_default_module_path(config.name)
|
159
|
+
class_name_str = get_default_class_name(config.name)
|
160
|
+
|
161
|
+
try:
|
162
|
+
module = importlib.import_module(module_path_str)
|
163
|
+
importlib.reload(module) # Reload to pick up changes
|
164
|
+
app_class = getattr(module, class_name_str)
|
165
|
+
return app_class
|
166
|
+
except (ModuleNotFoundError, AttributeError) as e:
|
167
|
+
logger.error(f"Failed to load module/class '{module_path_str}.{class_name_str}': {e}")
|
168
|
+
raise
|
169
|
+
|
170
|
+
|
171
|
+
# --- Application Loaders ---
|
172
|
+
|
173
|
+
|
174
|
+
def load_app_from_package(config: AppConfig) -> type[BaseApplication]:
|
175
|
+
"""Loads an application from a pip-installable package."""
|
176
|
+
logger.debug(f"Loading '{config.name}' as a package.")
|
177
|
+
slug = config.name
|
178
|
+
repository_path = get_default_repository_path(slug)
|
179
|
+
package_name = get_default_package_name(slug)
|
180
|
+
install_or_upgrade_package(package_name, repository_path)
|
181
|
+
|
182
|
+
module_path_str = get_default_module_path(slug)
|
183
|
+
class_name_str = get_default_class_name(slug)
|
184
|
+
module = importlib.import_module(module_path_str)
|
185
|
+
return getattr(module, class_name_str)
|
186
|
+
|
187
|
+
|
188
|
+
def load_app_from_local_folder(config: AppConfig) -> type[BaseApplication]:
|
189
|
+
"""Loads an application from a local folder path."""
|
190
|
+
project_path = Path(config.source_path).resolve()
|
191
|
+
return _load_class_from_project_root(project_path, config)
|
192
|
+
|
193
|
+
|
194
|
+
def load_app_from_remote_zip(config: AppConfig) -> type[BaseApplication]:
|
195
|
+
"""Downloads, caches, and loads an application from a remote .zip file."""
|
196
|
+
url_hash = hashlib.sha256(config.source_path.encode()).hexdigest()[:16]
|
197
|
+
project_path = REMOTE_CACHE_DIR / f"{config.name}-{url_hash}"
|
198
|
+
|
199
|
+
if not project_path.exists():
|
200
|
+
logger.info(f"Downloading remote project for '{config.name}' from {config.source_path}")
|
201
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
202
|
+
response = httpx.get(config.source_path, follow_redirects=True, timeout=120)
|
203
|
+
response.raise_for_status()
|
204
|
+
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
|
205
|
+
z.extractall(project_path)
|
206
|
+
logger.info(f"Extracted remote project to {project_path}")
|
207
|
+
|
208
|
+
return _load_class_from_project_root(project_path, config)
|
209
|
+
|
210
|
+
|
211
|
+
def load_app_from_remote_file(config: AppConfig) -> type[BaseApplication]:
|
212
|
+
"""Downloads, caches, and loads an application from a remote Python file."""
|
213
|
+
logger.debug(f"Loading '{config.name}' as a remote file from {config.source_path}")
|
214
|
+
url_hash = hashlib.sha256(config.source_path.encode()).hexdigest()[:16]
|
215
|
+
cached_file_path = REMOTE_CACHE_DIR / f"{config.name}-{url_hash}.py"
|
216
|
+
|
217
|
+
if not cached_file_path.exists():
|
218
|
+
logger.info(f"Downloading remote file for '{config.name}' from {config.source_path}")
|
219
|
+
try:
|
220
|
+
response = httpx.get(config.source_path, follow_redirects=True, timeout=60)
|
221
|
+
response.raise_for_status()
|
222
|
+
cached_file_path.write_text(response.text, encoding="utf-8")
|
223
|
+
logger.info(f"Cached remote file to {cached_file_path}")
|
224
|
+
except httpx.HTTPStatusError as e:
|
225
|
+
logger.error(f"Failed to download remote file: {e.response.status_code} {e.response.reason_phrase}")
|
226
|
+
raise
|
227
|
+
except Exception as e:
|
228
|
+
logger.error(f"An unexpected error occurred during download: {e}")
|
229
|
+
raise
|
230
|
+
|
231
|
+
if not cached_file_path.stat().st_size > 0:
|
232
|
+
raise ImportError(f"Remote file at {cached_file_path} is empty.")
|
233
|
+
|
234
|
+
module_name = f"remote_app_{config.name}_{url_hash}"
|
235
|
+
spec = importlib.util.spec_from_file_location(module_name, cached_file_path)
|
236
|
+
if spec is None or spec.loader is None:
|
237
|
+
raise ImportError(f"Could not create module spec for {cached_file_path}")
|
238
|
+
|
239
|
+
module = importlib.util.module_from_spec(spec)
|
240
|
+
sys.modules[module_name] = module
|
241
|
+
spec.loader.exec_module(module)
|
242
|
+
|
243
|
+
for name, obj in inspect.getmembers(module, inspect.isclass):
|
244
|
+
if obj.__module__ == module_name and issubclass(obj, BaseApplication) and obj is not BaseApplication:
|
245
|
+
logger.debug(f"Found application class '{name}' defined in remote file for '{config.name}'.")
|
246
|
+
return obj
|
247
|
+
|
248
|
+
raise ImportError(f"No class inheriting from BaseApplication found in remote file {config.source_path}")
|
249
|
+
|
250
|
+
|
251
|
+
def load_app_from_local_file(config: AppConfig) -> type[BaseApplication]:
|
252
|
+
"""Loads an application from a local Python file."""
|
253
|
+
logger.debug(f"Loading '{config.name}' as a local file from {config.source_path}")
|
254
|
+
local_file_path = Path(config.source_path).resolve()
|
255
|
+
|
256
|
+
if not local_file_path.is_file():
|
257
|
+
raise FileNotFoundError(f"Local file not found at: {local_file_path}")
|
258
|
+
|
259
|
+
if not local_file_path.stat().st_size > 0:
|
260
|
+
raise ImportError(f"Local file at {local_file_path} is empty.")
|
261
|
+
|
262
|
+
path_hash = hashlib.sha256(str(local_file_path).encode()).hexdigest()[:16]
|
263
|
+
module_name = f"local_app_{config.name}_{path_hash}"
|
264
|
+
|
265
|
+
spec = importlib.util.spec_from_file_location(module_name, local_file_path)
|
266
|
+
if spec is None or spec.loader is None:
|
267
|
+
raise ImportError(f"Could not create module spec for {local_file_path}")
|
268
|
+
|
269
|
+
module = importlib.util.module_from_spec(spec)
|
270
|
+
sys.modules[module_name] = module
|
271
|
+
spec.loader.exec_module(module)
|
272
|
+
|
273
|
+
for name, obj in inspect.getmembers(module, inspect.isclass):
|
274
|
+
if obj.__module__ == module_name and issubclass(obj, BaseApplication) and obj is not BaseApplication:
|
275
|
+
logger.debug(f"Found application class '{name}' in local file for '{config.name}'.")
|
276
|
+
return obj
|
277
|
+
|
278
|
+
raise ImportError(f"No class inheriting from BaseApplication found in local file {config.source_path}")
|
@@ -2,6 +2,7 @@ import json
|
|
2
2
|
import shutil
|
3
3
|
import sys
|
4
4
|
from pathlib import Path
|
5
|
+
from typing import Any
|
5
6
|
|
6
7
|
from loguru import logger
|
7
8
|
from rich import print
|
@@ -27,7 +28,7 @@ def get_uvx_path() -> Path:
|
|
27
28
|
logger.error(
|
28
29
|
"uvx executable not found in PATH, falling back to 'uvx'. Please ensure uvx is installed and in your PATH"
|
29
30
|
)
|
30
|
-
return
|
31
|
+
return Path("uvx")
|
31
32
|
|
32
33
|
|
33
34
|
def _create_file_if_not_exists(path: Path) -> None:
|
@@ -38,10 +39,8 @@ def _create_file_if_not_exists(path: Path) -> None:
|
|
38
39
|
json.dump({}, f)
|
39
40
|
|
40
41
|
|
41
|
-
def _generate_mcp_config(api_key: str) ->
|
42
|
+
def _generate_mcp_config(api_key: str) -> dict[str, Any]:
|
42
43
|
uvx_path = get_uvx_path()
|
43
|
-
if not uvx_path:
|
44
|
-
raise ValueError("uvx executable not found in PATH")
|
45
44
|
return {
|
46
45
|
"command": str(uvx_path),
|
47
46
|
"args": ["universal_mcp@latest", "run"],
|