universal-mcp 0.1.17__py3-none-any.whl → 0.1.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- universal_mcp/applications/application.py +106 -16
- universal_mcp/cli.py +44 -4
- universal_mcp/config.py +4 -5
- universal_mcp/exceptions.py +4 -0
- universal_mcp/integrations/__init__.py +1 -1
- universal_mcp/integrations/integration.py +9 -9
- universal_mcp/servers/__init__.py +2 -2
- universal_mcp/servers/server.py +125 -54
- universal_mcp/tools/manager.py +24 -10
- universal_mcp/utils/agentr.py +10 -14
- universal_mcp/utils/openapi/api_splitter.py +400 -0
- universal_mcp/utils/openapi/openapi.py +299 -116
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/METADATA +2 -2
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/RECORD +17 -16
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/entry_points.txt +0 -0
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/licenses/LICENSE +0 -0
@@ -81,7 +81,7 @@ class APIApplication(BaseApplication):
|
|
81
81
|
"""
|
82
82
|
super().__init__(name, **kwargs)
|
83
83
|
self.default_timeout: int = 180
|
84
|
-
self.integration
|
84
|
+
self.integration = integration
|
85
85
|
logger.debug(f"Initializing APIApplication '{name}' with integration: {integration}")
|
86
86
|
self._client: httpx.Client | None = client
|
87
87
|
self.base_url: str = ""
|
@@ -169,14 +169,28 @@ class APIApplication(BaseApplication):
|
|
169
169
|
logger.debug(f"GET request successful with status code: {response.status_code}")
|
170
170
|
return response
|
171
171
|
|
172
|
-
def _post(
|
172
|
+
def _post(
|
173
|
+
self,
|
174
|
+
url: str,
|
175
|
+
data: Any,
|
176
|
+
params: dict[str, Any] | None = None,
|
177
|
+
content_type: str = "application/json",
|
178
|
+
files: dict[str, Any] | None = None,
|
179
|
+
) -> httpx.Response:
|
173
180
|
"""
|
174
181
|
Make a POST request to the specified URL.
|
175
182
|
|
176
183
|
Args:
|
177
184
|
url: The URL to send the request to
|
178
|
-
data: The data to send
|
185
|
+
data: The data to send. For 'application/json', this is JSON-serializable.
|
186
|
+
For 'application/x-www-form-urlencoded' or 'multipart/form-data', this is a dict of form fields.
|
187
|
+
For other content types, this is raw bytes or string.
|
179
188
|
params: Optional query parameters
|
189
|
+
content_type: The Content-Type of the request body.
|
190
|
+
Examples: 'application/json', 'application/x-www-form-urlencoded',
|
191
|
+
'multipart/form-data', 'application/octet-stream', 'text/plain'.
|
192
|
+
files: Optional dictionary of files to upload for 'multipart/form-data'.
|
193
|
+
Example: {'file_field_name': ('filename.txt', open('file.txt', 'rb'), 'text/plain')}
|
180
194
|
|
181
195
|
Returns:
|
182
196
|
httpx.Response: The response from the server
|
@@ -184,25 +198,69 @@ class APIApplication(BaseApplication):
|
|
184
198
|
Raises:
|
185
199
|
httpx.HTTPError: If the request fails
|
186
200
|
"""
|
187
|
-
logger.debug(
|
188
|
-
|
189
|
-
url,
|
190
|
-
headers=self._get_headers(),
|
191
|
-
json=data,
|
192
|
-
params=params,
|
201
|
+
logger.debug(
|
202
|
+
f"Making POST request to {url} with params: {params}, data type: {type(data)}, content_type={content_type}, files: {'yes' if files else 'no'}"
|
193
203
|
)
|
204
|
+
headers = self._get_headers().copy()
|
205
|
+
|
206
|
+
if content_type != "multipart/form-data":
|
207
|
+
headers["Content-Type"] = content_type
|
208
|
+
|
209
|
+
if content_type == "multipart/form-data":
|
210
|
+
response = self.client.post(
|
211
|
+
url,
|
212
|
+
headers=headers,
|
213
|
+
data=data, # For regular form fields
|
214
|
+
files=files, # For file parts
|
215
|
+
params=params,
|
216
|
+
)
|
217
|
+
elif content_type == "application/x-www-form-urlencoded":
|
218
|
+
response = self.client.post(
|
219
|
+
url,
|
220
|
+
headers=headers,
|
221
|
+
data=data,
|
222
|
+
params=params,
|
223
|
+
)
|
224
|
+
elif content_type == "application/json":
|
225
|
+
response = self.client.post(
|
226
|
+
url,
|
227
|
+
headers=headers,
|
228
|
+
json=data,
|
229
|
+
params=params,
|
230
|
+
)
|
231
|
+
else: # Handles 'application/octet-stream', 'text/plain', 'image/jpeg', etc.
|
232
|
+
response = self.client.post(
|
233
|
+
url,
|
234
|
+
headers=headers,
|
235
|
+
content=data, # Expect data to be bytes or str
|
236
|
+
params=params,
|
237
|
+
)
|
194
238
|
response.raise_for_status()
|
195
239
|
logger.debug(f"POST request successful with status code: {response.status_code}")
|
196
240
|
return response
|
197
241
|
|
198
|
-
def _put(
|
242
|
+
def _put(
|
243
|
+
self,
|
244
|
+
url: str,
|
245
|
+
data: Any,
|
246
|
+
params: dict[str, Any] | None = None,
|
247
|
+
content_type: str = "application/json",
|
248
|
+
files: dict[str, Any] | None = None,
|
249
|
+
) -> httpx.Response:
|
199
250
|
"""
|
200
251
|
Make a PUT request to the specified URL.
|
201
252
|
|
202
253
|
Args:
|
203
254
|
url: The URL to send the request to
|
204
|
-
data: The data to send
|
255
|
+
data: The data to send. For 'application/json', this is JSON-serializable.
|
256
|
+
For 'application/x-www-form-urlencoded' or 'multipart/form-data', this is a dict of form fields.
|
257
|
+
For other content types, this is raw bytes or string.
|
205
258
|
params: Optional query parameters
|
259
|
+
content_type: The Content-Type of the request body.
|
260
|
+
Examples: 'application/json', 'application/x-www-form-urlencoded',
|
261
|
+
'multipart/form-data', 'application/octet-stream', 'text/plain'.
|
262
|
+
files: Optional dictionary of files to upload for 'multipart/form-data'.
|
263
|
+
Example: {'file_field_name': ('filename.txt', open('file.txt', 'rb'), 'text/plain')}
|
206
264
|
|
207
265
|
Returns:
|
208
266
|
httpx.Response: The response from the server
|
@@ -210,12 +268,44 @@ class APIApplication(BaseApplication):
|
|
210
268
|
Raises:
|
211
269
|
httpx.HTTPError: If the request fails
|
212
270
|
"""
|
213
|
-
logger.debug(
|
214
|
-
|
215
|
-
url,
|
216
|
-
json=data,
|
217
|
-
params=params,
|
271
|
+
logger.debug(
|
272
|
+
f"Making PUT request to {url} with params: {params}, data type: {type(data)}, content_type={content_type}, files: {'yes' if files else 'no'}"
|
218
273
|
)
|
274
|
+
headers = self._get_headers().copy()
|
275
|
+
# For multipart/form-data, httpx handles the Content-Type header (with boundary)
|
276
|
+
# For other content types, we set it explicitly.
|
277
|
+
if content_type != "multipart/form-data":
|
278
|
+
headers["Content-Type"] = content_type
|
279
|
+
|
280
|
+
if content_type == "multipart/form-data":
|
281
|
+
response = self.client.put(
|
282
|
+
url,
|
283
|
+
headers=headers,
|
284
|
+
data=data, # For regular form fields
|
285
|
+
files=files, # For file parts
|
286
|
+
params=params,
|
287
|
+
)
|
288
|
+
elif content_type == "application/x-www-form-urlencoded":
|
289
|
+
response = self.client.put(
|
290
|
+
url,
|
291
|
+
headers=headers,
|
292
|
+
data=data,
|
293
|
+
params=params,
|
294
|
+
)
|
295
|
+
elif content_type == "application/json":
|
296
|
+
response = self.client.put(
|
297
|
+
url,
|
298
|
+
headers=headers,
|
299
|
+
json=data,
|
300
|
+
params=params,
|
301
|
+
)
|
302
|
+
else: # Handles 'application/octet-stream', 'text/plain', 'image/jpeg', etc.
|
303
|
+
response = self.client.put(
|
304
|
+
url,
|
305
|
+
headers=headers,
|
306
|
+
content=data, # Expect data to be bytes or str
|
307
|
+
params=params,
|
308
|
+
)
|
219
309
|
response.raise_for_status()
|
220
310
|
logger.debug(f"PUT request successful with status code: {response.status_code}")
|
221
311
|
return response
|
universal_mcp/cli.py
CHANGED
@@ -45,14 +45,26 @@ def generate(
|
|
45
45
|
raise typer.Exit(1)
|
46
46
|
|
47
47
|
try:
|
48
|
-
|
49
|
-
app_file = generate_api_from_schema(
|
48
|
+
app_file_data = generate_api_from_schema(
|
50
49
|
schema_path=schema_path,
|
51
50
|
output_path=output_path,
|
52
51
|
class_name=class_name,
|
53
52
|
)
|
54
|
-
|
55
|
-
|
53
|
+
if isinstance(app_file_data, dict) and "code" in app_file_data:
|
54
|
+
console.print("[yellow]No output path specified, printing generated code to console:[/yellow]")
|
55
|
+
console.print(app_file_data["code"])
|
56
|
+
elif isinstance(app_file_data, Path):
|
57
|
+
console.print("[green]API client successfully generated and installed.[/green]")
|
58
|
+
console.print(f"[blue]Application file: {app_file_data}[/blue]")
|
59
|
+
else:
|
60
|
+
# Handle the error case from api_generator if validation fails
|
61
|
+
if isinstance(app_file_data, dict) and "error" in app_file_data:
|
62
|
+
console.print(f"[red]{app_file_data['error']}[/red]")
|
63
|
+
raise typer.Exit(1)
|
64
|
+
else:
|
65
|
+
console.print("[red]Unexpected return value from API generator.[/red]")
|
66
|
+
raise typer.Exit(1)
|
67
|
+
|
56
68
|
except Exception as e:
|
57
69
|
console.print(f"[red]Error generating API client: {e}[/red]")
|
58
70
|
raise typer.Exit(1) from e
|
@@ -255,5 +267,33 @@ def preprocess(
|
|
255
267
|
run_preprocessing(schema_path, output_path)
|
256
268
|
|
257
269
|
|
270
|
+
@app.command()
|
271
|
+
def split_api(
|
272
|
+
input_app_file: Path = typer.Argument(..., help="Path to the generated app.py file to split"),
|
273
|
+
output_dir: Path = typer.Option(..., "--output-dir", "-o", help="Directory to save the split files"),
|
274
|
+
):
|
275
|
+
"""Splits a single generated API client file into multiple files based on path groups."""
|
276
|
+
from universal_mcp.utils.openapi.api_splitter import split_generated_app_file
|
277
|
+
|
278
|
+
if not input_app_file.exists() or not input_app_file.is_file():
|
279
|
+
console.print(f"[red]Error: Input file {input_app_file} does not exist or is not a file.[/red]")
|
280
|
+
raise typer.Exit(1)
|
281
|
+
|
282
|
+
if not output_dir.exists():
|
283
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
284
|
+
console.print(f"[green]Created output directory: {output_dir}[/green]")
|
285
|
+
elif not output_dir.is_dir():
|
286
|
+
console.print(f"[red]Error: Output path {output_dir} is not a directory.[/red]")
|
287
|
+
raise typer.Exit(1)
|
288
|
+
|
289
|
+
try:
|
290
|
+
split_generated_app_file(input_app_file, output_dir)
|
291
|
+
console.print(f"[green]Successfully split {input_app_file} into {output_dir}[/green]")
|
292
|
+
except Exception as e:
|
293
|
+
console.print(f"[red]Error splitting API client: {e}[/red]")
|
294
|
+
|
295
|
+
raise typer.Exit(1) from e
|
296
|
+
|
297
|
+
|
258
298
|
if __name__ == "__main__":
|
259
299
|
app()
|
universal_mcp/config.py
CHANGED
@@ -38,7 +38,6 @@ class ServerConfig(BaseSettings):
|
|
38
38
|
"""Main server configuration."""
|
39
39
|
|
40
40
|
model_config = SettingsConfigDict(
|
41
|
-
env_prefix="MCP_",
|
42
41
|
env_file=".env",
|
43
42
|
env_file_encoding="utf-8",
|
44
43
|
case_sensitive=True,
|
@@ -49,15 +48,15 @@ class ServerConfig(BaseSettings):
|
|
49
48
|
description: str = Field(default="Universal MCP", description="Description of the MCP server")
|
50
49
|
api_key: SecretStr | None = Field(default=None, description="API key for authentication")
|
51
50
|
type: Literal["local", "agentr"] = Field(default="agentr", description="Type of server deployment")
|
52
|
-
transport: Literal["stdio", "sse", "http"] = Field(
|
53
|
-
|
51
|
+
transport: Literal["stdio", "sse", "streamable-http"] = Field(
|
52
|
+
default="stdio", description="Transport protocol to use"
|
53
|
+
)
|
54
|
+
port: int = Field(default=8005, description="Port to run the server on (if applicable)", ge=1024, le=65535)
|
54
55
|
host: str = Field(default="localhost", description="Host to bind the server to (if applicable)")
|
55
56
|
apps: list[AppConfig] | None = Field(default=None, description="List of configured applications")
|
56
57
|
store: StoreConfig | None = Field(default=None, description="Default store configuration")
|
57
58
|
debug: bool = Field(default=False, description="Enable debug mode")
|
58
59
|
log_level: str = Field(default="INFO", description="Logging level")
|
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")
|
61
60
|
|
62
61
|
@field_validator("log_level", mode="before")
|
63
62
|
def validate_log_level(cls, v: str) -> str:
|
universal_mcp/exceptions.py
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
from abc import ABC, abstractmethod
|
2
1
|
from typing import Any
|
3
2
|
|
4
3
|
import httpx
|
@@ -18,7 +17,7 @@ def sanitize_api_key_name(name: str) -> str:
|
|
18
17
|
return f"{name.upper()}{suffix}"
|
19
18
|
|
20
19
|
|
21
|
-
class Integration
|
20
|
+
class Integration:
|
22
21
|
"""Abstract base class for handling application integrations and authentication.
|
23
22
|
|
24
23
|
This class defines the interface for different types of integrations that handle
|
@@ -35,9 +34,11 @@ class Integration(ABC):
|
|
35
34
|
|
36
35
|
def __init__(self, name: str, store: BaseStore | None = None):
|
37
36
|
self.name = name
|
38
|
-
|
37
|
+
if store is None:
|
38
|
+
self.store = MemoryStore()
|
39
|
+
else:
|
40
|
+
self.store = store
|
39
41
|
|
40
|
-
@abstractmethod
|
41
42
|
def authorize(self) -> str | dict[str, Any]:
|
42
43
|
"""Authorize the integration.
|
43
44
|
|
@@ -49,7 +50,6 @@ class Integration(ABC):
|
|
49
50
|
"""
|
50
51
|
pass
|
51
52
|
|
52
|
-
@abstractmethod
|
53
53
|
def get_credentials(self) -> dict[str, Any]:
|
54
54
|
"""Get credentials for the integration.
|
55
55
|
|
@@ -59,9 +59,9 @@ class Integration(ABC):
|
|
59
59
|
Raises:
|
60
60
|
NotAuthorizedError: If credentials are not found or invalid.
|
61
61
|
"""
|
62
|
-
|
62
|
+
credentials = self.store.get(self.name)
|
63
|
+
return credentials
|
63
64
|
|
64
|
-
@abstractmethod
|
65
65
|
def set_credentials(self, credentials: dict[str, Any]) -> None:
|
66
66
|
"""Set credentials for the integration.
|
67
67
|
|
@@ -71,7 +71,7 @@ class Integration(ABC):
|
|
71
71
|
Raises:
|
72
72
|
ValueError: If credentials are invalid or missing required fields.
|
73
73
|
"""
|
74
|
-
|
74
|
+
self.store.set(self.name, credentials)
|
75
75
|
|
76
76
|
|
77
77
|
class ApiKeyIntegration(Integration):
|
@@ -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 | None = None, **kwargs):
|
95
95
|
self.type = "api_key"
|
96
96
|
sanitized_name = sanitize_api_key_name(name)
|
97
97
|
super().__init__(sanitized_name, store, **kwargs)
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from universal_mcp.config import ServerConfig
|
2
|
-
from universal_mcp.servers.server import AgentRServer, LocalServer, SingleMCPServer
|
2
|
+
from universal_mcp.servers.server import AgentRServer, BaseServer, LocalServer, SingleMCPServer
|
3
3
|
|
4
4
|
|
5
5
|
def server_from_config(config: ServerConfig):
|
@@ -12,4 +12,4 @@ def server_from_config(config: ServerConfig):
|
|
12
12
|
raise ValueError(f"Unsupported server type: {config.type}")
|
13
13
|
|
14
14
|
|
15
|
-
__all__ = [AgentRServer, LocalServer, SingleMCPServer, server_from_config]
|
15
|
+
__all__ = [AgentRServer, LocalServer, SingleMCPServer, BaseServer, server_from_config]
|
universal_mcp/servers/server.py
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
from abc import ABC, abstractmethod
|
2
1
|
from collections.abc import Callable
|
3
2
|
from typing import Any
|
4
3
|
|
@@ -6,16 +5,18 @@ import httpx
|
|
6
5
|
from loguru import logger
|
7
6
|
from mcp.server.fastmcp import FastMCP
|
8
7
|
from mcp.types import TextContent
|
8
|
+
from pydantic import ValidationError
|
9
9
|
|
10
10
|
from universal_mcp.applications import BaseApplication, app_from_slug
|
11
11
|
from universal_mcp.config import AppConfig, ServerConfig, StoreConfig
|
12
|
+
from universal_mcp.exceptions import ConfigurationError, ToolError
|
12
13
|
from universal_mcp.integrations import AgentRIntegration, integration_from_config
|
13
14
|
from universal_mcp.stores import BaseStore, store_from_config
|
14
15
|
from universal_mcp.tools import ToolManager
|
15
16
|
from universal_mcp.utils.agentr import AgentrClient
|
16
17
|
|
17
18
|
|
18
|
-
class BaseServer(FastMCP
|
19
|
+
class BaseServer(FastMCP):
|
19
20
|
"""Base server class with common functionality.
|
20
21
|
|
21
22
|
This class provides core server functionality including store setup,
|
@@ -26,24 +27,27 @@ class BaseServer(FastMCP, ABC):
|
|
26
27
|
**kwargs: Additional keyword arguments passed to FastMCP
|
27
28
|
"""
|
28
29
|
|
29
|
-
def __init__(self, config: ServerConfig, **kwargs):
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
pass
|
30
|
+
def __init__(self, config: ServerConfig, tool_manager: ToolManager | None = None, **kwargs):
|
31
|
+
try:
|
32
|
+
super().__init__(config.name, config.description, port=config.port, **kwargs)
|
33
|
+
logger.info(f"Initializing server: {config.name} ({config.type}) with store: {config.store}")
|
34
|
+
self.config = config
|
35
|
+
self._tool_manager = tool_manager or ToolManager(warn_on_duplicate_tools=True)
|
36
|
+
ServerConfig.model_validate(config)
|
37
|
+
except Exception as e:
|
38
|
+
logger.error(f"Failed to initialize server: {e}", exc_info=True)
|
39
|
+
raise ConfigurationError(f"Server initialization failed: {str(e)}") from e
|
40
40
|
|
41
41
|
def add_tool(self, tool: Callable) -> None:
|
42
42
|
"""Add a tool to the server.
|
43
43
|
|
44
44
|
Args:
|
45
45
|
tool: Tool to add
|
46
|
+
|
47
|
+
Raises:
|
48
|
+
ValueError: If tool is invalid
|
46
49
|
"""
|
50
|
+
|
47
51
|
self._tool_manager.add_tool(tool)
|
48
52
|
|
49
53
|
async def list_tools(self) -> list[dict]:
|
@@ -83,11 +87,21 @@ class BaseServer(FastMCP, ABC):
|
|
83
87
|
|
84
88
|
Raises:
|
85
89
|
ToolError: If tool execution fails
|
90
|
+
ValueError: If tool name is invalid or arguments are malformed
|
86
91
|
"""
|
92
|
+
if not name:
|
93
|
+
raise ValueError("Tool name is required")
|
94
|
+
if not isinstance(arguments, dict):
|
95
|
+
raise ValueError("Arguments must be a dictionary")
|
96
|
+
|
87
97
|
logger.info(f"Calling tool: {name} with arguments: {arguments}")
|
88
|
-
|
89
|
-
|
90
|
-
|
98
|
+
try:
|
99
|
+
result = await self._tool_manager.call_tool(name, arguments)
|
100
|
+
logger.info(f"Tool '{name}' completed successfully")
|
101
|
+
return self._format_tool_result(result)
|
102
|
+
except Exception as e:
|
103
|
+
logger.error(f"Tool '{name}' failed: {e}", exc_info=True)
|
104
|
+
raise ToolError(f"Tool execution failed: {str(e)}") from e
|
91
105
|
|
92
106
|
|
93
107
|
class LocalServer(BaseServer):
|
@@ -111,14 +125,23 @@ class LocalServer(BaseServer):
|
|
111
125
|
|
112
126
|
Returns:
|
113
127
|
Configured store instance or None if no config provided
|
128
|
+
|
129
|
+
Raises:
|
130
|
+
ConfigurationError: If store configuration is invalid
|
114
131
|
"""
|
115
132
|
if not store_config:
|
133
|
+
logger.info("No store configuration provided")
|
116
134
|
return None
|
117
135
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
136
|
+
try:
|
137
|
+
store = store_from_config(store_config)
|
138
|
+
self.add_tool(store.set)
|
139
|
+
self.add_tool(store.delete)
|
140
|
+
logger.info(f"Successfully configured store: {store_config.type}")
|
141
|
+
return store
|
142
|
+
except Exception as e:
|
143
|
+
logger.error(f"Failed to setup store: {e}", exc_info=True)
|
144
|
+
raise ConfigurationError(f"Store setup failed: {str(e)}") from e
|
122
145
|
|
123
146
|
def _load_app(self, app_config: AppConfig) -> BaseApplication | None:
|
124
147
|
"""Load a single application with its integration.
|
@@ -129,22 +152,57 @@ class LocalServer(BaseServer):
|
|
129
152
|
Returns:
|
130
153
|
Configured application instance or None if loading fails
|
131
154
|
"""
|
155
|
+
if not app_config.name:
|
156
|
+
logger.error("App configuration missing name")
|
157
|
+
return None
|
158
|
+
|
132
159
|
try:
|
133
|
-
integration =
|
134
|
-
|
135
|
-
|
136
|
-
|
160
|
+
integration = None
|
161
|
+
if app_config.integration:
|
162
|
+
try:
|
163
|
+
integration = integration_from_config(app_config.integration, store=self.store)
|
164
|
+
logger.debug(f"Successfully configured integration for {app_config.name}")
|
165
|
+
except Exception as e:
|
166
|
+
logger.error(f"Failed to setup integration for {app_config.name}: {e}", exc_info=True)
|
167
|
+
# Continue without integration if it fails
|
168
|
+
|
169
|
+
app = app_from_slug(app_config.name)(integration=integration)
|
170
|
+
logger.info(f"Successfully loaded app: {app_config.name}")
|
171
|
+
return app
|
137
172
|
except Exception as e:
|
138
173
|
logger.error(f"Failed to load app {app_config.name}: {e}", exc_info=True)
|
139
174
|
return None
|
140
175
|
|
141
176
|
def _load_apps(self) -> None:
|
142
|
-
"""Load all configured applications."""
|
143
|
-
|
177
|
+
"""Load all configured applications with graceful degradation."""
|
178
|
+
if not self.config.apps:
|
179
|
+
logger.warning("No applications configured")
|
180
|
+
return
|
181
|
+
|
182
|
+
logger.info(f"Loading {len(self.config.apps)} apps")
|
183
|
+
loaded_apps = 0
|
184
|
+
failed_apps = []
|
185
|
+
|
144
186
|
for app_config in self.config.apps:
|
145
187
|
app = self._load_app(app_config)
|
146
188
|
if app:
|
147
|
-
|
189
|
+
try:
|
190
|
+
self._tool_manager.register_tools_from_app(app, app_config.actions)
|
191
|
+
loaded_apps += 1
|
192
|
+
logger.info(f"Successfully registered tools for {app_config.name}")
|
193
|
+
except Exception as e:
|
194
|
+
logger.error(f"Failed to register tools for {app_config.name}: {e}", exc_info=True)
|
195
|
+
failed_apps.append(app_config.name)
|
196
|
+
else:
|
197
|
+
failed_apps.append(app_config.name)
|
198
|
+
|
199
|
+
if failed_apps:
|
200
|
+
logger.warning(f"Failed to load {len(failed_apps)} apps: {', '.join(failed_apps)}")
|
201
|
+
|
202
|
+
if loaded_apps == 0:
|
203
|
+
logger.error("No apps were successfully loaded")
|
204
|
+
else:
|
205
|
+
logger.info(f"Successfully loaded {loaded_apps}/{len(self.config.apps)} apps")
|
148
206
|
|
149
207
|
|
150
208
|
class AgentRServer(BaseServer):
|
@@ -154,27 +212,38 @@ class AgentRServer(BaseServer):
|
|
154
212
|
config: Server configuration
|
155
213
|
api_key: Optional API key for AgentR authentication. If not provided,
|
156
214
|
will attempt to read from AGENTR_API_KEY environment variable.
|
215
|
+
max_retries: Maximum number of retries for API calls (default: 3)
|
216
|
+
retry_delay: Delay between retries in seconds (default: 1)
|
157
217
|
**kwargs: Additional keyword arguments passed to FastMCP
|
158
218
|
"""
|
159
219
|
|
160
220
|
def __init__(self, config: ServerConfig, api_key: str | None = None, **kwargs):
|
161
|
-
self.
|
221
|
+
self.api_key = api_key or str(config.api_key)
|
222
|
+
self.client = AgentrClient(api_key=self.api_key)
|
162
223
|
super().__init__(config, **kwargs)
|
163
224
|
self.integration = AgentRIntegration(name="agentr", api_key=self.client.api_key)
|
164
225
|
self._load_apps()
|
165
226
|
|
166
227
|
def _fetch_apps(self) -> list[AppConfig]:
|
167
|
-
"""Fetch available apps from AgentR API.
|
228
|
+
"""Fetch available apps from AgentR API with retry logic.
|
168
229
|
|
169
230
|
Returns:
|
170
231
|
List of application configurations
|
171
232
|
|
172
233
|
Raises:
|
173
|
-
httpx.HTTPError: If API request fails
|
234
|
+
httpx.HTTPError: If API request fails after all retries
|
235
|
+
ValidationError: If app configuration validation fails
|
174
236
|
"""
|
175
237
|
try:
|
176
238
|
apps = self.client.fetch_apps()
|
177
|
-
|
239
|
+
validated_apps = []
|
240
|
+
for app in apps:
|
241
|
+
try:
|
242
|
+
validated_apps.append(AppConfig.model_validate(app))
|
243
|
+
except ValidationError as e:
|
244
|
+
logger.error(f"Failed to validate app config: {e}", exc_info=True)
|
245
|
+
continue
|
246
|
+
return validated_apps
|
178
247
|
except httpx.HTTPError as e:
|
179
248
|
logger.error(f"Failed to fetch apps from AgentR: {e}", exc_info=True)
|
180
249
|
raise
|
@@ -194,21 +263,37 @@ class AgentRServer(BaseServer):
|
|
194
263
|
if app_config.integration
|
195
264
|
else None
|
196
265
|
)
|
197
|
-
|
266
|
+
app = app_from_slug(app_config.name)(integration=integration)
|
267
|
+
logger.info(f"Successfully loaded app: {app_config.name}")
|
268
|
+
return app
|
198
269
|
except Exception as e:
|
199
270
|
logger.error(f"Failed to load app {app_config.name}: {e}", exc_info=True)
|
200
271
|
return None
|
201
272
|
|
202
273
|
def _load_apps(self) -> None:
|
203
|
-
"""Load all apps available from AgentR."""
|
274
|
+
"""Load all apps available from AgentR with graceful degradation."""
|
204
275
|
try:
|
205
|
-
|
276
|
+
app_configs = self._fetch_apps()
|
277
|
+
if not app_configs:
|
278
|
+
logger.warning("No apps found from AgentR API")
|
279
|
+
return
|
280
|
+
|
281
|
+
loaded_apps = 0
|
282
|
+
for app_config in app_configs:
|
206
283
|
app = self._load_app(app_config)
|
207
284
|
if app:
|
208
285
|
self._tool_manager.register_tools_from_app(app, app_config.actions)
|
286
|
+
loaded_apps += 1
|
287
|
+
|
288
|
+
if loaded_apps == 0:
|
289
|
+
logger.error("Failed to load any apps from AgentR")
|
290
|
+
else:
|
291
|
+
logger.info(f"Successfully loaded {loaded_apps}/{len(app_configs)} apps from AgentR")
|
292
|
+
|
209
293
|
except Exception:
|
210
294
|
logger.error("Failed to load apps", exc_info=True)
|
211
|
-
raise
|
295
|
+
# Don't raise the exception to allow server to start with partial functionality
|
296
|
+
logger.warning("Server will start with limited functionality due to app loading failures")
|
212
297
|
|
213
298
|
|
214
299
|
class SingleMCPServer(BaseServer):
|
@@ -234,27 +319,13 @@ class SingleMCPServer(BaseServer):
|
|
234
319
|
config: ServerConfig | None = None,
|
235
320
|
**kwargs,
|
236
321
|
):
|
322
|
+
if not app_instance:
|
323
|
+
raise ValueError("app_instance is required")
|
237
324
|
if not config:
|
238
325
|
config = ServerConfig(
|
239
326
|
type="local",
|
240
|
-
name=f"{app_instance.name.title()} MCP Server for Local Development"
|
241
|
-
|
242
|
-
else "Unnamed MCP Server",
|
243
|
-
description=f"Minimal MCP server for the local {app_instance.name} application."
|
244
|
-
if app_instance
|
245
|
-
else "Minimal MCP server with no application loaded.",
|
327
|
+
name=f"{app_instance.name.title()} MCP Server for Local Development",
|
328
|
+
description=f"Minimal MCP server for the local {app_instance.name} application.",
|
246
329
|
)
|
247
330
|
super().__init__(config, **kwargs)
|
248
|
-
|
249
|
-
self.app_instance = app_instance
|
250
|
-
self._load_apps()
|
251
|
-
|
252
|
-
def _load_apps(self) -> None:
|
253
|
-
"""Registers tools from the single provided application instance."""
|
254
|
-
if not self.app_instance:
|
255
|
-
logger.warning("No app_instance provided. No tools registered.")
|
256
|
-
return
|
257
|
-
|
258
|
-
tool_functions = self.app_instance.list_tools()
|
259
|
-
for tool_func in tool_functions:
|
260
|
-
self._tool_manager.add_tool(tool_func)
|
331
|
+
self._tool_manager.register_tools_from_app(app_instance)
|