chuk-tool-processor 0.6.4__py3-none-any.whl → 0.9.7__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.
Potentially problematic release.
This version of chuk-tool-processor might be problematic. Click here for more details.
- chuk_tool_processor/core/__init__.py +32 -1
- chuk_tool_processor/core/exceptions.py +225 -13
- chuk_tool_processor/core/processor.py +135 -104
- chuk_tool_processor/execution/strategies/__init__.py +6 -0
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +142 -150
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +202 -206
- chuk_tool_processor/execution/tool_executor.py +82 -84
- chuk_tool_processor/execution/wrappers/__init__.py +42 -0
- chuk_tool_processor/execution/wrappers/caching.py +150 -116
- chuk_tool_processor/execution/wrappers/circuit_breaker.py +370 -0
- chuk_tool_processor/execution/wrappers/rate_limiting.py +76 -43
- chuk_tool_processor/execution/wrappers/retry.py +116 -78
- chuk_tool_processor/logging/__init__.py +23 -17
- chuk_tool_processor/logging/context.py +40 -45
- chuk_tool_processor/logging/formatter.py +22 -21
- chuk_tool_processor/logging/helpers.py +28 -42
- chuk_tool_processor/logging/metrics.py +13 -15
- chuk_tool_processor/mcp/__init__.py +8 -12
- chuk_tool_processor/mcp/mcp_tool.py +158 -114
- chuk_tool_processor/mcp/register_mcp_tools.py +22 -22
- chuk_tool_processor/mcp/setup_mcp_http_streamable.py +57 -17
- chuk_tool_processor/mcp/setup_mcp_sse.py +57 -17
- chuk_tool_processor/mcp/setup_mcp_stdio.py +11 -11
- chuk_tool_processor/mcp/stream_manager.py +333 -276
- chuk_tool_processor/mcp/transport/__init__.py +22 -29
- chuk_tool_processor/mcp/transport/base_transport.py +180 -44
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +505 -325
- chuk_tool_processor/mcp/transport/models.py +100 -0
- chuk_tool_processor/mcp/transport/sse_transport.py +607 -276
- chuk_tool_processor/mcp/transport/stdio_transport.py +597 -116
- chuk_tool_processor/models/__init__.py +21 -1
- chuk_tool_processor/models/execution_strategy.py +16 -21
- chuk_tool_processor/models/streaming_tool.py +28 -25
- chuk_tool_processor/models/tool_call.py +49 -31
- chuk_tool_processor/models/tool_export_mixin.py +22 -8
- chuk_tool_processor/models/tool_result.py +40 -77
- chuk_tool_processor/models/tool_spec.py +350 -0
- chuk_tool_processor/models/validated_tool.py +36 -18
- chuk_tool_processor/observability/__init__.py +30 -0
- chuk_tool_processor/observability/metrics.py +312 -0
- chuk_tool_processor/observability/setup.py +105 -0
- chuk_tool_processor/observability/tracing.py +345 -0
- chuk_tool_processor/plugins/__init__.py +1 -1
- chuk_tool_processor/plugins/discovery.py +11 -11
- chuk_tool_processor/plugins/parsers/__init__.py +1 -1
- chuk_tool_processor/plugins/parsers/base.py +1 -2
- chuk_tool_processor/plugins/parsers/function_call_tool.py +13 -8
- chuk_tool_processor/plugins/parsers/json_tool.py +4 -3
- chuk_tool_processor/plugins/parsers/openai_tool.py +12 -7
- chuk_tool_processor/plugins/parsers/xml_tool.py +4 -4
- chuk_tool_processor/registry/__init__.py +12 -12
- chuk_tool_processor/registry/auto_register.py +22 -30
- chuk_tool_processor/registry/decorators.py +127 -129
- chuk_tool_processor/registry/interface.py +26 -23
- chuk_tool_processor/registry/metadata.py +27 -22
- chuk_tool_processor/registry/provider.py +17 -18
- chuk_tool_processor/registry/providers/__init__.py +16 -19
- chuk_tool_processor/registry/providers/memory.py +18 -25
- chuk_tool_processor/registry/tool_export.py +42 -51
- chuk_tool_processor/utils/validation.py +15 -16
- chuk_tool_processor-0.9.7.dist-info/METADATA +1813 -0
- chuk_tool_processor-0.9.7.dist-info/RECORD +67 -0
- chuk_tool_processor-0.6.4.dist-info/METADATA +0 -697
- chuk_tool_processor-0.6.4.dist-info/RECORD +0 -60
- {chuk_tool_processor-0.6.4.dist-info → chuk_tool_processor-0.9.7.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.6.4.dist-info → chuk_tool_processor-0.9.7.dist-info}/top_level.txt +0 -0
|
@@ -1,236 +1,717 @@
|
|
|
1
|
-
# chuk_tool_processor/mcp/transport/stdio_transport.py
|
|
1
|
+
# chuk_tool_processor/mcp/transport/stdio_transport.py - ENHANCED
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
5
|
import json
|
|
6
|
-
from typing import Dict, Any, List, Optional
|
|
7
6
|
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
from chuk_mcp.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
import psutil
|
|
12
|
+
from chuk_mcp.protocol.messages import ( # type: ignore[import-untyped]
|
|
13
|
+
send_initialize,
|
|
14
|
+
send_ping,
|
|
15
|
+
send_prompts_get,
|
|
16
|
+
send_prompts_list,
|
|
17
|
+
send_resources_list,
|
|
18
|
+
send_resources_read,
|
|
19
|
+
send_tools_call,
|
|
20
|
+
send_tools_list,
|
|
14
21
|
)
|
|
22
|
+
from chuk_mcp.transports.stdio import stdio_client # type: ignore[import-untyped]
|
|
23
|
+
from chuk_mcp.transports.stdio.parameters import StdioParameters # type: ignore[import-untyped]
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
try:
|
|
18
|
-
from chuk_mcp.protocol.messages import send_resources_list, send_resources_read
|
|
19
|
-
HAS_RESOURCES = True
|
|
20
|
-
except ImportError:
|
|
21
|
-
HAS_RESOURCES = False
|
|
22
|
-
|
|
23
|
-
try:
|
|
24
|
-
from chuk_mcp.protocol.messages import send_prompts_list, send_prompts_get
|
|
25
|
-
HAS_PROMPTS = True
|
|
26
|
-
except ImportError:
|
|
27
|
-
HAS_PROMPTS = False
|
|
25
|
+
from .base_transport import MCPBaseTransport
|
|
28
26
|
|
|
29
27
|
logger = logging.getLogger(__name__)
|
|
30
28
|
|
|
31
29
|
|
|
32
30
|
class StdioTransport(MCPBaseTransport):
|
|
33
|
-
"""
|
|
31
|
+
"""
|
|
32
|
+
STDIO transport for MCP communication using process pipes.
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
ENHANCED: Now matches SSE transport robustness with improved process
|
|
35
|
+
management, health monitoring, and comprehensive error handling.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
server_params,
|
|
41
|
+
connection_timeout: float = 30.0,
|
|
42
|
+
default_timeout: float = 30.0,
|
|
43
|
+
enable_metrics: bool = True,
|
|
44
|
+
process_monitor: bool = True,
|
|
45
|
+
): # NEW
|
|
46
|
+
"""
|
|
47
|
+
Initialize STDIO transport with enhanced configuration.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
server_params: Server parameters (dict or StdioParameters object)
|
|
51
|
+
connection_timeout: Timeout for initial connection setup
|
|
52
|
+
default_timeout: Default timeout for operations
|
|
53
|
+
enable_metrics: Whether to track performance metrics
|
|
54
|
+
process_monitor: Whether to monitor subprocess health (NEW)
|
|
55
|
+
"""
|
|
36
56
|
# Convert dict to StdioParameters if needed
|
|
37
57
|
if isinstance(server_params, dict):
|
|
58
|
+
# Merge provided env with system environment to ensure PATH is available
|
|
59
|
+
merged_env = os.environ.copy()
|
|
60
|
+
if server_params.get("env"):
|
|
61
|
+
merged_env.update(server_params["env"])
|
|
62
|
+
|
|
38
63
|
self.server_params = StdioParameters(
|
|
39
|
-
command=server_params.get(
|
|
40
|
-
args=server_params.get(
|
|
41
|
-
env=
|
|
64
|
+
command=server_params.get("command", "python"),
|
|
65
|
+
args=server_params.get("args", []),
|
|
66
|
+
env=merged_env,
|
|
42
67
|
)
|
|
43
68
|
else:
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
# Also handle StdioParameters object - merge env if provided
|
|
70
|
+
# Create a new StdioParameters with merged env (Pydantic models are immutable)
|
|
71
|
+
merged_env = os.environ.copy()
|
|
72
|
+
if hasattr(server_params, "env") and server_params.env:
|
|
73
|
+
merged_env.update(server_params.env)
|
|
74
|
+
|
|
75
|
+
self.server_params = StdioParameters(
|
|
76
|
+
command=server_params.command,
|
|
77
|
+
args=server_params.args,
|
|
78
|
+
env=merged_env,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.connection_timeout = connection_timeout
|
|
82
|
+
self.default_timeout = default_timeout
|
|
83
|
+
self.enable_metrics = enable_metrics
|
|
84
|
+
self.process_monitor = process_monitor # NEW
|
|
85
|
+
|
|
86
|
+
# Connection state
|
|
46
87
|
self._context = None
|
|
47
88
|
self._streams = None
|
|
48
89
|
self._initialized = False
|
|
49
90
|
|
|
91
|
+
# Process monitoring (NEW - like SSE's health monitoring)
|
|
92
|
+
self._process_id = None
|
|
93
|
+
self._process_start_time = None
|
|
94
|
+
self._last_successful_ping = None
|
|
95
|
+
self._consecutive_failures = 0
|
|
96
|
+
self._max_consecutive_failures = 3
|
|
97
|
+
|
|
98
|
+
# Enhanced performance metrics (like SSE)
|
|
99
|
+
self._metrics = {
|
|
100
|
+
"total_calls": 0,
|
|
101
|
+
"successful_calls": 0,
|
|
102
|
+
"failed_calls": 0,
|
|
103
|
+
"total_time": 0.0,
|
|
104
|
+
"avg_response_time": 0.0,
|
|
105
|
+
"last_ping_time": None,
|
|
106
|
+
"initialization_time": None,
|
|
107
|
+
"process_restarts": 0,
|
|
108
|
+
"pipe_errors": 0,
|
|
109
|
+
"process_crashes": 0, # NEW
|
|
110
|
+
"recovery_attempts": 0, # NEW
|
|
111
|
+
"memory_usage_mb": 0.0, # NEW
|
|
112
|
+
"cpu_percent": 0.0, # NEW
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
logger.debug("STDIO transport initialized for command: %s", getattr(self.server_params, "command", "unknown"))
|
|
116
|
+
|
|
117
|
+
async def _get_process_info(self) -> dict[str, Any] | None:
|
|
118
|
+
"""Get process information for monitoring (NEW)."""
|
|
119
|
+
if not self._process_id or not self.process_monitor:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# FIXED: Validate PID is a real integer before using psutil
|
|
124
|
+
if not isinstance(self._process_id, int) or self._process_id <= 0:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
process = psutil.Process(self._process_id)
|
|
128
|
+
if process.is_running():
|
|
129
|
+
memory_info = process.memory_info()
|
|
130
|
+
return {
|
|
131
|
+
"pid": self._process_id,
|
|
132
|
+
"status": process.status(),
|
|
133
|
+
"memory_mb": memory_info.rss / 1024 / 1024,
|
|
134
|
+
"cpu_percent": process.cpu_percent(),
|
|
135
|
+
"create_time": process.create_time(),
|
|
136
|
+
"uptime": time.time() - self._process_start_time if self._process_start_time else 0,
|
|
137
|
+
}
|
|
138
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError, ValueError):
|
|
139
|
+
# FIXED: Handle all possible errors including TypeError from mock objects
|
|
140
|
+
pass
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
async def _monitor_process_health(self) -> bool:
|
|
144
|
+
"""Monitor subprocess health (NEW - like SSE's health monitoring)."""
|
|
145
|
+
if not self.process_monitor:
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
# FIXED: Check if process_id is valid before monitoring
|
|
149
|
+
if not self._process_id or not isinstance(self._process_id, int) or self._process_id <= 0:
|
|
150
|
+
return True # No monitoring if no valid PID
|
|
151
|
+
|
|
152
|
+
process_info = await self._get_process_info()
|
|
153
|
+
if not process_info:
|
|
154
|
+
logger.debug("Process monitoring unavailable (may be in test environment)")
|
|
155
|
+
return True # Don't fail in test environments
|
|
156
|
+
|
|
157
|
+
# Update metrics with process info
|
|
158
|
+
if self.enable_metrics:
|
|
159
|
+
self._metrics["memory_usage_mb"] = process_info["memory_mb"]
|
|
160
|
+
self._metrics["cpu_percent"] = process_info["cpu_percent"]
|
|
161
|
+
|
|
162
|
+
# Check for concerning process states
|
|
163
|
+
status = process_info.get("status", "unknown")
|
|
164
|
+
if status in ["zombie", "dead"]:
|
|
165
|
+
logger.error("Process is in %s state", status)
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
# Check for excessive memory usage (warn at 1GB)
|
|
169
|
+
memory_mb = process_info.get("memory_mb", 0)
|
|
170
|
+
if memory_mb > 1024:
|
|
171
|
+
logger.warning("Process using excessive memory: %.1f MB", memory_mb)
|
|
172
|
+
|
|
173
|
+
return True
|
|
174
|
+
|
|
50
175
|
async def initialize(self) -> bool:
|
|
51
|
-
"""
|
|
176
|
+
"""Enhanced initialization with process monitoring."""
|
|
52
177
|
if self._initialized:
|
|
178
|
+
logger.warning("Transport already initialized")
|
|
53
179
|
return True
|
|
54
|
-
|
|
180
|
+
|
|
181
|
+
start_time = time.time()
|
|
182
|
+
|
|
55
183
|
try:
|
|
56
|
-
logger.
|
|
184
|
+
logger.debug("Initializing STDIO transport...")
|
|
185
|
+
|
|
186
|
+
# Create context with timeout protection
|
|
57
187
|
self._context = stdio_client(self.server_params)
|
|
58
|
-
self._streams = await self._context.__aenter__()
|
|
59
|
-
|
|
60
|
-
#
|
|
61
|
-
|
|
188
|
+
self._streams = await asyncio.wait_for(self._context.__aenter__(), timeout=self.connection_timeout)
|
|
189
|
+
|
|
190
|
+
# Capture process information for monitoring (NEW)
|
|
191
|
+
if self.process_monitor and hasattr(self._context, "_process"):
|
|
192
|
+
self._process_id = getattr(self._context._process, "pid", None)
|
|
193
|
+
self._process_start_time = time.time()
|
|
194
|
+
logger.debug("Subprocess PID: %s", self._process_id)
|
|
195
|
+
|
|
196
|
+
# Send initialize message with timeout
|
|
197
|
+
init_result = await asyncio.wait_for(send_initialize(*self._streams), timeout=self.default_timeout)
|
|
198
|
+
|
|
62
199
|
if init_result:
|
|
63
|
-
|
|
64
|
-
logger.
|
|
65
|
-
|
|
200
|
+
# Enhanced health verification (like SSE)
|
|
201
|
+
logger.debug("Verifying connection with ping...")
|
|
202
|
+
ping_start = time.time()
|
|
203
|
+
# Use default timeout for initial ping verification
|
|
204
|
+
ping_success = await asyncio.wait_for(send_ping(*self._streams), timeout=self.default_timeout)
|
|
205
|
+
ping_time = time.time() - ping_start
|
|
206
|
+
|
|
207
|
+
if ping_success:
|
|
208
|
+
self._initialized = True
|
|
209
|
+
self._last_successful_ping = time.time()
|
|
210
|
+
self._consecutive_failures = 0
|
|
211
|
+
|
|
212
|
+
if self.enable_metrics:
|
|
213
|
+
init_time = time.time() - start_time
|
|
214
|
+
self._metrics["initialization_time"] = init_time
|
|
215
|
+
self._metrics["last_ping_time"] = ping_time
|
|
216
|
+
|
|
217
|
+
logger.debug(
|
|
218
|
+
"STDIO transport initialized successfully in %.3fs (ping: %.3fs)",
|
|
219
|
+
time.time() - start_time,
|
|
220
|
+
ping_time,
|
|
221
|
+
)
|
|
222
|
+
return True
|
|
223
|
+
else:
|
|
224
|
+
logger.debug("STDIO connection established but ping failed")
|
|
225
|
+
# Still consider it initialized
|
|
226
|
+
self._initialized = True
|
|
227
|
+
self._consecutive_failures = 1
|
|
228
|
+
if self.enable_metrics:
|
|
229
|
+
self._metrics["initialization_time"] = time.time() - start_time
|
|
230
|
+
return True
|
|
66
231
|
else:
|
|
232
|
+
logger.warning("STDIO initialization failed")
|
|
67
233
|
await self._cleanup()
|
|
68
234
|
return False
|
|
235
|
+
|
|
236
|
+
except TimeoutError:
|
|
237
|
+
logger.error("STDIO initialization timed out after %ss", self.connection_timeout)
|
|
238
|
+
await self._cleanup()
|
|
239
|
+
if self.enable_metrics:
|
|
240
|
+
self._metrics["process_crashes"] += 1
|
|
241
|
+
return False
|
|
69
242
|
except Exception as e:
|
|
70
|
-
logger.error(
|
|
243
|
+
logger.error("Error initializing STDIO transport: %s", e)
|
|
71
244
|
await self._cleanup()
|
|
245
|
+
if self.enable_metrics:
|
|
246
|
+
self._metrics["process_crashes"] += 1
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
async def _attempt_recovery(self) -> bool:
|
|
250
|
+
"""Attempt to recover from process/connection issues (NEW)."""
|
|
251
|
+
if self.enable_metrics:
|
|
252
|
+
self._metrics["recovery_attempts"] += 1
|
|
253
|
+
self._metrics["process_restarts"] += 1
|
|
254
|
+
|
|
255
|
+
logger.warning("Attempting STDIO process recovery...")
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Force cleanup of existing process
|
|
259
|
+
await self._cleanup()
|
|
260
|
+
|
|
261
|
+
# Brief delay before restart
|
|
262
|
+
await asyncio.sleep(1.0)
|
|
263
|
+
|
|
264
|
+
# Re-initialize
|
|
265
|
+
return await self.initialize()
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error("Recovery attempt failed: %s", e)
|
|
72
268
|
return False
|
|
73
269
|
|
|
74
270
|
async def close(self) -> None:
|
|
75
|
-
"""
|
|
271
|
+
"""Enhanced close with process monitoring and metrics."""
|
|
272
|
+
if not self._initialized:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
# Enhanced metrics logging (like SSE)
|
|
276
|
+
if self.enable_metrics and self._metrics["total_calls"] > 0:
|
|
277
|
+
success_rate = self._metrics["successful_calls"] / self._metrics["total_calls"] * 100
|
|
278
|
+
logger.debug(
|
|
279
|
+
"STDIO transport closing - Calls: %d, Success: %.1f%%, "
|
|
280
|
+
"Avg time: %.3fs, Restarts: %d, Crashes: %d, Memory: %.1f MB",
|
|
281
|
+
self._metrics["total_calls"],
|
|
282
|
+
success_rate,
|
|
283
|
+
self._metrics["avg_response_time"],
|
|
284
|
+
self._metrics["process_restarts"],
|
|
285
|
+
self._metrics["process_crashes"],
|
|
286
|
+
self._metrics["memory_usage_mb"],
|
|
287
|
+
)
|
|
288
|
+
|
|
76
289
|
if self._context:
|
|
77
290
|
try:
|
|
78
|
-
# Simple delegation - the StreamManager now calls this in the correct context
|
|
79
291
|
await self._context.__aexit__(None, None, None)
|
|
292
|
+
logger.debug("STDIO context closed")
|
|
80
293
|
except Exception as e:
|
|
81
|
-
logger.debug(
|
|
294
|
+
logger.debug("Error during STDIO close: %s", e)
|
|
82
295
|
finally:
|
|
83
296
|
await self._cleanup()
|
|
84
297
|
|
|
85
298
|
async def _cleanup(self) -> None:
|
|
86
|
-
"""
|
|
299
|
+
"""Enhanced cleanup with process termination."""
|
|
300
|
+
# Attempt graceful process termination if we have a PID
|
|
301
|
+
if self._process_id and self.process_monitor:
|
|
302
|
+
try:
|
|
303
|
+
# FIXED: Validate PID is a real integer before using psutil
|
|
304
|
+
if isinstance(self._process_id, int) and self._process_id > 0:
|
|
305
|
+
process = psutil.Process(self._process_id)
|
|
306
|
+
if process.is_running():
|
|
307
|
+
logger.debug("Terminating subprocess %s", self._process_id)
|
|
308
|
+
process.terminate()
|
|
309
|
+
|
|
310
|
+
# Wait briefly for graceful termination
|
|
311
|
+
try:
|
|
312
|
+
process.wait(timeout=2.0)
|
|
313
|
+
except psutil.TimeoutExpired:
|
|
314
|
+
logger.warning("Process did not terminate gracefully, killing...")
|
|
315
|
+
process.kill()
|
|
316
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, TypeError, ValueError):
|
|
317
|
+
# FIXED: Handle all possible errors including TypeError from mock objects
|
|
318
|
+
logger.debug("Could not terminate process %s (may be mock or already dead)", self._process_id)
|
|
319
|
+
|
|
87
320
|
self._context = None
|
|
88
321
|
self._streams = None
|
|
89
322
|
self._initialized = False
|
|
323
|
+
self._process_id = None
|
|
324
|
+
self._process_start_time = None
|
|
90
325
|
|
|
91
326
|
async def send_ping(self) -> bool:
|
|
92
|
-
"""
|
|
327
|
+
"""Enhanced ping with process health monitoring."""
|
|
93
328
|
if not self._initialized:
|
|
94
329
|
return False
|
|
330
|
+
|
|
331
|
+
# Check process health first (NEW) - but only if we have a real process
|
|
332
|
+
if (
|
|
333
|
+
self.process_monitor
|
|
334
|
+
and self._process_id
|
|
335
|
+
and isinstance(self._process_id, int)
|
|
336
|
+
and not await self._monitor_process_health()
|
|
337
|
+
):
|
|
338
|
+
self._consecutive_failures += 1
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
start_time = time.time()
|
|
95
342
|
try:
|
|
96
|
-
|
|
97
|
-
|
|
343
|
+
result = await asyncio.wait_for(send_ping(*self._streams), timeout=self.default_timeout)
|
|
344
|
+
|
|
345
|
+
success = bool(result)
|
|
346
|
+
|
|
347
|
+
if success:
|
|
348
|
+
self._last_successful_ping = time.time()
|
|
349
|
+
self._consecutive_failures = 0
|
|
350
|
+
else:
|
|
351
|
+
self._consecutive_failures += 1
|
|
352
|
+
|
|
353
|
+
if self.enable_metrics:
|
|
354
|
+
ping_time = time.time() - start_time
|
|
355
|
+
self._metrics["last_ping_time"] = ping_time
|
|
356
|
+
logger.debug("STDIO ping completed in %.3fs: %s", ping_time, success)
|
|
357
|
+
|
|
358
|
+
return success
|
|
359
|
+
except TimeoutError:
|
|
360
|
+
logger.error("STDIO ping timed out")
|
|
361
|
+
self._consecutive_failures += 1
|
|
362
|
+
return False
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.error("STDIO ping failed: %s", e)
|
|
365
|
+
self._consecutive_failures += 1
|
|
366
|
+
if self.enable_metrics:
|
|
367
|
+
self._metrics["pipe_errors"] += 1
|
|
98
368
|
return False
|
|
99
369
|
|
|
100
|
-
|
|
101
|
-
"""
|
|
370
|
+
def is_connected(self) -> bool:
|
|
371
|
+
"""Enhanced connection status check (like SSE)."""
|
|
372
|
+
if not self._initialized or not self._streams:
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
# Check for too many consecutive failures (like SSE)
|
|
376
|
+
if self._consecutive_failures >= self._max_consecutive_failures:
|
|
377
|
+
logger.warning("Connection marked unhealthy after %d failures", self._consecutive_failures)
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
return True
|
|
381
|
+
|
|
382
|
+
async def get_tools(self) -> list[dict[str, Any]]:
|
|
383
|
+
"""Enhanced tools retrieval with recovery."""
|
|
102
384
|
if not self._initialized:
|
|
385
|
+
logger.debug("Cannot get tools: transport not initialized")
|
|
103
386
|
return []
|
|
387
|
+
|
|
388
|
+
start_time = time.time()
|
|
104
389
|
try:
|
|
105
|
-
response = await send_tools_list(*self._streams)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
390
|
+
response = await asyncio.wait_for(send_tools_list(*self._streams), timeout=self.default_timeout)
|
|
391
|
+
|
|
392
|
+
# Normalize response - handle multiple formats including Pydantic models
|
|
393
|
+
# 1. Check if it's a Pydantic model with tools attribute (e.g., ListToolsResult from chuk_mcp)
|
|
394
|
+
if hasattr(response, "tools"):
|
|
395
|
+
tools = response.tools
|
|
396
|
+
# Convert Pydantic Tool models to dicts if needed
|
|
397
|
+
if tools and len(tools) > 0 and hasattr(tools[0], "model_dump"):
|
|
398
|
+
tools = [tool.model_dump() if hasattr(tool, "model_dump") else tool for tool in tools]
|
|
399
|
+
elif tools and len(tools) > 0 and hasattr(tools[0], "dict"):
|
|
400
|
+
# Older Pydantic versions use dict() instead of model_dump()
|
|
401
|
+
tools = [tool.dict() if hasattr(tool, "dict") else tool for tool in tools]
|
|
402
|
+
# 2. Check if it's a Pydantic model that can be dumped
|
|
403
|
+
elif hasattr(response, "model_dump"):
|
|
404
|
+
dumped = response.model_dump()
|
|
405
|
+
tools = dumped.get("tools", [])
|
|
406
|
+
# 3. Handle dict responses
|
|
407
|
+
elif isinstance(response, dict):
|
|
408
|
+
# Check for tools at top level
|
|
409
|
+
if "tools" in response:
|
|
410
|
+
tools = response["tools"]
|
|
411
|
+
# Check for nested result.tools (common in some MCP implementations)
|
|
412
|
+
elif "result" in response and isinstance(response["result"], dict):
|
|
413
|
+
tools = response["result"].get("tools", [])
|
|
414
|
+
# Check if response itself is the result with MCP structure
|
|
415
|
+
elif "jsonrpc" in response and "result" in response:
|
|
416
|
+
result = response["result"]
|
|
417
|
+
if isinstance(result, dict):
|
|
418
|
+
tools = result.get("tools", [])
|
|
419
|
+
elif isinstance(result, list):
|
|
420
|
+
tools = result
|
|
421
|
+
else:
|
|
422
|
+
tools = []
|
|
423
|
+
else:
|
|
424
|
+
tools = []
|
|
425
|
+
# 4. Handle list responses
|
|
426
|
+
elif isinstance(response, list):
|
|
427
|
+
tools = response
|
|
428
|
+
else:
|
|
429
|
+
logger.warning("Unexpected tools response type: %s", type(response))
|
|
430
|
+
tools = []
|
|
431
|
+
|
|
432
|
+
# Reset failure count on success
|
|
433
|
+
self._consecutive_failures = 0
|
|
434
|
+
|
|
435
|
+
if self.enable_metrics:
|
|
436
|
+
response_time = time.time() - start_time
|
|
437
|
+
logger.debug("Retrieved %d tools in %.3fs", len(tools), response_time)
|
|
438
|
+
|
|
439
|
+
return tools
|
|
440
|
+
|
|
441
|
+
except TimeoutError:
|
|
442
|
+
logger.error("Get tools timed out")
|
|
443
|
+
self._consecutive_failures += 1
|
|
444
|
+
return []
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.error("Error getting tools: %s", e)
|
|
447
|
+
self._consecutive_failures += 1
|
|
448
|
+
if self.enable_metrics:
|
|
449
|
+
self._metrics["pipe_errors"] += 1
|
|
110
450
|
return []
|
|
111
451
|
|
|
112
|
-
async def call_tool(
|
|
113
|
-
|
|
452
|
+
async def call_tool(
|
|
453
|
+
self, tool_name: str, arguments: dict[str, Any], timeout: float | None = None
|
|
454
|
+
) -> dict[str, Any]:
|
|
455
|
+
"""Enhanced tool calling with recovery and process monitoring."""
|
|
114
456
|
if not self._initialized:
|
|
115
457
|
return {"isError": True, "error": "Transport not initialized"}
|
|
116
|
-
|
|
458
|
+
|
|
459
|
+
tool_timeout = timeout or self.default_timeout
|
|
460
|
+
start_time = time.time()
|
|
461
|
+
|
|
462
|
+
if self.enable_metrics:
|
|
463
|
+
self._metrics["total_calls"] += 1
|
|
464
|
+
|
|
117
465
|
try:
|
|
118
|
-
|
|
119
|
-
|
|
466
|
+
logger.debug("Calling tool '%s' with timeout %ss", tool_name, tool_timeout)
|
|
467
|
+
|
|
468
|
+
# Enhanced connection check with recovery attempt
|
|
469
|
+
if not self.is_connected():
|
|
470
|
+
logger.warning("Connection unhealthy, attempting recovery...")
|
|
471
|
+
if not await self._attempt_recovery():
|
|
472
|
+
if self.enable_metrics:
|
|
473
|
+
self._update_metrics(time.time() - start_time, False)
|
|
474
|
+
return {"isError": True, "error": "Failed to recover connection"}
|
|
475
|
+
|
|
476
|
+
response = await asyncio.wait_for(
|
|
477
|
+
send_tools_call(*self._streams, tool_name, arguments, timeout=tool_timeout), timeout=tool_timeout
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
response_time = time.time() - start_time
|
|
481
|
+
result = self._normalize_mcp_response(response)
|
|
482
|
+
|
|
483
|
+
# Reset failure count and update health on success
|
|
484
|
+
self._consecutive_failures = 0
|
|
485
|
+
self._last_successful_ping = time.time()
|
|
486
|
+
|
|
487
|
+
if self.enable_metrics:
|
|
488
|
+
self._update_metrics(response_time, not result.get("isError", False))
|
|
489
|
+
|
|
490
|
+
if not result.get("isError", False):
|
|
491
|
+
logger.debug("Tool '%s' completed successfully in %.3fs", tool_name, response_time)
|
|
492
|
+
else:
|
|
493
|
+
logger.warning(
|
|
494
|
+
"Tool '%s' failed in %.3fs: %s", tool_name, response_time, result.get("error", "Unknown error")
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return result
|
|
498
|
+
|
|
499
|
+
except TimeoutError:
|
|
500
|
+
response_time = time.time() - start_time
|
|
501
|
+
self._consecutive_failures += 1
|
|
502
|
+
if self.enable_metrics:
|
|
503
|
+
self._update_metrics(response_time, False)
|
|
504
|
+
|
|
505
|
+
error_msg = f"Tool execution timed out after {tool_timeout}s"
|
|
506
|
+
logger.error("Tool '%s' %s", tool_name, error_msg)
|
|
507
|
+
return {"isError": True, "error": error_msg}
|
|
120
508
|
except Exception as e:
|
|
121
|
-
|
|
509
|
+
response_time = time.time() - start_time
|
|
510
|
+
self._consecutive_failures += 1
|
|
511
|
+
if self.enable_metrics:
|
|
512
|
+
self._update_metrics(response_time, False)
|
|
513
|
+
self._metrics["pipe_errors"] += 1
|
|
514
|
+
|
|
515
|
+
# Enhanced process error detection
|
|
516
|
+
error_str = str(e).lower()
|
|
517
|
+
if any(indicator in error_str for indicator in ["broken pipe", "process", "eof", "connection", "died"]):
|
|
518
|
+
logger.warning("Process error detected: %s", e)
|
|
519
|
+
self._initialized = False
|
|
520
|
+
if self.enable_metrics:
|
|
521
|
+
self._metrics["process_crashes"] += 1
|
|
522
|
+
|
|
523
|
+
error_msg = f"Tool execution failed: {str(e)}"
|
|
524
|
+
logger.error("Tool '%s' error: %s", tool_name, error_msg)
|
|
525
|
+
return {"isError": True, "error": error_msg}
|
|
526
|
+
|
|
527
|
+
def _update_metrics(self, response_time: float, success: bool) -> None:
|
|
528
|
+
"""Enhanced metrics tracking (like SSE)."""
|
|
529
|
+
if success:
|
|
530
|
+
self._metrics["successful_calls"] += 1
|
|
531
|
+
else:
|
|
532
|
+
self._metrics["failed_calls"] += 1
|
|
122
533
|
|
|
123
|
-
|
|
124
|
-
""
|
|
534
|
+
self._metrics["total_time"] += response_time
|
|
535
|
+
if self._metrics["total_calls"] > 0:
|
|
536
|
+
self._metrics["avg_response_time"] = self._metrics["total_time"] / self._metrics["total_calls"]
|
|
537
|
+
|
|
538
|
+
def _normalize_mcp_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
|
539
|
+
"""
|
|
540
|
+
Enhanced response normalization with STDIO-specific handling.
|
|
541
|
+
|
|
542
|
+
STDIO preserves string representations of numeric values for
|
|
543
|
+
backward compatibility with existing tests.
|
|
544
|
+
"""
|
|
545
|
+
# Handle explicit error in response
|
|
125
546
|
if "error" in response:
|
|
126
547
|
error_info = response["error"]
|
|
127
548
|
error_msg = error_info.get("message", str(error_info)) if isinstance(error_info, dict) else str(error_info)
|
|
128
549
|
return {"isError": True, "error": error_msg}
|
|
129
|
-
|
|
550
|
+
|
|
551
|
+
# Handle successful response with result
|
|
130
552
|
if "result" in response:
|
|
131
553
|
result = response["result"]
|
|
132
554
|
if isinstance(result, dict) and "content" in result:
|
|
133
|
-
return {"isError": False, "content": self.
|
|
555
|
+
return {"isError": False, "content": self._extract_stdio_content(result["content"])}
|
|
134
556
|
return {"isError": False, "content": result}
|
|
135
|
-
|
|
557
|
+
|
|
558
|
+
# Handle direct content-based response
|
|
136
559
|
if "content" in response:
|
|
137
|
-
return {"isError": False, "content": self.
|
|
138
|
-
|
|
560
|
+
return {"isError": False, "content": self._extract_stdio_content(response["content"])}
|
|
561
|
+
|
|
139
562
|
return {"isError": False, "content": response}
|
|
140
563
|
|
|
141
|
-
def
|
|
564
|
+
def _extract_stdio_content(self, content_list: Any) -> Any:
|
|
142
565
|
"""
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
566
|
+
Enhanced content extraction with STDIO-specific string preservation.
|
|
567
|
+
|
|
568
|
+
STDIO transport preserves string representations of numeric values
|
|
569
|
+
for backward compatibility with existing tests.
|
|
147
570
|
"""
|
|
148
571
|
if not isinstance(content_list, list) or not content_list:
|
|
149
572
|
return content_list
|
|
150
|
-
|
|
573
|
+
|
|
151
574
|
if len(content_list) == 1:
|
|
152
575
|
item = content_list[0]
|
|
153
576
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
154
577
|
text = item.get("text", "")
|
|
155
|
-
|
|
156
|
-
#
|
|
157
|
-
# if they look like simple values (numbers, booleans, etc.)
|
|
578
|
+
|
|
579
|
+
# STDIO-specific: preserve string format for numeric values
|
|
158
580
|
try:
|
|
159
581
|
parsed = json.loads(text)
|
|
160
582
|
# If the parsed result is a simple type and the original was a string,
|
|
161
|
-
# keep it as a string to maintain
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
583
|
+
# keep it as a string to maintain compatibility
|
|
584
|
+
if (
|
|
585
|
+
isinstance(parsed, int | float | bool)
|
|
586
|
+
and isinstance(text, str)
|
|
587
|
+
and (text.strip().isdigit() or text.strip().replace(".", "", 1).isdigit())
|
|
588
|
+
):
|
|
589
|
+
return text # Return as string for numeric values
|
|
166
590
|
return parsed
|
|
167
591
|
except json.JSONDecodeError:
|
|
168
592
|
return text
|
|
169
593
|
return item
|
|
170
|
-
|
|
594
|
+
|
|
171
595
|
return content_list
|
|
172
596
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if not HAS_RESOURCES or not self._initialized:
|
|
597
|
+
async def list_resources(self) -> dict[str, Any]:
|
|
598
|
+
"""Enhanced resource listing with error handling."""
|
|
599
|
+
if not self._initialized:
|
|
177
600
|
return {}
|
|
178
601
|
try:
|
|
179
|
-
response = await send_resources_list(*self._streams)
|
|
602
|
+
response = await asyncio.wait_for(send_resources_list(*self._streams), timeout=self.default_timeout)
|
|
603
|
+
self._consecutive_failures = 0 # Reset on success
|
|
180
604
|
return response if isinstance(response, dict) else {}
|
|
181
|
-
except
|
|
605
|
+
except TimeoutError:
|
|
606
|
+
logger.error("List resources timed out")
|
|
607
|
+
self._consecutive_failures += 1
|
|
608
|
+
return {}
|
|
609
|
+
except Exception as e:
|
|
610
|
+
logger.debug("Error listing resources: %s", e)
|
|
611
|
+
self._consecutive_failures += 1
|
|
182
612
|
return {}
|
|
183
613
|
|
|
184
|
-
async def
|
|
185
|
-
"""
|
|
186
|
-
if not
|
|
614
|
+
async def list_prompts(self) -> dict[str, Any]:
|
|
615
|
+
"""Enhanced prompt listing with error handling."""
|
|
616
|
+
if not self._initialized:
|
|
187
617
|
return {}
|
|
188
618
|
try:
|
|
189
|
-
response = await
|
|
619
|
+
response = await asyncio.wait_for(send_prompts_list(*self._streams), timeout=self.default_timeout)
|
|
620
|
+
self._consecutive_failures = 0 # Reset on success
|
|
190
621
|
return response if isinstance(response, dict) else {}
|
|
191
|
-
except
|
|
622
|
+
except TimeoutError:
|
|
623
|
+
logger.error("List prompts timed out")
|
|
624
|
+
self._consecutive_failures += 1
|
|
625
|
+
return {}
|
|
626
|
+
except Exception as e:
|
|
627
|
+
logger.debug("Error listing prompts: %s", e)
|
|
628
|
+
self._consecutive_failures += 1
|
|
192
629
|
return {}
|
|
193
630
|
|
|
194
|
-
async def
|
|
195
|
-
"""
|
|
196
|
-
if not
|
|
631
|
+
async def read_resource(self, uri: str) -> dict[str, Any]:
|
|
632
|
+
"""Read a specific resource."""
|
|
633
|
+
if not self._initialized:
|
|
197
634
|
return {}
|
|
198
635
|
try:
|
|
199
|
-
response = await
|
|
636
|
+
response = await asyncio.wait_for(send_resources_read(*self._streams, uri), timeout=self.default_timeout)
|
|
637
|
+
self._consecutive_failures = 0 # Reset on success
|
|
200
638
|
return response if isinstance(response, dict) else {}
|
|
201
|
-
except
|
|
639
|
+
except TimeoutError:
|
|
640
|
+
logger.error("Read resource timed out")
|
|
641
|
+
self._consecutive_failures += 1
|
|
642
|
+
return {}
|
|
643
|
+
except Exception as e:
|
|
644
|
+
logger.debug("Error reading resource: %s", e)
|
|
645
|
+
self._consecutive_failures += 1
|
|
202
646
|
return {}
|
|
203
647
|
|
|
204
|
-
async def get_prompt(self, name: str, arguments:
|
|
205
|
-
"""
|
|
206
|
-
if not
|
|
648
|
+
async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
649
|
+
"""Get a specific prompt."""
|
|
650
|
+
if not self._initialized:
|
|
207
651
|
return {}
|
|
208
652
|
try:
|
|
209
|
-
response = await
|
|
653
|
+
response = await asyncio.wait_for(
|
|
654
|
+
send_prompts_get(*self._streams, name, arguments or {}), timeout=self.default_timeout
|
|
655
|
+
)
|
|
656
|
+
self._consecutive_failures = 0 # Reset on success
|
|
210
657
|
return response if isinstance(response, dict) else {}
|
|
211
|
-
except
|
|
658
|
+
except TimeoutError:
|
|
659
|
+
logger.error("Get prompt timed out")
|
|
660
|
+
self._consecutive_failures += 1
|
|
661
|
+
return {}
|
|
662
|
+
except Exception as e:
|
|
663
|
+
logger.debug("Error getting prompt: %s", e)
|
|
664
|
+
self._consecutive_failures += 1
|
|
212
665
|
return {}
|
|
213
666
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
667
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
668
|
+
"""Enhanced metrics with process and health information."""
|
|
669
|
+
metrics = self._metrics.copy()
|
|
670
|
+
metrics.update(
|
|
671
|
+
{
|
|
672
|
+
"is_connected": self.is_connected(),
|
|
673
|
+
"consecutive_failures": self._consecutive_failures,
|
|
674
|
+
"last_successful_ping": self._last_successful_ping,
|
|
675
|
+
"max_consecutive_failures": self._max_consecutive_failures,
|
|
676
|
+
"process_id": self._process_id,
|
|
677
|
+
"process_uptime": (time.time() - self._process_start_time) if self._process_start_time else 0,
|
|
678
|
+
}
|
|
679
|
+
)
|
|
680
|
+
return metrics
|
|
218
681
|
|
|
219
|
-
def
|
|
220
|
-
"""
|
|
221
|
-
|
|
682
|
+
def reset_metrics(self) -> None:
|
|
683
|
+
"""Enhanced metrics reset preserving health and process state."""
|
|
684
|
+
preserved_init_time = self._metrics.get("initialization_time")
|
|
685
|
+
preserved_last_ping = self._metrics.get("last_ping_time")
|
|
686
|
+
preserved_restarts = self._metrics.get("process_restarts", 0)
|
|
687
|
+
|
|
688
|
+
self._metrics = {
|
|
689
|
+
"total_calls": 0,
|
|
690
|
+
"successful_calls": 0,
|
|
691
|
+
"failed_calls": 0,
|
|
692
|
+
"total_time": 0.0,
|
|
693
|
+
"avg_response_time": 0.0,
|
|
694
|
+
"last_ping_time": preserved_last_ping,
|
|
695
|
+
"initialization_time": preserved_init_time,
|
|
696
|
+
"process_restarts": preserved_restarts,
|
|
697
|
+
"pipe_errors": 0,
|
|
698
|
+
"process_crashes": 0,
|
|
699
|
+
"recovery_attempts": 0,
|
|
700
|
+
"memory_usage_mb": 0.0,
|
|
701
|
+
"cpu_percent": 0.0,
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
def get_streams(self) -> list[tuple]:
|
|
705
|
+
"""Enhanced streams access with connection check."""
|
|
706
|
+
return [self._streams] if self._streams else []
|
|
222
707
|
|
|
223
708
|
async def __aenter__(self):
|
|
224
|
-
"""
|
|
225
|
-
|
|
226
|
-
|
|
709
|
+
"""Enhanced context manager entry."""
|
|
710
|
+
success = await self.initialize()
|
|
711
|
+
if not success:
|
|
712
|
+
raise RuntimeError("Failed to initialize StdioTransport")
|
|
227
713
|
return self
|
|
228
714
|
|
|
229
715
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
230
|
-
"""
|
|
716
|
+
"""Enhanced context manager cleanup."""
|
|
231
717
|
await self.close()
|
|
232
|
-
|
|
233
|
-
def __repr__(self) -> str:
|
|
234
|
-
"""String representation."""
|
|
235
|
-
status = "initialized" if self._initialized else "not initialized"
|
|
236
|
-
return f"StdioTransport(status={status}, command={getattr(self.server_params, 'command', 'unknown')})"
|