omnimancer-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. omnimancer/__init__.py +42 -0
  2. omnimancer/__main__.py +9 -0
  3. omnimancer/cli/__init__.py +8 -0
  4. omnimancer/cli/agent_manager.py +826 -0
  5. omnimancer/cli/agent_wizard.py +944 -0
  6. omnimancer/cli/approval_formatter.py +734 -0
  7. omnimancer/cli/approval_integration.py +734 -0
  8. omnimancer/cli/approval_prompt.py +1286 -0
  9. omnimancer/cli/batch_approval_display.py +617 -0
  10. omnimancer/cli/batch_approval_filters.py +729 -0
  11. omnimancer/cli/batch_operation_monitor.py +775 -0
  12. omnimancer/cli/commands/performance_cmd.py +624 -0
  13. omnimancer/cli/commands.py +757 -0
  14. omnimancer/cli/completion_handler.py +281 -0
  15. omnimancer/cli/config_setup_wizard.py +783 -0
  16. omnimancer/cli/dynamic_commands.py +371 -0
  17. omnimancer/cli/handlers/__init__.py +18 -0
  18. omnimancer/cli/handlers/agent_handler.py +865 -0
  19. omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
  20. omnimancer/cli/handlers/custom_agent_handler.py +352 -0
  21. omnimancer/cli/handlers/permissions_handler.py +1609 -0
  22. omnimancer/cli/interface.py +6695 -0
  23. omnimancer/cli/performance_dashboard.py +612 -0
  24. omnimancer/core/__init__.py +34 -0
  25. omnimancer/core/agent/__init__.py +256 -0
  26. omnimancer/core/agent/agent_config.py +715 -0
  27. omnimancer/core/agent/agent_switcher.py +668 -0
  28. omnimancer/core/agent/approval_context.py +460 -0
  29. omnimancer/core/agent/approval_dialog.py +902 -0
  30. omnimancer/core/agent/approval_interface.py +628 -0
  31. omnimancer/core/agent/approval_manager.py +760 -0
  32. omnimancer/core/agent/cli_integration.py +470 -0
  33. omnimancer/core/agent/config.py +845 -0
  34. omnimancer/core/agent/custom_agent_integration.py +654 -0
  35. omnimancer/core/agent/diff_renderer.py +706 -0
  36. omnimancer/core/agent/file_content_display.py +503 -0
  37. omnimancer/core/agent/file_modification_workflow.py +658 -0
  38. omnimancer/core/agent/file_system_manager.py +1790 -0
  39. omnimancer/core/agent/input_handler.py +614 -0
  40. omnimancer/core/agent/metrics_collector.py +771 -0
  41. omnimancer/core/agent/optimization_engine.py +729 -0
  42. omnimancer/core/agent/performance_monitor.py +717 -0
  43. omnimancer/core/agent/persona.py +870 -0
  44. omnimancer/core/agent/persona_validator.py +672 -0
  45. omnimancer/core/agent/program_executor.py +748 -0
  46. omnimancer/core/agent/proposed_changes_integration.py +663 -0
  47. omnimancer/core/agent/read_before_write_errors.py +492 -0
  48. omnimancer/core/agent/read_before_write_ui.py +647 -0
  49. omnimancer/core/agent/rich_renderer.py +763 -0
  50. omnimancer/core/agent/status_config.py +238 -0
  51. omnimancer/core/agent/status_core.py +446 -0
  52. omnimancer/core/agent/status_integrated_engine.py +540 -0
  53. omnimancer/core/agent/status_manager.py +878 -0
  54. omnimancer/core/agent/status_ui_components.py +535 -0
  55. omnimancer/core/agent/template_applicator.py +548 -0
  56. omnimancer/core/agent/terminal_status_listener.py +394 -0
  57. omnimancer/core/agent/token_tracker.py +679 -0
  58. omnimancer/core/agent/types.py +60 -0
  59. omnimancer/core/agent/web_client.py +745 -0
  60. omnimancer/core/agent/workflow_orchestrator.py +826 -0
  61. omnimancer/core/agent_engine.py +1625 -0
  62. omnimancer/core/agent_mode_manager.py +706 -0
  63. omnimancer/core/agent_progress_ui.py +545 -0
  64. omnimancer/core/chat_manager.py +141 -0
  65. omnimancer/core/config_manager.py +2199 -0
  66. omnimancer/core/config_migration.py +772 -0
  67. omnimancer/core/config_provider.py +1974 -0
  68. omnimancer/core/config_security_validator.py +393 -0
  69. omnimancer/core/config_validator.py +768 -0
  70. omnimancer/core/conversation_manager.py +352 -0
  71. omnimancer/core/engine.py +787 -0
  72. omnimancer/core/enhanced_input.py +523 -0
  73. omnimancer/core/env_loader.py +83 -0
  74. omnimancer/core/fallback_manager.py +568 -0
  75. omnimancer/core/health_monitor.py +462 -0
  76. omnimancer/core/history_manager.py +462 -0
  77. omnimancer/core/mcp_integration_layer.py +878 -0
  78. omnimancer/core/models.py +1889 -0
  79. omnimancer/core/provider_capabilities.py +403 -0
  80. omnimancer/core/provider_initializer.py +450 -0
  81. omnimancer/core/provider_registry.py +432 -0
  82. omnimancer/core/security/__init__.py +16 -0
  83. omnimancer/core/security/approval_workflow.py +406 -0
  84. omnimancer/core/security/audit_logger.py +511 -0
  85. omnimancer/core/security/permission_controller.py +511 -0
  86. omnimancer/core/security/sandbox_manager.py +379 -0
  87. omnimancer/core/security/security_manager.py +530 -0
  88. omnimancer/core/setup_wizard.py +345 -0
  89. omnimancer/core/setup_wizard_core.py +449 -0
  90. omnimancer/core/setup_wizard_guide_generator.py +376 -0
  91. omnimancer/core/setup_wizard_provider_setup.py +454 -0
  92. omnimancer/core/setup_wizard_ui.py +317 -0
  93. omnimancer/core/setup_wizard_validation.py +371 -0
  94. omnimancer/core/signal_handler.py +174 -0
  95. omnimancer/mcp/__init__.py +11 -0
  96. omnimancer/mcp/client.py +725 -0
  97. omnimancer/mcp/manager.py +649 -0
  98. omnimancer/providers/__init__.py +24 -0
  99. omnimancer/providers/azure.py +608 -0
  100. omnimancer/providers/base.py +262 -0
  101. omnimancer/providers/bedrock.py +711 -0
  102. omnimancer/providers/claude.py +426 -0
  103. omnimancer/providers/claude_code.py +406 -0
  104. omnimancer/providers/cohere.py +412 -0
  105. omnimancer/providers/factory.py +988 -0
  106. omnimancer/providers/gemini.py +708 -0
  107. omnimancer/providers/mistral.py +554 -0
  108. omnimancer/providers/ollama.py +510 -0
  109. omnimancer/providers/openai.py +362 -0
  110. omnimancer/providers/openrouter.py +709 -0
  111. omnimancer/providers/perplexity.py +476 -0
  112. omnimancer/providers/vertex.py +617 -0
  113. omnimancer/providers/xai.py +484 -0
  114. omnimancer/ui/__init__.py +6 -0
  115. omnimancer/ui/cancellation_handler.py +235 -0
  116. omnimancer/ui/progress_indicator.py +318 -0
  117. omnimancer/utils/__init__.py +22 -0
  118. omnimancer/utils/error_handler.py +589 -0
  119. omnimancer/utils/errors.py +667 -0
  120. omnimancer/utils/retry.py +238 -0
  121. omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
  122. omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
  123. omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
  124. omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
  125. omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  126. omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,725 @@
1
+ """
2
+ MCP Client for connecting to and communicating with MCP servers.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import subprocess
9
+ import time
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from ..core.models import (
13
+ MCPServerConfig,
14
+ MCPServerError,
15
+ MCPToolError,
16
+ ToolCall,
17
+ ToolDefinition,
18
+ ToolResult,
19
+ )
20
+ from ..utils.errors import (
21
+ MCPConfigurationError,
22
+ MCPConnectionError,
23
+ MCPTimeoutError,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class MCPClient:
30
+ """
31
+ Client for communicating with MCP servers.
32
+
33
+ This class handles the connection lifecycle, tool discovery,
34
+ and tool execution for a single MCP server.
35
+ """
36
+
37
+ def __init__(self, server_config: MCPServerConfig):
38
+ """
39
+ Initialize MCP client.
40
+
41
+ Args:
42
+ server_config: Configuration for the MCP server
43
+ """
44
+ self.server_config = server_config
45
+ self.process: Optional[subprocess.Popen] = None
46
+ self.connected = False
47
+ self.tools: Dict[str, ToolDefinition] = {}
48
+ self._request_id = 0
49
+
50
+ async def connect(self, retry_count: int = 0, max_retries: int = 3) -> None:
51
+ """
52
+ Connect to the MCP server with retry logic and comprehensive error handling.
53
+
54
+ Args:
55
+ retry_count: Current retry attempt
56
+ max_retries: Maximum number of retry attempts
57
+
58
+ Raises:
59
+ MCPConnectionError: If connection fails after retries
60
+ MCPConfigurationError: If server configuration is invalid
61
+ """
62
+ if self.connected:
63
+ return
64
+
65
+ try:
66
+ logger.info(
67
+ f"Connecting to MCP server: {self.server_config.name} (attempt {retry_count + 1})"
68
+ )
69
+
70
+ # Validate server configuration
71
+ await self._validate_server_config()
72
+
73
+ # Prepare environment variables
74
+ env = dict(self.server_config.env)
75
+
76
+ # Start the MCP server process
77
+ try:
78
+ self.process = subprocess.Popen(
79
+ [self.server_config.command] + self.server_config.args,
80
+ stdin=subprocess.PIPE,
81
+ stdout=subprocess.PIPE,
82
+ stderr=subprocess.PIPE,
83
+ env=env,
84
+ text=True,
85
+ bufsize=0,
86
+ )
87
+ except FileNotFoundError:
88
+ raise MCPConnectionError(
89
+ f"Failed to start MCP server: {self.server_config.command} not found",
90
+ server_name=self.server_config.name,
91
+ connection_type="stdio",
92
+ )
93
+ except PermissionError:
94
+ raise MCPConnectionError(
95
+ f"Failed to start MCP server: Permission denied for {self.server_config.command}",
96
+ server_name=self.server_config.name,
97
+ connection_type="stdio",
98
+ )
99
+
100
+ # Wait for process to start with exponential backoff
101
+ initial_wait = 0.1 * (2**retry_count) # Exponential backoff
102
+ await asyncio.sleep(min(initial_wait, 2.0))
103
+
104
+ # Check if process is still running
105
+ if self.process.poll() is not None:
106
+ stderr_output = ""
107
+ if self.process.stderr:
108
+ try:
109
+ stderr_output = self.process.stderr.read()
110
+ except:
111
+ stderr_output = "Unable to read stderr"
112
+
113
+ # Analyze stderr for specific error types
114
+ error_msg = f"MCP server '{self.server_config.name}' failed to start"
115
+ if stderr_output:
116
+ error_msg += f": {stderr_output}"
117
+
118
+ if "command not found" in stderr_output.lower():
119
+ raise MCPConfigurationError(
120
+ f"MCP server command not found: {self.server_config.command}",
121
+ server_name=self.server_config.name,
122
+ details="Install the required MCP server package",
123
+ )
124
+ elif "permission denied" in stderr_output.lower():
125
+ raise MCPConfigurationError(
126
+ f"Permission denied for MCP server: {self.server_config.command}",
127
+ server_name=self.server_config.name,
128
+ details="Check file permissions",
129
+ )
130
+ elif (
131
+ "module not found" in stderr_output.lower()
132
+ or "importerror" in stderr_output.lower()
133
+ ):
134
+ raise MCPConfigurationError(
135
+ f"Missing dependencies for MCP server: {self.server_config.name}",
136
+ server_name=self.server_config.name,
137
+ details="Install required Python packages or dependencies",
138
+ )
139
+ else:
140
+ raise MCPConnectionError(
141
+ error_msg,
142
+ server_name=self.server_config.name,
143
+ connection_type="stdio",
144
+ retry_suggestion="Check server logs and configuration",
145
+ )
146
+
147
+ # Initialize connection with the server
148
+ try:
149
+ await self._initialize_connection()
150
+ except MCPTimeoutError:
151
+ if retry_count < max_retries:
152
+ logger.warning(
153
+ f"Connection timeout for {self.server_config.name}, retrying..."
154
+ )
155
+ await self.disconnect()
156
+ await asyncio.sleep(1.0 * (retry_count + 1)) # Progressive delay
157
+ return await self.connect(retry_count + 1, max_retries)
158
+ else:
159
+ raise MCPTimeoutError(
160
+ f"Connection initialization timed out for server '{self.server_config.name}'",
161
+ server_name=self.server_config.name,
162
+ timeout_duration=self.server_config.timeout,
163
+ operation="initialization",
164
+ )
165
+
166
+ # Discover available tools
167
+ try:
168
+ await self._discover_tools()
169
+ except Exception as e:
170
+ logger.warning(
171
+ f"Tool discovery failed for {self.server_config.name}: {e}"
172
+ )
173
+ # Continue connection even if tool discovery fails
174
+
175
+ self.connected = True
176
+ logger.info(
177
+ f"Successfully connected to MCP server: {self.server_config.name}"
178
+ )
179
+
180
+ except (MCPConnectionError, MCPConfigurationError, MCPTimeoutError):
181
+ await self.disconnect()
182
+ raise
183
+ except Exception as e:
184
+ await self.disconnect()
185
+
186
+ # Retry logic for transient errors
187
+ if retry_count < max_retries and self._is_retryable_error(e):
188
+ logger.warning(f"Retryable error for {self.server_config.name}: {e}")
189
+ await asyncio.sleep(2.0 * (retry_count + 1)) # Progressive delay
190
+ return await self.connect(retry_count + 1, max_retries)
191
+
192
+ raise MCPConnectionError(
193
+ f"Failed to start MCP server: {e}",
194
+ server_name=self.server_config.name,
195
+ connection_type="stdio",
196
+ )
197
+
198
+ async def disconnect(self) -> None:
199
+ """Disconnect from the MCP server."""
200
+ if self.process:
201
+ try:
202
+ self.process.terminate()
203
+ # Wait for graceful shutdown
204
+ try:
205
+ self.process.wait(timeout=5)
206
+ except subprocess.TimeoutExpired:
207
+ self.process.kill()
208
+ self.process.wait()
209
+ except Exception as e:
210
+ logger.warning(f"Error during MCP server shutdown: {e}")
211
+ finally:
212
+ self.process = None
213
+
214
+ self.connected = False
215
+ self.tools.clear()
216
+ logger.info(f"Disconnected from MCP server: {self.server_config.name}")
217
+
218
+ async def list_tools(self) -> List[ToolDefinition]:
219
+ """
220
+ List available tools from the MCP server.
221
+
222
+ Returns:
223
+ List of available tool definitions
224
+
225
+ Raises:
226
+ MCPServerError: If not connected or listing fails
227
+ """
228
+ if not self.connected:
229
+ raise MCPServerError(
230
+ f"Not connected to MCP server: {self.server_config.name}"
231
+ )
232
+
233
+ return list(self.tools.values())
234
+
235
+ async def call_tool(self, name: str, arguments: Dict[str, Any]) -> ToolResult:
236
+ """
237
+ Execute a tool call on the MCP server.
238
+
239
+ Args:
240
+ name: Name of the tool to call
241
+ arguments: Arguments to pass to the tool
242
+
243
+ Returns:
244
+ Result of the tool execution
245
+
246
+ Raises:
247
+ MCPToolError: If tool execution fails
248
+ MCPServerError: If not connected
249
+ """
250
+ if not self.connected:
251
+ raise MCPServerError(
252
+ f"Not connected to MCP server: {self.server_config.name}"
253
+ )
254
+
255
+ if name not in self.tools:
256
+ raise MCPToolError(
257
+ f"Tool '{name}' not found on server '{self.server_config.name}'"
258
+ )
259
+
260
+ try:
261
+ logger.debug(f"Calling tool '{name}' with arguments: {arguments}")
262
+
263
+ # Create tool call request
264
+ request = {
265
+ "jsonrpc": "2.0",
266
+ "id": self._get_next_request_id(),
267
+ "method": "tools/call",
268
+ "params": {"name": name, "arguments": arguments},
269
+ }
270
+
271
+ # Send request and get response
272
+ response = await self._send_request(request)
273
+
274
+ # Parse response
275
+ if "error" in response:
276
+ error_info = response["error"]
277
+ raise MCPToolError(
278
+ f"Tool '{name}' execution failed: {error_info.get('message', 'Unknown error')}"
279
+ )
280
+
281
+ result_data = response.get("result", {})
282
+
283
+ return ToolResult(
284
+ content=result_data.get("content", ""),
285
+ error=result_data.get("error"),
286
+ metadata=result_data.get("metadata", {}),
287
+ )
288
+
289
+ except MCPToolError:
290
+ raise
291
+ except (asyncio.TimeoutError, TimeoutError):
292
+ raise MCPTimeoutError(
293
+ f"Tool call timed out for '{name}'",
294
+ server_name=self.server_config.name,
295
+ timeout_duration=self.server_config.timeout,
296
+ operation="tool_call",
297
+ )
298
+ except Exception as e:
299
+ raise MCPToolError(f"Failed to execute tool '{name}': {e}")
300
+
301
+ async def _initialize_connection(self) -> None:
302
+ """Initialize connection with the MCP server."""
303
+ # Send initialization request
304
+ init_request = {
305
+ "jsonrpc": "2.0",
306
+ "id": self._get_next_request_id(),
307
+ "method": "initialize",
308
+ "params": {
309
+ "protocolVersion": "2024-11-05",
310
+ "capabilities": {"tools": {}},
311
+ "clientInfo": {"name": "omnimancer-cli", "version": "1.0.0"},
312
+ },
313
+ }
314
+
315
+ response = await self._send_request(init_request)
316
+
317
+ if "error" in response:
318
+ raise MCPServerError(f"Server initialization failed: {response['error']}")
319
+
320
+ # Check protocol version
321
+ result = response.get("result", {})
322
+ protocol_version = result.get("protocolVersion")
323
+ if protocol_version and protocol_version not in ["1.0", "2024-11-05"]:
324
+ raise MCPConnectionError(
325
+ f"Unsupported protocol version: {protocol_version}"
326
+ )
327
+
328
+ # Send initialized notification
329
+ initialized_notification = {
330
+ "jsonrpc": "2.0",
331
+ "method": "notifications/initialized",
332
+ }
333
+
334
+ await self._send_notification(initialized_notification)
335
+
336
+ async def _discover_tools(self) -> None:
337
+ """Discover available tools from the MCP server."""
338
+ # Request tools list
339
+ tools_request = {
340
+ "jsonrpc": "2.0",
341
+ "id": self._get_next_request_id(),
342
+ "method": "tools/list",
343
+ }
344
+
345
+ response = await self._send_request(tools_request)
346
+
347
+ if "error" in response:
348
+ logger.warning(f"Failed to discover tools: {response['error']}")
349
+ return
350
+
351
+ # Parse tools from response
352
+ tools_data = response.get("result", {}).get("tools", [])
353
+
354
+ for tool_data in tools_data:
355
+ tool_def = ToolDefinition(
356
+ name=tool_data.get("name", ""),
357
+ description=tool_data.get("description", ""),
358
+ parameters=tool_data.get("inputSchema", {}),
359
+ server_name=self.server_config.name,
360
+ auto_approved=tool_data.get("name", "")
361
+ in self.server_config.auto_approve,
362
+ )
363
+ self.tools[tool_def.name] = tool_def
364
+
365
+ logger.info(
366
+ f"Discovered {len(self.tools)} tools from server '{self.server_config.name}'"
367
+ )
368
+
369
+ async def _send_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
370
+ """
371
+ Send a JSON-RPC request to the MCP server and wait for response.
372
+
373
+ Args:
374
+ request: JSON-RPC request object
375
+
376
+ Returns:
377
+ JSON-RPC response object
378
+
379
+ Raises:
380
+ MCPServerError: If communication fails
381
+ """
382
+ if not self.process or not self.process.stdin or not self.process.stdout:
383
+ raise MCPServerError("MCP server process not available")
384
+
385
+ try:
386
+ # Send request
387
+ request_json = json.dumps(request) + "\n"
388
+ self.process.stdin.write(request_json)
389
+ self.process.stdin.flush()
390
+
391
+ # Read response with timeout
392
+ start_time = time.time()
393
+ while time.time() - start_time < self.server_config.timeout:
394
+ if self.process.stdout.readable():
395
+ line = self.process.stdout.readline()
396
+ if line.strip():
397
+ response = json.loads(line.strip())
398
+
399
+ # Check for error in response
400
+ if "error" in response:
401
+ error_info = response["error"]
402
+ raise MCPServerError(
403
+ error_info.get("message", "Unknown error")
404
+ )
405
+
406
+ # Increment request ID to match test expectations
407
+ if "id" in request:
408
+ self._request_id = max(self._request_id, request["id"])
409
+
410
+ return response
411
+ await asyncio.sleep(0.01)
412
+
413
+ raise MCPServerError(
414
+ f"Request timeout after {self.server_config.timeout} seconds"
415
+ )
416
+
417
+ except json.JSONDecodeError as e:
418
+ raise MCPServerError(f"Invalid JSON response from server: {e}")
419
+ except Exception as e:
420
+ raise MCPServerError(f"Communication error with MCP server: {e}")
421
+
422
+ async def _send_notification(self, notification: Dict[str, Any]) -> None:
423
+ """
424
+ Send a JSON-RPC notification to the MCP server (no response expected).
425
+
426
+ Args:
427
+ notification: JSON-RPC notification object
428
+
429
+ Raises:
430
+ MCPServerError: If communication fails
431
+ """
432
+ if not self.process or not self.process.stdin:
433
+ raise MCPServerError("MCP server process not available")
434
+
435
+ try:
436
+ notification_json = json.dumps(notification) + "\n"
437
+ self.process.stdin.write(notification_json)
438
+ self.process.stdin.flush()
439
+ except Exception as e:
440
+ raise MCPServerError(f"Failed to send notification: {e}")
441
+
442
+ def _get_next_request_id(self) -> int:
443
+ """Get the next request ID."""
444
+ self._request_id += 1
445
+ return self._request_id
446
+
447
+ @property
448
+ def is_connected(self) -> bool:
449
+ """Check if client is connected to the server."""
450
+ return self.connected and self.process and self.process.poll() is None
451
+
452
+ @property
453
+ def server_name(self) -> str:
454
+ """Get the server name."""
455
+ return self.server_config.name
456
+
457
+ def get_tool_definition(self, tool_name: str) -> Optional[ToolDefinition]:
458
+ """
459
+ Get definition for a specific tool.
460
+
461
+ Args:
462
+ tool_name: Name of the tool
463
+
464
+ Returns:
465
+ ToolDefinition or None if not found
466
+ """
467
+ return self.tools.get(tool_name)
468
+
469
+ async def health_check(self) -> bool:
470
+ """
471
+ Perform a health check on the MCP server connection.
472
+
473
+ Returns:
474
+ True if server is healthy, False otherwise
475
+ """
476
+ try:
477
+ if not self.is_connected:
478
+ self.connected = False
479
+ return False
480
+
481
+ # Check if process is still running
482
+ if self.process and self.process.poll() is not None:
483
+ self.connected = False
484
+ return False
485
+
486
+ # For basic health check, just verify connection state
487
+ return self.connected and self.process is not None
488
+
489
+ except Exception:
490
+ self.connected = False
491
+ return False
492
+
493
+ async def _validate_server_config(self) -> None:
494
+ """
495
+ Validate server configuration before attempting connection.
496
+
497
+ Raises:
498
+ MCPConfigurationError: If configuration is invalid
499
+ """
500
+ if not self.server_config.command:
501
+ raise MCPConfigurationError(
502
+ "MCP server command is required",
503
+ server_name=self.server_config.name,
504
+ details="Specify the command to run the MCP server",
505
+ )
506
+
507
+ if not self.server_config.name:
508
+ raise MCPConfigurationError(
509
+ "MCP server name is required",
510
+ details="Provide a unique name for the MCP server",
511
+ )
512
+
513
+ # Validate timeout
514
+ if self.server_config.timeout <= 0:
515
+ raise MCPConfigurationError(
516
+ f"Invalid timeout value: {self.server_config.timeout}",
517
+ server_name=self.server_config.name,
518
+ details="Timeout must be a positive number",
519
+ )
520
+
521
+ # Check if command exists (basic check)
522
+ import shutil
523
+
524
+ if not shutil.which(self.server_config.command):
525
+ logger.warning(f"Command '{self.server_config.command}' not found in PATH")
526
+
527
+ def _is_retryable_error(self, error: Exception) -> bool:
528
+ """
529
+ Determine if an error is retryable.
530
+
531
+ Args:
532
+ error: The exception to check
533
+
534
+ Returns:
535
+ True if the error is retryable, False otherwise
536
+ """
537
+ # Configuration errors are not retryable
538
+ if isinstance(error, MCPConfigurationError):
539
+ return False
540
+
541
+ # Connection errors might be retryable
542
+ if isinstance(error, MCPConnectionError):
543
+ return True
544
+
545
+ # Timeout errors are retryable
546
+ if isinstance(error, MCPTimeoutError):
547
+ return True
548
+
549
+ # Check for specific error messages that indicate transient issues
550
+ error_str = str(error).lower()
551
+ transient_indicators = [
552
+ "connection refused",
553
+ "timeout",
554
+ "temporary failure",
555
+ "resource temporarily unavailable",
556
+ "broken pipe",
557
+ ]
558
+
559
+ return any(indicator in error_str for indicator in transient_indicators)
560
+
561
+ async def reconnect(self) -> bool:
562
+ """
563
+ Attempt to reconnect to the MCP server.
564
+
565
+ Returns:
566
+ True if reconnection successful, False otherwise
567
+ """
568
+ try:
569
+ logger.info(
570
+ f"Attempting to reconnect to MCP server: {self.server_config.name}"
571
+ )
572
+
573
+ # Disconnect first
574
+ await self.disconnect()
575
+
576
+ # Wait a moment before reconnecting
577
+ await asyncio.sleep(1.0)
578
+
579
+ # Attempt to connect
580
+ await self.connect()
581
+
582
+ return self.is_connected
583
+
584
+ except Exception as e:
585
+ logger.error(f"Reconnection failed for {self.server_config.name}: {e}")
586
+ return False
587
+
588
+ async def _perform_handshake(self) -> bool:
589
+ """Perform handshake with the MCP server."""
590
+ await self._initialize_connection()
591
+ return True
592
+
593
+ async def _read_response(self) -> Dict[str, Any]:
594
+ """Read a response from the MCP server."""
595
+ if not self.process or not self.process.stdout:
596
+ raise MCPServerError("MCP server process not available")
597
+
598
+ try:
599
+ line = self.process.stdout.readline()
600
+ if not line.strip():
601
+ raise MCPServerError("Empty response from server")
602
+
603
+ return json.loads(line.strip())
604
+ except json.JSONDecodeError as e:
605
+ raise MCPServerError(f"Invalid JSON response: {e}")
606
+
607
+ def get_available_tools(self) -> List[ToolDefinition]:
608
+ """Get list of available tools."""
609
+ return list(self.tools.values())
610
+
611
+ def get_server_info(self) -> Dict[str, Any]:
612
+ """Get server information."""
613
+ return {
614
+ "name": self.server_config.name,
615
+ "command": self.server_config.command,
616
+ "args": self.server_config.args,
617
+ "enabled": self.server_config.enabled,
618
+ "connected": self.connected,
619
+ "tool_count": len(self.tools),
620
+ }
621
+
622
+ def _validate_tool_call(self, tool_call: ToolCall) -> None:
623
+ """Validate a tool call."""
624
+ if not tool_call.name:
625
+ raise MCPToolError("Tool name is required")
626
+
627
+ if tool_call.name not in self.tools:
628
+ raise MCPToolError(f"Tool '{tool_call.name}' not found")
629
+
630
+ tool_def = self.tools[tool_call.name]
631
+
632
+ # Basic parameter validation
633
+ if "required" in tool_def.parameters:
634
+ required_params = tool_def.parameters["required"]
635
+ for param in required_params:
636
+ if param not in tool_call.arguments:
637
+ raise MCPToolError(
638
+ f"Missing required parameter '{param}' for tool '{tool_call.name}'"
639
+ )
640
+
641
+ # Parameter type validation
642
+ if "properties" in tool_def.parameters:
643
+ properties = tool_def.parameters["properties"]
644
+ for param_name, param_value in tool_call.arguments.items():
645
+ if param_name in properties:
646
+ expected_type = properties[param_name].get("type")
647
+ if expected_type == "number" and not isinstance(
648
+ param_value, (int, float)
649
+ ):
650
+ raise MCPToolError(
651
+ f"Invalid parameter type for '{param_name}': expected number, got {type(param_value).__name__}"
652
+ )
653
+ elif expected_type == "string" and not isinstance(param_value, str):
654
+ raise MCPToolError(
655
+ f"Invalid parameter type for '{param_name}': expected string, got {type(param_value).__name__}"
656
+ )
657
+
658
+ async def call_tool_with_retry(
659
+ self, name: str, arguments: Dict[str, Any], max_retries: int = 2
660
+ ) -> ToolResult:
661
+ """
662
+ Execute a tool call with retry logic for transient failures.
663
+
664
+ Args:
665
+ name: Name of the tool to call
666
+ arguments: Arguments to pass to the tool
667
+ max_retries: Maximum number of retry attempts
668
+
669
+ Returns:
670
+ Result of the tool execution
671
+
672
+ Raises:
673
+ MCPToolError: If tool execution fails after retries
674
+ MCPServerError: If not connected
675
+ """
676
+ last_error = None
677
+
678
+ for attempt in range(max_retries + 1):
679
+ try:
680
+ return await self.call_tool(name, arguments)
681
+
682
+ except MCPToolError as e:
683
+ last_error = e
684
+
685
+ # Don't retry for certain types of tool errors
686
+ error_msg = str(e).lower()
687
+ if any(
688
+ indicator in error_msg
689
+ for indicator in [
690
+ "not found",
691
+ "invalid parameter",
692
+ "permission denied",
693
+ ]
694
+ ):
695
+ raise
696
+
697
+ if attempt < max_retries:
698
+ logger.warning(
699
+ f"Tool call failed (attempt {attempt + 1}), retrying: {e}"
700
+ )
701
+ await asyncio.sleep(1.0 * (attempt + 1)) # Progressive delay
702
+
703
+ # Try to reconnect if connection seems broken
704
+ if not self.is_connected:
705
+ await self.reconnect()
706
+
707
+ except MCPServerError as e:
708
+ last_error = e
709
+
710
+ if attempt < max_retries:
711
+ logger.warning(
712
+ f"Server error (attempt {attempt + 1}), retrying: {e}"
713
+ )
714
+ await asyncio.sleep(2.0 * (attempt + 1)) # Progressive delay
715
+
716
+ # Try to reconnect
717
+ await self.reconnect()
718
+
719
+ # If we get here, all retries failed
720
+ if last_error:
721
+ raise last_error
722
+ else:
723
+ raise MCPToolError(
724
+ f"Tool '{name}' execution failed after {max_retries} retries"
725
+ )