universal-mcp 0.1.15rc5__py3-none-any.whl → 0.1.16__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/analytics.py +7 -1
- universal_mcp/applications/README.md +122 -0
- universal_mcp/applications/__init__.py +51 -56
- universal_mcp/applications/application.py +255 -82
- universal_mcp/cli.py +27 -43
- universal_mcp/config.py +16 -48
- universal_mcp/exceptions.py +8 -0
- universal_mcp/integrations/__init__.py +1 -3
- universal_mcp/integrations/integration.py +18 -2
- universal_mcp/logger.py +31 -29
- universal_mcp/servers/server.py +6 -18
- universal_mcp/stores/store.py +2 -12
- universal_mcp/tools/__init__.py +12 -1
- universal_mcp/tools/adapters.py +11 -0
- universal_mcp/tools/func_metadata.py +11 -15
- universal_mcp/tools/manager.py +163 -117
- universal_mcp/tools/tools.py +6 -13
- universal_mcp/utils/agentr.py +2 -6
- universal_mcp/utils/common.py +33 -0
- universal_mcp/utils/docstring_parser.py +4 -13
- universal_mcp/utils/installation.py +67 -184
- universal_mcp/utils/openapi/__inti__.py +0 -0
- universal_mcp/utils/{api_generator.py → openapi/api_generator.py} +2 -4
- universal_mcp/utils/{docgen.py → openapi/docgen.py} +17 -54
- universal_mcp/utils/openapi/openapi.py +882 -0
- universal_mcp/utils/openapi/preprocessor.py +1093 -0
- universal_mcp/utils/{readme.py → openapi/readme.py} +21 -37
- universal_mcp-0.1.16.dist-info/METADATA +282 -0
- universal_mcp-0.1.16.dist-info/RECORD +44 -0
- universal_mcp-0.1.16.dist-info/licenses/LICENSE +21 -0
- universal_mcp/utils/openapi.py +0 -646
- universal_mcp-0.1.15rc5.dist-info/METADATA +0 -245
- universal_mcp-0.1.15rc5.dist-info/RECORD +0 -39
- /universal_mcp/{templates → utils/templates}/README.md.j2 +0 -0
- /universal_mcp/{templates → utils/templates}/api_client.py.j2 +0 -0
- {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.16.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.16.dist-info}/entry_points.txt +0 -0
universal_mcp/config.py
CHANGED
@@ -12,9 +12,7 @@ class StoreConfig(BaseModel):
|
|
12
12
|
type: Literal["memory", "environment", "keyring", "agentr"] = Field(
|
13
13
|
default="memory", description="Type of credential storage to use"
|
14
14
|
)
|
15
|
-
path: Path | None = Field(
|
16
|
-
default=None, description="Path to store credentials (if applicable)"
|
17
|
-
)
|
15
|
+
path: Path | None = Field(default=None, description="Path to store credentials (if applicable)")
|
18
16
|
|
19
17
|
|
20
18
|
class IntegrationConfig(BaseModel):
|
@@ -24,24 +22,16 @@ class IntegrationConfig(BaseModel):
|
|
24
22
|
type: Literal["api_key", "oauth", "agentr", "oauth2"] = Field(
|
25
23
|
default="api_key", description="Type of authentication to use"
|
26
24
|
)
|
27
|
-
credentials: dict[str, Any] | None = Field(
|
28
|
-
|
29
|
-
)
|
30
|
-
store: StoreConfig | None = Field(
|
31
|
-
default=None, description="Store configuration for credentials"
|
32
|
-
)
|
25
|
+
credentials: dict[str, Any] | None = Field(default=None, description="Integration-specific credentials")
|
26
|
+
store: StoreConfig | None = Field(default=None, description="Store configuration for credentials")
|
33
27
|
|
34
28
|
|
35
29
|
class AppConfig(BaseModel):
|
36
30
|
"""Configuration for individual applications."""
|
37
31
|
|
38
32
|
name: str = Field(..., description="Name of the application")
|
39
|
-
integration: IntegrationConfig | None = Field(
|
40
|
-
|
41
|
-
)
|
42
|
-
actions: list[str] | None = Field(
|
43
|
-
default=None, description="List of available actions"
|
44
|
-
)
|
33
|
+
integration: IntegrationConfig | None = Field(default=None, description="Integration configuration")
|
34
|
+
actions: list[str] | None = Field(default=None, description="List of available actions")
|
45
35
|
|
46
36
|
|
47
37
|
class ServerConfig(BaseSettings):
|
@@ -56,46 +46,24 @@ class ServerConfig(BaseSettings):
|
|
56
46
|
)
|
57
47
|
|
58
48
|
name: str = Field(default="Universal MCP", description="Name of the MCP server")
|
59
|
-
description: str = Field(
|
60
|
-
|
61
|
-
)
|
62
|
-
|
63
|
-
|
64
|
-
)
|
65
|
-
|
66
|
-
|
67
|
-
)
|
68
|
-
transport: Literal["stdio", "sse", "http"] = Field(
|
69
|
-
default="stdio", description="Transport protocol to use"
|
70
|
-
)
|
71
|
-
port: int = Field(
|
72
|
-
default=8005, description="Port to run the server on (if applicable)"
|
73
|
-
)
|
74
|
-
host: str = Field(
|
75
|
-
default="localhost", description="Host to bind the server to (if applicable)"
|
76
|
-
)
|
77
|
-
apps: list[AppConfig] | None = Field(
|
78
|
-
default=None, description="List of configured applications"
|
79
|
-
)
|
80
|
-
store: StoreConfig | None = Field(
|
81
|
-
default=None, description="Default store configuration"
|
82
|
-
)
|
49
|
+
description: str = Field(default="Universal MCP", description="Description of the MCP server")
|
50
|
+
api_key: SecretStr | None = Field(default=None, description="API key for authentication")
|
51
|
+
type: Literal["local", "agentr"] = Field(default="agentr", description="Type of server deployment")
|
52
|
+
transport: Literal["stdio", "sse", "http"] = Field(default="stdio", description="Transport protocol to use")
|
53
|
+
port: int = Field(default=8005, description="Port to run the server on (if applicable)")
|
54
|
+
host: str = Field(default="localhost", description="Host to bind the server to (if applicable)")
|
55
|
+
apps: list[AppConfig] | None = Field(default=None, description="List of configured applications")
|
56
|
+
store: StoreConfig | None = Field(default=None, description="Default store configuration")
|
83
57
|
debug: bool = Field(default=False, description="Enable debug mode")
|
84
58
|
log_level: str = Field(default="INFO", description="Logging level")
|
85
|
-
max_connections: int = Field(
|
86
|
-
|
87
|
-
)
|
88
|
-
request_timeout: int = Field(
|
89
|
-
default=60, description="Default request timeout in seconds"
|
90
|
-
)
|
59
|
+
max_connections: int = Field(default=100, description="Maximum number of concurrent connections")
|
60
|
+
request_timeout: int = Field(default=60, description="Default request timeout in seconds")
|
91
61
|
|
92
62
|
@field_validator("log_level", mode="before")
|
93
63
|
def validate_log_level(cls, v: str) -> str:
|
94
64
|
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
95
65
|
if v.upper() not in valid_levels:
|
96
|
-
raise ValueError(
|
97
|
-
f"Invalid log level. Must be one of: {', '.join(valid_levels)}"
|
98
|
-
)
|
66
|
+
raise ValueError(f"Invalid log level. Must be one of: {', '.join(valid_levels)}")
|
99
67
|
return v.upper()
|
100
68
|
|
101
69
|
@field_validator("port", mode="before")
|
universal_mcp/exceptions.py
CHANGED
@@ -11,3 +11,11 @@ class ToolError(Exception):
|
|
11
11
|
|
12
12
|
class InvalidSignature(Exception):
|
13
13
|
"""Raised when a signature is invalid."""
|
14
|
+
|
15
|
+
|
16
|
+
class StoreError(Exception):
|
17
|
+
"""Base exception class for store-related errors."""
|
18
|
+
|
19
|
+
|
20
|
+
class KeyNotFoundError(StoreError):
|
21
|
+
"""Exception raised when a key is not found in the store."""
|
@@ -8,9 +8,7 @@ from universal_mcp.integrations.integration import (
|
|
8
8
|
from universal_mcp.stores.store import BaseStore
|
9
9
|
|
10
10
|
|
11
|
-
def integration_from_config(
|
12
|
-
config: IntegrationConfig, store: BaseStore | None = None, **kwargs
|
13
|
-
) -> Integration:
|
11
|
+
def integration_from_config(config: IntegrationConfig, store: BaseStore | None = None, **kwargs) -> Integration:
|
14
12
|
if config.type == "api_key":
|
15
13
|
return ApiKeyIntegration(config.name, store=store, **kwargs)
|
16
14
|
elif config.type == "agentr":
|
@@ -6,7 +6,7 @@ from loguru import logger
|
|
6
6
|
|
7
7
|
from universal_mcp.exceptions import NotAuthorizedError
|
8
8
|
from universal_mcp.stores import BaseStore
|
9
|
-
from universal_mcp.stores.store import KeyNotFoundError
|
9
|
+
from universal_mcp.stores.store import KeyNotFoundError, MemoryStore
|
10
10
|
from universal_mcp.utils.agentr import AgentrClient
|
11
11
|
|
12
12
|
|
@@ -91,7 +91,7 @@ class ApiKeyIntegration(Integration):
|
|
91
91
|
store: Store instance for persisting credentials and other data
|
92
92
|
"""
|
93
93
|
|
94
|
-
def __init__(self, name: str, store: BaseStore
|
94
|
+
def __init__(self, name: str, store: BaseStore = MemoryStore(), **kwargs):
|
95
95
|
self.type = "api_key"
|
96
96
|
sanitized_name = sanitize_api_key_name(name)
|
97
97
|
super().__init__(sanitized_name, store, **kwargs)
|
@@ -109,6 +109,22 @@ class ApiKeyIntegration(Integration):
|
|
109
109
|
raise NotAuthorizedError(action) from e
|
110
110
|
return self._api_key
|
111
111
|
|
112
|
+
@api_key.setter
|
113
|
+
def api_key(self, value: str | None) -> None:
|
114
|
+
"""Set the API key.
|
115
|
+
|
116
|
+
Args:
|
117
|
+
value: The API key value to set.
|
118
|
+
|
119
|
+
Raises:
|
120
|
+
ValueError: If the API key is invalid.
|
121
|
+
"""
|
122
|
+
if value is not None and not isinstance(value, str):
|
123
|
+
raise ValueError("API key must be a string")
|
124
|
+
self._api_key = value
|
125
|
+
if value is not None:
|
126
|
+
self.store.set(self.name, value)
|
127
|
+
|
112
128
|
def get_credentials(self) -> dict[str, str]:
|
113
129
|
"""Get API key credentials.
|
114
130
|
|
universal_mcp/logger.py
CHANGED
@@ -5,6 +5,21 @@ from pathlib import Path
|
|
5
5
|
from loguru import logger
|
6
6
|
|
7
7
|
|
8
|
+
def get_log_file_path(app_name: str = "universal-mcp") -> Path:
|
9
|
+
"""Get a standardized log file path for an application.
|
10
|
+
|
11
|
+
Args:
|
12
|
+
app_name: Name of the application.
|
13
|
+
|
14
|
+
Returns:
|
15
|
+
Path to the log file in the format: logs/{app_name}/{app_name}_{date}.log
|
16
|
+
"""
|
17
|
+
date_str = datetime.now().strftime("%Y%m%d")
|
18
|
+
home = Path.home()
|
19
|
+
log_dir = home / ".universal-mcp" / "logs"
|
20
|
+
return log_dir / f"{app_name}_{date_str}.log"
|
21
|
+
|
22
|
+
|
8
23
|
def setup_logger(
|
9
24
|
log_file: Path | None = None,
|
10
25
|
rotation: str = "10 MB",
|
@@ -35,34 +50,21 @@ def setup_logger(
|
|
35
50
|
backtrace=True,
|
36
51
|
diagnose=True,
|
37
52
|
)
|
53
|
+
if not log_file:
|
54
|
+
log_file = get_log_file_path()
|
38
55
|
|
39
|
-
#
|
40
|
-
|
41
|
-
# Ensure log directory exists
|
42
|
-
log_file.parent.mkdir(parents=True, exist_ok=True)
|
43
|
-
|
44
|
-
logger.add(
|
45
|
-
sink=str(log_file),
|
46
|
-
rotation=rotation,
|
47
|
-
retention=retention,
|
48
|
-
compression=compression,
|
49
|
-
level=level,
|
50
|
-
format=format,
|
51
|
-
enqueue=True,
|
52
|
-
backtrace=True,
|
53
|
-
diagnose=True,
|
54
|
-
)
|
55
|
-
|
56
|
-
|
57
|
-
def get_log_file_path(app_name: str) -> Path:
|
58
|
-
"""Get a standardized log file path for an application.
|
56
|
+
# Ensure log directory exists
|
57
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
59
58
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
59
|
+
logger.add(
|
60
|
+
sink=str(log_file),
|
61
|
+
rotation=rotation,
|
62
|
+
retention=retention,
|
63
|
+
compression=compression,
|
64
|
+
level=level,
|
65
|
+
format=format,
|
66
|
+
enqueue=True,
|
67
|
+
backtrace=True,
|
68
|
+
diagnose=True,
|
69
|
+
)
|
70
|
+
logger.info(f"Logging to {log_file}")
|
universal_mcp/servers/server.py
CHANGED
@@ -28,9 +28,7 @@ class BaseServer(FastMCP, ABC):
|
|
28
28
|
|
29
29
|
def __init__(self, config: ServerConfig, **kwargs):
|
30
30
|
super().__init__(config.name, config.description, port=config.port, **kwargs)
|
31
|
-
logger.info(
|
32
|
-
f"Initializing server: {config.name} ({config.type}) with store: {config.store}"
|
33
|
-
)
|
31
|
+
logger.info(f"Initializing server: {config.name} ({config.type}) with store: {config.store}")
|
34
32
|
|
35
33
|
self.config = config # Store config at base level for consistency
|
36
34
|
self._tool_manager = ToolManager(warn_on_duplicate_tools=True)
|
@@ -67,19 +65,13 @@ class BaseServer(FastMCP, ABC):
|
|
67
65
|
"""
|
68
66
|
if isinstance(result, str):
|
69
67
|
return [TextContent(type="text", text=result)]
|
70
|
-
elif isinstance(result, list) and all(
|
71
|
-
isinstance(item, TextContent) for item in result
|
72
|
-
):
|
68
|
+
elif isinstance(result, list) and all(isinstance(item, TextContent) for item in result):
|
73
69
|
return result
|
74
70
|
else:
|
75
|
-
logger.warning(
|
76
|
-
f"Tool returned unexpected type: {type(result)}. Wrapping in TextContent."
|
77
|
-
)
|
71
|
+
logger.warning(f"Tool returned unexpected type: {type(result)}. Wrapping in TextContent.")
|
78
72
|
return [TextContent(type="text", text=str(result))]
|
79
73
|
|
80
|
-
async def call_tool(
|
81
|
-
self, name: str, arguments: dict[str, Any]
|
82
|
-
) -> list[TextContent]:
|
74
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
83
75
|
"""Call a tool with comprehensive error handling.
|
84
76
|
|
85
77
|
Args:
|
@@ -139,9 +131,7 @@ class LocalServer(BaseServer):
|
|
139
131
|
"""
|
140
132
|
try:
|
141
133
|
integration = (
|
142
|
-
integration_from_config(app_config.integration, store=self.store)
|
143
|
-
if app_config.integration
|
144
|
-
else None
|
134
|
+
integration_from_config(app_config.integration, store=self.store) if app_config.integration else None
|
145
135
|
)
|
146
136
|
return app_from_slug(app_config.name)(integration=integration)
|
147
137
|
except Exception as e:
|
@@ -200,9 +190,7 @@ class AgentRServer(BaseServer):
|
|
200
190
|
"""
|
201
191
|
try:
|
202
192
|
integration = (
|
203
|
-
AgentRIntegration(
|
204
|
-
name=app_config.integration.name, api_key=self.client.api_key
|
205
|
-
)
|
193
|
+
AgentRIntegration(name=app_config.integration.name, api_key=self.client.api_key)
|
206
194
|
if app_config.integration
|
207
195
|
else None
|
208
196
|
)
|
universal_mcp/stores/store.py
CHANGED
@@ -5,17 +5,7 @@ from typing import Any
|
|
5
5
|
import keyring
|
6
6
|
from loguru import logger
|
7
7
|
|
8
|
-
|
9
|
-
class StoreError(Exception):
|
10
|
-
"""Base exception class for store-related errors."""
|
11
|
-
|
12
|
-
pass
|
13
|
-
|
14
|
-
|
15
|
-
class KeyNotFoundError(StoreError):
|
16
|
-
"""Exception raised when a key is not found in the store."""
|
17
|
-
|
18
|
-
pass
|
8
|
+
from universal_mcp.exceptions import KeyNotFoundError, StoreError
|
19
9
|
|
20
10
|
|
21
11
|
class BaseStore(ABC):
|
@@ -84,7 +74,7 @@ class MemoryStore(BaseStore):
|
|
84
74
|
|
85
75
|
def __init__(self):
|
86
76
|
"""Initialize an empty dictionary to store the data."""
|
87
|
-
self.data: dict[str,
|
77
|
+
self.data: dict[str, Any] = {}
|
88
78
|
|
89
79
|
def get(self, key: str) -> Any:
|
90
80
|
"""
|
universal_mcp/tools/__init__.py
CHANGED
@@ -1,4 +1,15 @@
|
|
1
|
+
from .adapters import (
|
2
|
+
convert_tool_to_langchain_tool,
|
3
|
+
convert_tool_to_mcp_tool,
|
4
|
+
convert_tool_to_openai_tool,
|
5
|
+
)
|
1
6
|
from .manager import ToolManager
|
2
7
|
from .tools import Tool
|
3
8
|
|
4
|
-
__all__ = [
|
9
|
+
__all__ = [
|
10
|
+
"Tool",
|
11
|
+
"ToolManager",
|
12
|
+
"convert_tool_to_langchain_tool",
|
13
|
+
"convert_tool_to_openai_tool",
|
14
|
+
"convert_tool_to_mcp_tool",
|
15
|
+
]
|
universal_mcp/tools/adapters.py
CHANGED
@@ -1,6 +1,16 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
1
3
|
from universal_mcp.tools.tools import Tool
|
2
4
|
|
3
5
|
|
6
|
+
class ToolFormat(str, Enum):
|
7
|
+
"""Supported tool formats."""
|
8
|
+
|
9
|
+
MCP = "mcp"
|
10
|
+
LANGCHAIN = "langchain"
|
11
|
+
OPENAI = "openai"
|
12
|
+
|
13
|
+
|
4
14
|
def convert_tool_to_mcp_tool(
|
5
15
|
tool: Tool,
|
6
16
|
):
|
@@ -40,6 +50,7 @@ def convert_tool_to_langchain_tool(
|
|
40
50
|
description=tool.description or "",
|
41
51
|
coroutine=call_tool,
|
42
52
|
response_format="content",
|
53
|
+
args_schema=tool.parameters,
|
43
54
|
)
|
44
55
|
|
45
56
|
|
@@ -15,9 +15,7 @@ from pydantic_core import PydanticUndefined
|
|
15
15
|
|
16
16
|
|
17
17
|
def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
|
18
|
-
def try_eval_type(
|
19
|
-
value: Any, globalns: dict[str, Any], localns: dict[str, Any]
|
20
|
-
) -> tuple[Any, bool]:
|
18
|
+
def try_eval_type(value: Any, globalns: dict[str, Any], localns: dict[str, Any]) -> tuple[Any, bool]:
|
21
19
|
try:
|
22
20
|
return eval_type_backport(value, globalns, localns), True
|
23
21
|
except NameError:
|
@@ -82,6 +80,7 @@ class FuncMetadata(BaseModel):
|
|
82
80
|
fn_is_async: bool,
|
83
81
|
arguments_to_validate: dict[str, Any],
|
84
82
|
arguments_to_pass_directly: dict[str, Any] | None,
|
83
|
+
context: dict[str, Any] | None = None,
|
85
84
|
) -> Any:
|
86
85
|
"""Call the given function with arguments validated and injected.
|
87
86
|
|
@@ -137,7 +136,10 @@ class FuncMetadata(BaseModel):
|
|
137
136
|
|
138
137
|
@classmethod
|
139
138
|
def func_metadata(
|
140
|
-
cls,
|
139
|
+
cls,
|
140
|
+
func: Callable[..., Any],
|
141
|
+
skip_names: Sequence[str] = (),
|
142
|
+
arg_description: dict[str, str] | None = None,
|
141
143
|
) -> "FuncMetadata":
|
142
144
|
"""Given a function, return metadata including a pydantic model representing its
|
143
145
|
signature.
|
@@ -165,9 +167,7 @@ class FuncMetadata(BaseModel):
|
|
165
167
|
globalns = getattr(func, "__globals__", {})
|
166
168
|
for param in params.values():
|
167
169
|
if param.name.startswith("_"):
|
168
|
-
raise InvalidSignature(
|
169
|
-
f"Parameter {param.name} of {func.__name__} cannot start with '_'"
|
170
|
-
)
|
170
|
+
raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'")
|
171
171
|
if param.name in skip_names:
|
172
172
|
continue
|
173
173
|
annotation = param.annotation
|
@@ -176,11 +176,7 @@ class FuncMetadata(BaseModel):
|
|
176
176
|
if annotation is None:
|
177
177
|
annotation = Annotated[
|
178
178
|
None,
|
179
|
-
Field(
|
180
|
-
default=param.default
|
181
|
-
if param.default is not inspect.Parameter.empty
|
182
|
-
else PydanticUndefined
|
183
|
-
),
|
179
|
+
Field(default=param.default if param.default is not inspect.Parameter.empty else PydanticUndefined),
|
184
180
|
]
|
185
181
|
|
186
182
|
# Untyped field
|
@@ -194,10 +190,10 @@ class FuncMetadata(BaseModel):
|
|
194
190
|
|
195
191
|
field_info = FieldInfo.from_annotated_attribute(
|
196
192
|
_get_typed_annotation(annotation, globalns),
|
197
|
-
param.default
|
198
|
-
if param.default is not inspect.Parameter.empty
|
199
|
-
else PydanticUndefined,
|
193
|
+
param.default if param.default is not inspect.Parameter.empty else PydanticUndefined,
|
200
194
|
)
|
195
|
+
if not field_info.title and arg_description and arg_description.get(param.name):
|
196
|
+
field_info.title = arg_description.get(param.name)
|
201
197
|
dynamic_pydantic_model_params[param.name] = (
|
202
198
|
field_info.annotation,
|
203
199
|
field_info,
|