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.
@@ -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: Integration | None = 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(self, url: str, data: dict[str, Any], params: dict[str, Any] | None = None) -> httpx.Response:
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 in the request body
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(f"Making POST request to {url} with params: {params} and data: {data}")
188
- response = httpx.post(
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(self, url: str, data: dict[str, Any], params: dict[str, Any] | None = None) -> httpx.Response:
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 in the request body
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(f"Making PUT request to {url} with params: {params} and data: {data}")
214
- response = self.client.put(
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
- # Run the async function in the event loop
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
- console.print("[green]API client successfully generated and installed.[/green]")
55
- console.print(f"[blue]Application file: {app_file}[/blue]")
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(default="stdio", description="Transport protocol to use")
53
- port: int = Field(default=8005, description="Port to run the server on (if applicable)")
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:
@@ -19,3 +19,7 @@ class StoreError(Exception):
19
19
 
20
20
  class KeyNotFoundError(StoreError):
21
21
  """Exception raised when a key is not found in the store."""
22
+
23
+
24
+ class ConfigurationError(Exception):
25
+ """Exception raised when a configuration error occurs."""
@@ -22,8 +22,8 @@ def integration_from_config(config: IntegrationConfig, store: BaseStore | None =
22
22
 
23
23
  __all__ = [
24
24
  "AgentRIntegration",
25
- "Integration",
26
25
  "ApiKeyIntegration",
26
+ "Integration",
27
27
  "OAuthIntegration",
28
28
  "integration_from_config",
29
29
  ]
@@ -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(ABC):
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
- self.store = store
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
- pass
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
- pass
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 = MemoryStore(), **kwargs):
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]
@@ -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, ABC):
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
- super().__init__(config.name, config.description, port=config.port, **kwargs)
31
- logger.info(f"Initializing server: {config.name} ({config.type}) with store: {config.store}")
32
-
33
- self.config = config # Store config at base level for consistency
34
- self._tool_manager = ToolManager(warn_on_duplicate_tools=True)
35
-
36
- @abstractmethod
37
- def _load_apps(self) -> None:
38
- """Load and register applications."""
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
- result = await self._tool_manager.call_tool(name, arguments)
89
- logger.info(f"Tool '{name}' completed successfully")
90
- return self._format_tool_result(result)
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
- store = store_from_config(store_config)
119
- self.add_tool(store.set)
120
- self.add_tool(store.delete)
121
- return store
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
- integration_from_config(app_config.integration, store=self.store) if app_config.integration else None
135
- )
136
- return app_from_slug(app_config.name)(integration=integration)
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
- logger.info(f"Loading apps: {self.config.apps}")
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
- self._tool_manager.register_tools_from_app(app, app_config.actions)
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.client = AgentrClient(api_key=api_key)
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
- return [AppConfig.model_validate(app) for app in apps]
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
- return app_from_slug(app_config.name)(integration=integration)
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
- for app_config in self._fetch_apps():
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
- if app_instance
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)