universal-mcp 0.1.23rc2__py3-none-any.whl → 0.1.24rc2__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 +43 -11
- universal_mcp/applications/application.py +186 -132
- universal_mcp/applications/sample_tool_app.py +80 -0
- universal_mcp/cli.py +5 -229
- universal_mcp/client/agents/__init__.py +4 -0
- universal_mcp/client/agents/base.py +38 -0
- universal_mcp/client/agents/llm.py +115 -0
- universal_mcp/client/agents/react.py +67 -0
- universal_mcp/client/cli.py +181 -0
- 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 +160 -46
- universal_mcp/exceptions.py +50 -6
- universal_mcp/integrations/__init__.py +1 -4
- universal_mcp/integrations/integration.py +220 -121
- universal_mcp/servers/__init__.py +1 -1
- universal_mcp/servers/server.py +114 -247
- universal_mcp/stores/store.py +126 -93
- universal_mcp/tools/func_metadata.py +1 -1
- universal_mcp/tools/manager.py +15 -3
- universal_mcp/tools/tools.py +2 -2
- universal_mcp/utils/agentr.py +3 -4
- universal_mcp/utils/installation.py +3 -4
- universal_mcp/utils/openapi/api_generator.py +28 -2
- universal_mcp/utils/openapi/api_splitter.py +0 -1
- universal_mcp/utils/openapi/cli.py +243 -0
- universal_mcp/utils/openapi/filters.py +114 -0
- universal_mcp/utils/openapi/openapi.py +31 -2
- universal_mcp/utils/openapi/preprocessor.py +62 -7
- universal_mcp/utils/prompts.py +787 -0
- universal_mcp/utils/singleton.py +4 -1
- universal_mcp/utils/testing.py +6 -6
- universal_mcp-0.1.24rc2.dist-info/METADATA +54 -0
- universal_mcp-0.1.24rc2.dist-info/RECORD +53 -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.24rc2.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc2.dist-info}/entry_points.txt +0 -0
- {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc2.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,7 @@
|
|
1
1
|
import os
|
2
2
|
import webbrowser
|
3
3
|
from contextlib import AsyncExitStack
|
4
|
-
from typing import Any, Literal
|
4
|
+
from typing import Any, Literal, Self
|
5
5
|
|
6
6
|
from loguru import logger
|
7
7
|
from mcp import ClientSession, StdioServerParameters
|
@@ -9,7 +9,6 @@ from mcp.client.auth import OAuthClientProvider
|
|
9
9
|
from mcp.client.sse import sse_client
|
10
10
|
from mcp.client.stdio import stdio_client
|
11
11
|
from mcp.client.streamable_http import streamablehttp_client
|
12
|
-
from mcp.server import Server
|
13
12
|
from mcp.shared.auth import OAuthClientMetadata
|
14
13
|
from mcp.types import (
|
15
14
|
CallToolResult as MCPCallToolResult,
|
@@ -21,13 +20,20 @@ from openai.types.chat import ChatCompletionToolParam
|
|
21
20
|
|
22
21
|
from universal_mcp.client.oauth import CallbackServer
|
23
22
|
from universal_mcp.client.token_store import TokenStore
|
24
|
-
from universal_mcp.config import ClientTransportConfig
|
23
|
+
from universal_mcp.config import ClientConfig, ClientTransportConfig
|
25
24
|
from universal_mcp.stores.store import KeyringStore
|
26
25
|
from universal_mcp.tools.adapters import transform_mcp_tool_to_openai_tool
|
27
26
|
|
28
27
|
|
29
|
-
class
|
30
|
-
"""
|
28
|
+
class ClientTransport:
|
29
|
+
"""
|
30
|
+
Client for connecting to and interacting with a single MCP server.
|
31
|
+
|
32
|
+
Manages the lifecycle of a connection to an MCP server, handles various
|
33
|
+
transport mechanisms (stdio, sse, streamable_http), and facilitates
|
34
|
+
authentication, including OAuth 2.0 client flows. Allows listing tools
|
35
|
+
available on the server and calling them.
|
36
|
+
"""
|
31
37
|
|
32
38
|
def __init__(self, name: str, config: ClientTransportConfig) -> None:
|
33
39
|
self.name: str = name
|
@@ -35,14 +41,12 @@ class MCPClient:
|
|
35
41
|
self.session: ClientSession | None = None
|
36
42
|
self.server_url: str = config.url
|
37
43
|
|
38
|
-
#
|
39
|
-
self.
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
self.store = KeyringStore(self.name)
|
45
|
-
self.auth = OAuthClientProvider(
|
44
|
+
# Create OAuth authentication handler if needed
|
45
|
+
if self.server_url and not getattr(self.config, "headers", None):
|
46
|
+
# Set up callback server
|
47
|
+
self._callback_server = CallbackServer(port=3000)
|
48
|
+
self.store: KeyringStore | None = KeyringStore(self.name)
|
49
|
+
self.auth: OAuthClientProvider | None = OAuthClientProvider(
|
46
50
|
server_url="/".join(self.server_url.split("/")[:-1]),
|
47
51
|
client_metadata=OAuthClientMetadata.model_validate(self.client_metadata_dict),
|
48
52
|
storage=TokenStore(self.store),
|
@@ -50,10 +54,18 @@ class MCPClient:
|
|
50
54
|
callback_handler=self._callback_handler,
|
51
55
|
)
|
52
56
|
else:
|
57
|
+
self._callback_server = None
|
58
|
+
self.store = None
|
53
59
|
self.auth = None
|
54
60
|
|
61
|
+
@property
|
62
|
+
def callback_server(self) -> CallbackServer:
|
63
|
+
if self._callback_server and not self._callback_server.is_running:
|
64
|
+
self._callback_server.start()
|
65
|
+
return self._callback_server
|
66
|
+
|
55
67
|
async def _callback_handler(self) -> tuple[str, str | None]:
|
56
|
-
"""
|
68
|
+
"""Handles the OAuth callback by waiting for and returning auth details."""
|
57
69
|
print("⏳ Waiting for authorization callback...")
|
58
70
|
try:
|
59
71
|
auth_code = self.callback_server.wait_for_callback(timeout=300)
|
@@ -63,38 +75,45 @@ class MCPClient:
|
|
63
75
|
|
64
76
|
@property
|
65
77
|
def client_metadata_dict(self) -> dict[str, Any]:
|
78
|
+
"""Provides OAuth 2.0 client metadata for registration or authentication."""
|
66
79
|
return {
|
67
|
-
"client_name":
|
68
|
-
"redirect_uris": [
|
80
|
+
"client_name": self.name,
|
81
|
+
"redirect_uris": [self.callback_server.redirect_uri], # type: ignore
|
69
82
|
"grant_types": ["authorization_code", "refresh_token"],
|
70
83
|
"response_types": ["code"],
|
71
84
|
"token_endpoint_auth_method": "client_secret_post",
|
72
85
|
}
|
73
86
|
|
74
87
|
async def _default_redirect_handler(self, authorization_url: str) -> None:
|
75
|
-
"""Default
|
88
|
+
"""Default handler for OAuth redirects; opens URL in a web browser."""
|
76
89
|
print(f"Opening browser for authorization: {authorization_url}")
|
77
90
|
webbrowser.open(authorization_url)
|
78
91
|
|
79
|
-
async def initialize(self, exit_stack: AsyncExitStack):
|
80
|
-
"""
|
81
|
-
|
92
|
+
async def initialize(self, exit_stack: AsyncExitStack) -> None:
|
93
|
+
"""
|
94
|
+
Establishes and initializes the connection to the MCP server.
|
95
|
+
|
96
|
+
Raises:
|
97
|
+
ValueError: If the transport type is unknown or if required
|
98
|
+
configuration for a transport is missing.
|
99
|
+
"""
|
100
|
+
transport = getattr(self.config, "transport", None)
|
101
|
+
session = None
|
82
102
|
try:
|
83
103
|
if transport == "stdio":
|
84
|
-
command = self.config
|
85
|
-
if command
|
104
|
+
command = self.config.get("command")
|
105
|
+
if not command:
|
86
106
|
raise ValueError("The command must be a valid string and cannot be None.")
|
87
107
|
|
88
108
|
server_params = StdioServerParameters(
|
89
109
|
command=command,
|
90
|
-
args=self.config
|
91
|
-
env={**os.environ, **self.config
|
110
|
+
args=self.config.get("args", []),
|
111
|
+
env={**os.environ, **self.config.get("env", {})} if self.config.get("env") else None,
|
92
112
|
)
|
93
113
|
stdio_transport = await exit_stack.enter_async_context(stdio_client(server_params))
|
94
114
|
read, write = stdio_transport
|
95
115
|
session = await exit_stack.enter_async_context(ClientSession(read, write))
|
96
116
|
await session.initialize()
|
97
|
-
self.session = session
|
98
117
|
elif transport == "streamable_http":
|
99
118
|
url = self.config.get("url")
|
100
119
|
headers = self.config.get("headers", {})
|
@@ -106,10 +125,9 @@ class MCPClient:
|
|
106
125
|
read, write, _ = streamable_http_transport
|
107
126
|
session = await exit_stack.enter_async_context(ClientSession(read, write))
|
108
127
|
await session.initialize()
|
109
|
-
self.session = session
|
110
128
|
elif transport == "sse":
|
111
|
-
url = self.config.url
|
112
|
-
headers = self.config.headers
|
129
|
+
url = self.config.get("url")
|
130
|
+
headers = self.config.get("headers", {})
|
113
131
|
if not url:
|
114
132
|
raise ValueError("'url' must be provided for sse transport.")
|
115
133
|
sse_transport = await exit_stack.enter_async_context(
|
@@ -118,73 +136,126 @@ class MCPClient:
|
|
118
136
|
read, write = sse_transport
|
119
137
|
session = await exit_stack.enter_async_context(ClientSession(read, write))
|
120
138
|
await session.initialize()
|
121
|
-
self.session = session
|
122
139
|
else:
|
123
140
|
raise ValueError(f"Unknown transport: {transport}")
|
141
|
+
self.session = session
|
124
142
|
except Exception as e:
|
143
|
+
if session:
|
144
|
+
await session.aclose()
|
125
145
|
logger.error(f"Error initializing server {self.name}: {e}")
|
126
146
|
raise
|
127
147
|
|
128
148
|
async def list_tools(self) -> list[MCPTool]:
|
129
|
-
"""
|
149
|
+
"""Lists all tools available on the connected MCP server."""
|
130
150
|
if self.session:
|
131
|
-
|
132
|
-
|
151
|
+
try:
|
152
|
+
tools = await self.session.list_tools()
|
153
|
+
return list(tools.tools)
|
154
|
+
except Exception as e:
|
155
|
+
logger.warning(f"Failed to list tools for client {self.name}: {e}")
|
133
156
|
return []
|
134
157
|
|
135
158
|
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> MCPCallToolResult:
|
136
|
-
"""
|
159
|
+
"""Calls a specified tool on the connected MCP server with given arguments."""
|
137
160
|
if self.session:
|
138
|
-
|
161
|
+
try:
|
162
|
+
return await self.session.call_tool(tool_name, arguments)
|
163
|
+
except Exception as e:
|
164
|
+
logger.error(f"Error calling tool '{tool_name}' on client {self.name}: {e}")
|
139
165
|
return MCPCallToolResult(
|
140
166
|
content=[],
|
141
167
|
isError=True,
|
142
168
|
)
|
143
169
|
|
144
170
|
|
145
|
-
class
|
171
|
+
class MultiClientTransport:
|
146
172
|
"""
|
147
|
-
|
173
|
+
Aggregates multiple ClientTransport instances to act as a single MCP Server.
|
174
|
+
|
175
|
+
Provides a unified Server interface for a collection of ClientTransport
|
176
|
+
instances, each potentially connected to a different MCP server.
|
177
|
+
Maintains a mapping of tool names to the specific ClientTransport that
|
178
|
+
provides that tool.
|
148
179
|
"""
|
149
180
|
|
150
181
|
def __init__(self, clients: dict[str, ClientTransportConfig]):
|
151
|
-
self.clients: list[
|
152
|
-
self.tool_to_client: dict[str,
|
182
|
+
self.clients: list[ClientTransport] = [ClientTransport(name, config) for name, config in clients.items()]
|
183
|
+
self.tool_to_client: dict[str, ClientTransport] = {}
|
153
184
|
self._mcp_tools: list[MCPTool] = []
|
154
185
|
self._exit_stack: AsyncExitStack = AsyncExitStack()
|
155
186
|
|
187
|
+
@classmethod
|
188
|
+
def from_file(cls, path: str) -> Self:
|
189
|
+
mcp_config = ClientConfig.load_json_config(path)
|
190
|
+
return cls(mcp_config.mcpServers)
|
191
|
+
|
192
|
+
def save_to_file(self, path: str) -> None:
|
193
|
+
mcp_config = ClientConfig(mcpServers={name: config.model_dump() for name, config in self.clients.items()})
|
194
|
+
mcp_config.save_json_config(path)
|
195
|
+
|
196
|
+
async def add_client(self, name: str, config: ClientTransportConfig) -> None:
|
197
|
+
if name in self.tool_to_client:
|
198
|
+
logger.warning(f"Client {name} already exists. Skipping.")
|
199
|
+
return
|
200
|
+
self.clients.append(ClientTransport(name, config))
|
201
|
+
self.tool_to_client[name] = self.clients[-1]
|
202
|
+
logger.info(f"Added client: {name}")
|
203
|
+
await self._populate_tool_mapping()
|
204
|
+
|
205
|
+
async def remove_client(self, name: str) -> None:
|
206
|
+
if name not in self.tool_to_client:
|
207
|
+
logger.warning(f"Client {name} not found. Skipping.")
|
208
|
+
return
|
209
|
+
self.clients.remove(self.tool_to_client[name])
|
210
|
+
del self.tool_to_client[name]
|
211
|
+
logger.info(f"Removed client: {name}")
|
212
|
+
await self._populate_tool_mapping()
|
213
|
+
|
156
214
|
async def __aenter__(self):
|
157
|
-
"""Initialize the server connection."""
|
158
215
|
for client in self.clients:
|
159
216
|
await client.initialize(self._exit_stack)
|
160
217
|
await self._populate_tool_mapping()
|
161
218
|
return self
|
162
219
|
|
163
220
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
164
|
-
"""Clean up the server connection."""
|
165
221
|
self.clients.clear()
|
166
222
|
self.tool_to_client.clear()
|
167
223
|
self._mcp_tools.clear()
|
168
224
|
await self._exit_stack.aclose()
|
169
225
|
|
170
226
|
async def _populate_tool_mapping(self):
|
171
|
-
"""Populate the mapping from tool name to server."""
|
172
227
|
self.tool_to_client.clear()
|
173
228
|
self._mcp_tools.clear()
|
174
229
|
for client in self.clients:
|
175
230
|
try:
|
176
231
|
tools = await client.list_tools()
|
177
232
|
for tool in tools:
|
178
|
-
|
179
|
-
tool_name = tool.name
|
180
|
-
logger.info(f"Found tool: {tool_name} from client: {client.name}")
|
233
|
+
tool_name = getattr(tool, "name", None)
|
181
234
|
if tool_name:
|
182
|
-
self.tool_to_client
|
235
|
+
if tool_name not in self.tool_to_client:
|
236
|
+
self._mcp_tools.append(tool)
|
237
|
+
self.tool_to_client[tool_name] = client
|
238
|
+
logger.info(f"Found tool: {tool_name} from client: {client.name}")
|
239
|
+
else:
|
240
|
+
logger.warning(
|
241
|
+
f"Duplicate tool name '{tool_name}' found in client '{client.name}'. Skipping."
|
242
|
+
)
|
183
243
|
except Exception as e:
|
184
244
|
logger.warning(f"Failed to list tools for client {client.name}: {e}")
|
185
245
|
|
186
246
|
async def list_tools(self, format: Literal["mcp", "openai"] = "mcp") -> list[MCPTool | ChatCompletionToolParam]:
|
187
|
-
"""
|
247
|
+
"""
|
248
|
+
Lists all unique tools available from all managed clients.
|
249
|
+
|
250
|
+
Args:
|
251
|
+
format: The desired format for the returned tools.
|
252
|
+
|
253
|
+
Returns:
|
254
|
+
List of tools in the specified format.
|
255
|
+
|
256
|
+
Raises:
|
257
|
+
ValueError: If an unsupported format is requested.
|
258
|
+
"""
|
188
259
|
if format == "mcp":
|
189
260
|
return self._mcp_tools
|
190
261
|
elif format == "openai":
|
@@ -193,6 +264,14 @@ class MultiClientServer(Server):
|
|
193
264
|
raise ValueError(f"Invalid format: {format}")
|
194
265
|
|
195
266
|
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> MCPCallToolResult:
|
196
|
-
"""
|
197
|
-
|
267
|
+
"""
|
268
|
+
Calls a tool by routing the request to the appropriate ClientTransport.
|
269
|
+
|
270
|
+
Raises:
|
271
|
+
KeyError: If the tool_name is not found.
|
272
|
+
"""
|
273
|
+
client = self.tool_to_client.get(tool_name)
|
274
|
+
if not client:
|
275
|
+
logger.error(f"Tool '{tool_name}' not found in any client.")
|
276
|
+
return MCPCallToolResult(content=[], isError=True)
|
198
277
|
return await client.call_tool(tool_name, arguments)
|
universal_mcp/config.py
CHANGED
@@ -7,38 +7,83 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
7
|
|
8
8
|
|
9
9
|
class StoreConfig(BaseModel):
|
10
|
-
"""
|
10
|
+
"""Specifies the configuration for a credential or token store.
|
11
11
|
|
12
|
-
|
12
|
+
Defines where and how sensitive information like API keys or OAuth tokens
|
13
|
+
should be stored and retrieved.
|
14
|
+
"""
|
15
|
+
|
16
|
+
name: str = Field(
|
17
|
+
default="universal_mcp",
|
18
|
+
description="Name of the store service or context (e.g., 'my_app_tokens', 'global_api_keys').",
|
19
|
+
)
|
13
20
|
type: Literal["memory", "environment", "keyring", "agentr"] = Field(
|
14
|
-
default="memory",
|
21
|
+
default="memory",
|
22
|
+
description="The type of storage backend to use. 'memory' is transient, 'environment' uses environment variables, 'keyring' uses the system's secure credential manager, 'agentr' delegates to AgentR platform storage.",
|
23
|
+
)
|
24
|
+
path: Path | None = Field(
|
25
|
+
default=None,
|
26
|
+
description="Filesystem path for store types that require it (e.g., a future 'file' store type). Currently not used by memory, environment, or keyring.",
|
15
27
|
)
|
16
|
-
path: Path | None = Field(default=None, description="Path to store credentials (if applicable)")
|
17
28
|
|
18
29
|
|
19
30
|
class IntegrationConfig(BaseModel):
|
20
|
-
"""
|
31
|
+
"""Defines the authentication and credential management for an application.
|
21
32
|
|
22
|
-
|
33
|
+
Specifies how a particular application (`AppConfig`) should authenticate
|
34
|
+
with its target service, including the authentication type (e.g., API key,
|
35
|
+
OAuth) and where to find the necessary credentials.
|
36
|
+
"""
|
37
|
+
|
38
|
+
name: str = Field(
|
39
|
+
..., description="A unique name for this integration instance (e.g., 'my_github_oauth', 'tavily_api_key')."
|
40
|
+
)
|
23
41
|
type: Literal["api_key", "oauth", "agentr", "oauth2", "basic_auth"] = Field(
|
24
|
-
default="api_key",
|
42
|
+
default="api_key",
|
43
|
+
description="The authentication mechanism to be used. 'oauth2' is often synonymous with 'oauth'. 'agentr' implies AgentR platform managed authentication.",
|
44
|
+
)
|
45
|
+
credentials: dict[str, Any] | None = Field(
|
46
|
+
default=None,
|
47
|
+
description="Directly provided credentials, if not using a store. Structure depends on the integration type (e.g., {'api_key': 'value'} or {'client_id': 'id', 'client_secret': 'secret'}). Use with caution for sensitive data; prefer using a 'store'.",
|
48
|
+
)
|
49
|
+
store: StoreConfig | None = Field(
|
50
|
+
default=None,
|
51
|
+
description="Configuration for the credential store to be used for this integration, overriding any default server-level store.",
|
25
52
|
)
|
26
|
-
credentials: dict[str, Any] | None = Field(default=None, description="Integration-specific credentials")
|
27
|
-
store: StoreConfig | None = Field(default=None, description="Store configuration for credentials")
|
28
53
|
|
29
54
|
|
30
55
|
class AppConfig(BaseModel):
|
31
|
-
"""Configuration for
|
56
|
+
"""Configuration for a single application to be loaded by the MCP server.
|
32
57
|
|
33
|
-
|
34
|
-
|
35
|
-
|
58
|
+
Defines an application's name (slug), its integration settings for
|
59
|
+
authentication, and optionally, a list of specific actions (tools)
|
60
|
+
it provides.
|
61
|
+
"""
|
62
|
+
|
63
|
+
name: str = Field(
|
64
|
+
...,
|
65
|
+
description="The unique name or slug of the application (e.g., 'github', 'google-calendar'). This is often used to dynamically load the application module.",
|
66
|
+
)
|
67
|
+
integration: IntegrationConfig | None = Field(
|
68
|
+
default=None,
|
69
|
+
description="Authentication and credential configuration for this application. If None, the application is assumed not to require authentication or uses a global/default mechanism.",
|
70
|
+
)
|
71
|
+
actions: list[str] | None = Field(
|
72
|
+
default=None,
|
73
|
+
description="A list of specific actions or tools provided by this application that should be exposed. If None or empty, all tools from the application might be exposed by default, depending on the application's implementation.",
|
74
|
+
)
|
36
75
|
|
37
76
|
|
38
77
|
class ServerConfig(BaseSettings):
|
39
|
-
"""
|
78
|
+
"""Core configuration settings for the Universal MCP server.
|
79
|
+
|
80
|
+
Manages server behavior, including its name, description, connection
|
81
|
+
to AgentR (if applicable), transport protocol, network settings (port/host),
|
82
|
+
applications to load, default credential store, and logging verbosity.
|
83
|
+
Settings can be loaded from environment variables or a .env file.
|
84
|
+
"""
|
40
85
|
|
41
|
-
model_config = SettingsConfigDict(
|
86
|
+
model_config: SettingsConfigDict = SettingsConfigDict(
|
42
87
|
env_file=".env",
|
43
88
|
env_file_encoding="utf-8",
|
44
89
|
case_sensitive=True,
|
@@ -46,24 +91,50 @@ class ServerConfig(BaseSettings):
|
|
46
91
|
)
|
47
92
|
|
48
93
|
name: str = Field(default="Universal MCP", description="Name of the MCP server")
|
49
|
-
description: str = Field(
|
50
|
-
|
51
|
-
|
94
|
+
description: str = Field(
|
95
|
+
default="Universal MCP", description="A brief description of this MCP server's purpose or deployment."
|
96
|
+
)
|
97
|
+
type: Literal["local", "agentr", "other"] = Field(
|
98
|
+
default="agentr",
|
99
|
+
description="Deployment type of the server. 'local' runs apps defined in 'apps' list; 'agentr' dynamically loads apps from the AgentR platform.",
|
52
100
|
)
|
53
|
-
api_key: SecretStr | None = Field(default=None, description="API key for authentication", alias="AGENTR_API_KEY")
|
54
|
-
type: Literal["local", "agentr"] = Field(default="agentr", description="Type of server deployment")
|
55
101
|
transport: Literal["stdio", "sse", "streamable-http"] = Field(
|
56
|
-
default="stdio",
|
102
|
+
default="stdio",
|
103
|
+
description="The communication protocol the server will use to interact with clients (e.g., an AI agent).",
|
104
|
+
)
|
105
|
+
port: int = Field(
|
106
|
+
default=8005,
|
107
|
+
description="Network port for 'sse' or 'streamable-http' transports. Must be between 1 and 65535.",
|
108
|
+
ge=1,
|
109
|
+
le=65535,
|
110
|
+
)
|
111
|
+
log_level: str = Field(
|
112
|
+
default="INFO", description="Logging level for the server (e.g., DEBUG, INFO, WARNING, ERROR, CRITICAL)."
|
113
|
+
)
|
114
|
+
# AgentR specific settings
|
115
|
+
base_url: str = Field(
|
116
|
+
default="https://api.agentr.dev",
|
117
|
+
description="The base URL for the AgentR API, used when type is 'agentr' or for AgentR-mediated integrations.",
|
118
|
+
alias="AGENTR_BASE_URL",
|
119
|
+
)
|
120
|
+
api_key: SecretStr | None = Field(
|
121
|
+
default=None,
|
122
|
+
description="The API key for authenticating with the AgentR platform. Stored as a SecretStr for security.",
|
123
|
+
alias="AGENTR_API_KEY",
|
124
|
+
)
|
125
|
+
# Local specific settings
|
126
|
+
apps: list[AppConfig] | None = Field(
|
127
|
+
default=None,
|
128
|
+
description="A list of application configurations to load when server 'type' is 'local'. Ignored if 'type' is 'agentr'.",
|
129
|
+
)
|
130
|
+
store: StoreConfig | None = Field(
|
131
|
+
default=None,
|
132
|
+
description="Default credential store configuration for applications that do not define their own specific store.",
|
57
133
|
)
|
58
|
-
port: int = Field(default=8005, description="Port to run the server on (if applicable)", ge=1024, le=65535)
|
59
|
-
host: str = Field(default="localhost", description="Host to bind the server to (if applicable)")
|
60
|
-
apps: list[AppConfig] | None = Field(default=None, description="List of configured applications")
|
61
|
-
store: StoreConfig | None = Field(default=None, description="Default store configuration")
|
62
|
-
debug: bool = Field(default=False, description="Enable debug mode")
|
63
|
-
log_level: str = Field(default="INFO", description="Logging level")
|
64
134
|
|
65
135
|
@field_validator("log_level", mode="before")
|
66
136
|
def validate_log_level(cls, v: str) -> str:
|
137
|
+
"""Validates and normalizes the log_level field."""
|
67
138
|
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
68
139
|
if v.upper() not in valid_levels:
|
69
140
|
raise ValueError(f"Invalid log level. Must be one of: {', '.join(valid_levels)}")
|
@@ -71,32 +142,59 @@ class ServerConfig(BaseSettings):
|
|
71
142
|
|
72
143
|
@field_validator("port", mode="before")
|
73
144
|
def validate_port(cls, v: int) -> int:
|
145
|
+
"""Validates the port number is within the valid range."""
|
74
146
|
if not 1 <= v <= 65535:
|
75
147
|
raise ValueError("Port must be between 1 and 65535")
|
76
148
|
return v
|
77
149
|
|
78
150
|
@classmethod
|
79
151
|
def load_json_config(cls, path: str = "local_config.json") -> Self:
|
152
|
+
"""Loads server configuration from a JSON file.
|
153
|
+
|
154
|
+
Args:
|
155
|
+
path (str, optional): The path to the JSON configuration file.
|
156
|
+
Defaults to "local_config.json".
|
157
|
+
|
158
|
+
Returns:
|
159
|
+
ServerConfig: An instance of ServerConfig populated with data
|
160
|
+
from the JSON file.
|
161
|
+
"""
|
80
162
|
with open(path) as f:
|
81
163
|
data = json.load(f)
|
82
164
|
return cls.model_validate(data)
|
83
165
|
|
84
166
|
|
85
167
|
class ClientTransportConfig(BaseModel):
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
168
|
+
"""Configuration for how an MCP client connects to an MCP server.
|
169
|
+
|
170
|
+
Specifies the transport protocol and its associated parameters, such as
|
171
|
+
the command for stdio, URL for HTTP-based transports (SSE, streamable_http),
|
172
|
+
and any necessary headers or environment variables.
|
173
|
+
"""
|
174
|
+
|
175
|
+
transport: str | None = Field(
|
176
|
+
default=None,
|
177
|
+
description="The transport protocol (e.g., 'stdio', 'sse', 'streamable_http'). Auto-detected in model_validate if not set.",
|
178
|
+
)
|
179
|
+
command: str | None = Field(
|
180
|
+
default=None, description="The command to execute for 'stdio' transport (e.g., 'python -m mcp_server.run')."
|
181
|
+
)
|
182
|
+
args: list[str] = Field(default=[], description="List of arguments for the 'stdio' command.")
|
183
|
+
env: dict[str, str] = Field(default={}, description="Environment variables to set for the 'stdio' command.")
|
184
|
+
url: str | None = Field(default=None, description="The URL for 'sse' or 'streamable_http' transport.")
|
185
|
+
headers: dict[str, str] = Field(
|
186
|
+
default={}, description="HTTP headers to include for 'sse' or 'streamable_http' transport."
|
187
|
+
)
|
92
188
|
|
93
189
|
@model_validator(mode="after")
|
94
|
-
def
|
95
|
-
"""
|
96
|
-
|
97
|
-
- If command is present, transport is 'stdio'.
|
98
|
-
-
|
99
|
-
|
190
|
+
def determine_transport_if_not_set(self) -> Self:
|
191
|
+
"""Determines and sets the transport type if not explicitly provided.
|
192
|
+
|
193
|
+
- If `command` is present, transport is set to 'stdio'.
|
194
|
+
- If `url` is present, transport is 'streamable_http' if URL ends with '/mcp',
|
195
|
+
otherwise 'sse' if URL ends with '/sse'.
|
196
|
+
- Raises ValueError if transport cannot be determined or if neither
|
197
|
+
`command` nor `url` is provided.
|
100
198
|
"""
|
101
199
|
if self.command:
|
102
200
|
self.transport = "stdio"
|
@@ -114,18 +212,34 @@ class ClientTransportConfig(BaseModel):
|
|
114
212
|
return self
|
115
213
|
|
116
214
|
|
117
|
-
class
|
118
|
-
|
119
|
-
base_url: str
|
120
|
-
model: str
|
215
|
+
class ClientConfig(BaseSettings):
|
216
|
+
"""Configuration for a client application that interacts with MCP servers and an LLM.
|
121
217
|
|
218
|
+
Defines connections to one or more MCP servers (via `mcpServers`) and
|
219
|
+
optionally, settings for an LLM to be used by the client (e.g., by an agent).
|
220
|
+
"""
|
122
221
|
|
123
|
-
|
124
|
-
|
125
|
-
|
222
|
+
mcpServers: dict[str, ClientTransportConfig] = Field(
|
223
|
+
...,
|
224
|
+
description="A dictionary where keys are descriptive names for MCP server connections and values are `ClientTransportConfig` objects defining how to connect to each server.",
|
225
|
+
)
|
126
226
|
|
127
227
|
@classmethod
|
128
228
|
def load_json_config(cls, path: str = "servers.json") -> Self:
|
229
|
+
"""Loads client configuration from a JSON file.
|
230
|
+
|
231
|
+
Args:
|
232
|
+
path (str, optional): The path to the JSON configuration file.
|
233
|
+
Defaults to "servers.json".
|
234
|
+
|
235
|
+
Returns:
|
236
|
+
ClientConfig: An instance of ClientConfig populated with data
|
237
|
+
from the JSON file.
|
238
|
+
"""
|
129
239
|
with open(path) as f:
|
130
240
|
data = json.load(f)
|
131
241
|
return cls.model_validate(data)
|
242
|
+
|
243
|
+
def save_json_config(self, path: str) -> None:
|
244
|
+
with open(path, "w") as f:
|
245
|
+
json.dump(self.model_dump(), f, indent=4)
|
universal_mcp/exceptions.py
CHANGED
@@ -1,25 +1,69 @@
|
|
1
1
|
class NotAuthorizedError(Exception):
|
2
|
-
"""Raised when
|
2
|
+
"""Raised when an action is attempted without necessary permissions.
|
3
|
+
|
4
|
+
This typically occurs if a user or process tries to access a protected
|
5
|
+
resource or perform an operation for which they lack the required
|
6
|
+
authorization credentials or roles.
|
7
|
+
"""
|
3
8
|
|
4
9
|
def __init__(self, message: str):
|
10
|
+
"""Initializes the NotAuthorizedError.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
message (str): A descriptive message explaining the authorization failure.
|
14
|
+
"""
|
5
15
|
self.message = message
|
16
|
+
super().__init__(message) # Ensure message is passed to base Exception
|
6
17
|
|
7
18
|
|
8
19
|
class ToolError(Exception):
|
9
|
-
"""
|
20
|
+
"""Indicates an issue related to tool discovery, validation, or execution.
|
21
|
+
|
22
|
+
This could be due to a tool not being found, failing during its
|
23
|
+
operation, or having invalid configuration or arguments.
|
24
|
+
"""
|
25
|
+
|
26
|
+
pass
|
10
27
|
|
11
28
|
|
12
29
|
class InvalidSignature(Exception):
|
13
|
-
"""Raised when a signature
|
30
|
+
"""Raised when a cryptographic signature verification fails.
|
31
|
+
|
32
|
+
This can occur during webhook validation or any other process that
|
33
|
+
relies on verifying the authenticity and integrity of a message
|
34
|
+
using a digital signature.
|
35
|
+
"""
|
36
|
+
|
37
|
+
pass
|
14
38
|
|
15
39
|
|
16
40
|
class StoreError(Exception):
|
17
|
-
"""Base exception
|
41
|
+
"""Base exception for errors related to data or credential stores.
|
42
|
+
|
43
|
+
This serves as a generic error for issues arising from operations
|
44
|
+
on any storage backend (e.g., KeyringStore, EnvironmentStore).
|
45
|
+
Specific store errors should ideally subclass this.
|
46
|
+
"""
|
47
|
+
|
48
|
+
pass
|
18
49
|
|
19
50
|
|
20
51
|
class KeyNotFoundError(StoreError):
|
21
|
-
"""
|
52
|
+
"""Raised when a specified key cannot be found in a data or credential store.
|
53
|
+
|
54
|
+
This is a common error when attempting to retrieve a piece of data
|
55
|
+
(e.g., an API key, token, or client information) that does not exist
|
56
|
+
under the given identifier.
|
57
|
+
"""
|
58
|
+
|
59
|
+
pass
|
22
60
|
|
23
61
|
|
24
62
|
class ConfigurationError(Exception):
|
25
|
-
"""
|
63
|
+
"""Indicates an error was detected in application or server configuration.
|
64
|
+
|
65
|
+
This can be due to missing required settings, invalid values for
|
66
|
+
configuration parameters, or inconsistencies in the provided setup.
|
67
|
+
"""
|
68
|
+
|
69
|
+
pass
|