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.
Files changed (69) hide show
  1. universal_mcp/agentr/__init__.py +6 -0
  2. universal_mcp/agentr/agentr.py +30 -0
  3. universal_mcp/{utils/agentr.py → agentr/client.py} +22 -7
  4. universal_mcp/agentr/integration.py +104 -0
  5. universal_mcp/agentr/registry.py +91 -0
  6. universal_mcp/agentr/server.py +51 -0
  7. universal_mcp/agents/__init__.py +6 -0
  8. universal_mcp/agents/auto.py +576 -0
  9. universal_mcp/agents/base.py +88 -0
  10. universal_mcp/agents/cli.py +27 -0
  11. universal_mcp/agents/codeact/__init__.py +243 -0
  12. universal_mcp/agents/codeact/sandbox.py +27 -0
  13. universal_mcp/agents/codeact/test.py +15 -0
  14. universal_mcp/agents/codeact/utils.py +61 -0
  15. universal_mcp/agents/hil.py +104 -0
  16. universal_mcp/agents/llm.py +10 -0
  17. universal_mcp/agents/react.py +58 -0
  18. universal_mcp/agents/simple.py +40 -0
  19. universal_mcp/agents/utils.py +111 -0
  20. universal_mcp/analytics.py +44 -14
  21. universal_mcp/applications/__init__.py +42 -75
  22. universal_mcp/applications/application.py +187 -133
  23. universal_mcp/applications/sample/app.py +245 -0
  24. universal_mcp/cli.py +14 -231
  25. universal_mcp/client/oauth.py +122 -18
  26. universal_mcp/client/token_store.py +62 -3
  27. universal_mcp/client/{client.py → transport.py} +127 -48
  28. universal_mcp/config.py +189 -49
  29. universal_mcp/exceptions.py +54 -6
  30. universal_mcp/integrations/__init__.py +0 -18
  31. universal_mcp/integrations/integration.py +185 -168
  32. universal_mcp/servers/__init__.py +2 -14
  33. universal_mcp/servers/server.py +84 -258
  34. universal_mcp/stores/store.py +126 -93
  35. universal_mcp/tools/__init__.py +3 -0
  36. universal_mcp/tools/adapters.py +20 -11
  37. universal_mcp/tools/func_metadata.py +1 -1
  38. universal_mcp/tools/manager.py +38 -53
  39. universal_mcp/tools/registry.py +41 -0
  40. universal_mcp/tools/tools.py +24 -3
  41. universal_mcp/types.py +10 -0
  42. universal_mcp/utils/common.py +245 -0
  43. universal_mcp/utils/installation.py +3 -4
  44. universal_mcp/utils/openapi/api_generator.py +71 -17
  45. universal_mcp/utils/openapi/api_splitter.py +0 -1
  46. universal_mcp/utils/openapi/cli.py +669 -0
  47. universal_mcp/utils/openapi/filters.py +114 -0
  48. universal_mcp/utils/openapi/openapi.py +315 -23
  49. universal_mcp/utils/openapi/postprocessor.py +275 -0
  50. universal_mcp/utils/openapi/preprocessor.py +63 -8
  51. universal_mcp/utils/openapi/test_generator.py +287 -0
  52. universal_mcp/utils/prompts.py +634 -0
  53. universal_mcp/utils/singleton.py +4 -1
  54. universal_mcp/utils/testing.py +196 -8
  55. universal_mcp-0.1.24rc3.dist-info/METADATA +68 -0
  56. universal_mcp-0.1.24rc3.dist-info/RECORD +70 -0
  57. universal_mcp/applications/README.md +0 -122
  58. universal_mcp/client/__main__.py +0 -30
  59. universal_mcp/client/agent.py +0 -96
  60. universal_mcp/integrations/README.md +0 -25
  61. universal_mcp/servers/README.md +0 -79
  62. universal_mcp/stores/README.md +0 -74
  63. universal_mcp/tools/README.md +0 -86
  64. universal_mcp-0.1.23rc2.dist-info/METADATA +0 -283
  65. universal_mcp-0.1.23rc2.dist-info/RECORD +0 -51
  66. /universal_mcp/{utils → tools}/docstring_parser.py +0 -0
  67. {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/WHEEL +0 -0
  68. {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/entry_points.txt +0 -0
  69. {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/licenses/LICENSE +0 -0
@@ -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 ToolError
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
- """Manages FastMCP tools.
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 = ToolFormat.MCP,
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.get_tools_by_app(app_name)
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} to app: {app_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], app_name: str = DEFAULT_APP_NAME) -> None:
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
- self.add_tool(tool, app_name=app_name)
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, app_name=app.name)
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.split(TOOL_NAME_SEPARATOR, 1)[0] if TOOL_NAME_SEPARATOR in name else DEFAULT_APP_NAME
301
+ app_name, _ = _get_app_and_tool_name(name)
318
302
  tool = self.get_tool(name)
319
303
  if not tool:
320
- raise ToolError(f"Unknown tool: {name}")
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 ToolError(f"Tool execution failed: {str(e)}") from e
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
@@ -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.utils.docstring_parser import parse_docstring
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
@@ -0,0 +1,10 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ToolFormat(str, Enum):
5
+ """Supported tool formats."""
6
+
7
+ NATIVE = "native"
8
+ MCP = "mcp"
9
+ LANGCHAIN = "langchain"
10
+ OPENAI = "openai"
@@ -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 None # Fall back to just "uvx" if not found
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) -> None:
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"],