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.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
omnimancer/mcp/client.py
ADDED
|
@@ -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
|
+
)
|