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
|
@@ -9,14 +9,19 @@ not configuration or bootstrapping. Configuration is handled at registration tim
|
|
|
9
9
|
CORE PRINCIPLE: MCPTool wraps a StreamManager and delegates calls to it.
|
|
10
10
|
If the StreamManager becomes unavailable, return graceful errors rather than
|
|
11
11
|
trying to recreate it with config files.
|
|
12
|
+
|
|
13
|
+
HEALTH MONITORING FIX: Updated health checking to be more lenient and trust
|
|
14
|
+
the underlying transport's health monitoring instead of doing aggressive
|
|
15
|
+
ping tests that create false negatives.
|
|
12
16
|
"""
|
|
17
|
+
|
|
13
18
|
from __future__ import annotations
|
|
14
19
|
|
|
15
20
|
import asyncio
|
|
16
21
|
import time
|
|
17
|
-
from enum import Enum
|
|
18
|
-
from typing import Any, Dict, List, Optional
|
|
19
22
|
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from typing import Any
|
|
20
25
|
|
|
21
26
|
from chuk_tool_processor.logging import get_logger
|
|
22
27
|
from chuk_tool_processor.mcp.stream_manager import StreamManager
|
|
@@ -26,8 +31,9 @@ logger = get_logger("chuk_tool_processor.mcp.mcp_tool")
|
|
|
26
31
|
|
|
27
32
|
class ConnectionState(Enum):
|
|
28
33
|
"""Connection states for the MCP tool."""
|
|
34
|
+
|
|
29
35
|
HEALTHY = "healthy"
|
|
30
|
-
DEGRADED = "degraded"
|
|
36
|
+
DEGRADED = "degraded"
|
|
31
37
|
DISCONNECTED = "disconnected"
|
|
32
38
|
FAILED = "failed"
|
|
33
39
|
|
|
@@ -35,6 +41,7 @@ class ConnectionState(Enum):
|
|
|
35
41
|
@dataclass
|
|
36
42
|
class RecoveryConfig:
|
|
37
43
|
"""Configuration for connection recovery behavior."""
|
|
44
|
+
|
|
38
45
|
max_retries: int = 3
|
|
39
46
|
initial_backoff: float = 1.0
|
|
40
47
|
max_backoff: float = 30.0
|
|
@@ -46,103 +53,107 @@ class RecoveryConfig:
|
|
|
46
53
|
@dataclass
|
|
47
54
|
class ConnectionStats:
|
|
48
55
|
"""Statistics for connection monitoring."""
|
|
56
|
+
|
|
49
57
|
total_calls: int = 0
|
|
50
58
|
successful_calls: int = 0
|
|
51
59
|
failed_calls: int = 0
|
|
52
60
|
connection_errors: int = 0
|
|
53
|
-
last_success_time:
|
|
54
|
-
last_failure_time:
|
|
61
|
+
last_success_time: float | None = None
|
|
62
|
+
last_failure_time: float | None = None
|
|
55
63
|
|
|
56
64
|
|
|
57
65
|
class MCPTool:
|
|
58
66
|
"""
|
|
59
67
|
Wrap a remote MCP tool so it can be called like a local tool.
|
|
60
|
-
|
|
68
|
+
|
|
61
69
|
SIMPLIFIED: This class now focuses only on execution delegation.
|
|
62
70
|
It does NOT handle configuration files or StreamManager bootstrapping.
|
|
71
|
+
|
|
72
|
+
FIXED: Health monitoring is now more lenient and trusts the underlying
|
|
73
|
+
transport's health reporting instead of doing aggressive health checks.
|
|
63
74
|
"""
|
|
64
75
|
|
|
65
76
|
def __init__(
|
|
66
77
|
self,
|
|
67
78
|
tool_name: str = "",
|
|
68
|
-
stream_manager:
|
|
79
|
+
stream_manager: StreamManager | None = None,
|
|
69
80
|
*,
|
|
70
|
-
default_timeout:
|
|
81
|
+
default_timeout: float | None = None,
|
|
71
82
|
enable_resilience: bool = True,
|
|
72
|
-
recovery_config:
|
|
83
|
+
recovery_config: RecoveryConfig | None = None,
|
|
73
84
|
) -> None:
|
|
74
85
|
if not tool_name:
|
|
75
86
|
raise ValueError("MCPTool requires a tool_name")
|
|
76
|
-
|
|
87
|
+
|
|
77
88
|
self.tool_name = tool_name
|
|
78
|
-
self._sm:
|
|
89
|
+
self._sm: StreamManager | None = stream_manager
|
|
79
90
|
self.default_timeout = default_timeout or 30.0
|
|
80
91
|
|
|
81
92
|
# Resilience features
|
|
82
93
|
self.enable_resilience = enable_resilience
|
|
83
94
|
self.recovery_config = recovery_config or RecoveryConfig()
|
|
84
|
-
|
|
95
|
+
|
|
85
96
|
# State tracking (only if resilience enabled)
|
|
86
97
|
if self.enable_resilience:
|
|
87
98
|
self.connection_state = ConnectionState.HEALTHY if stream_manager else ConnectionState.DISCONNECTED
|
|
88
99
|
self.stats = ConnectionStats()
|
|
89
|
-
|
|
100
|
+
|
|
90
101
|
# Circuit breaker state
|
|
91
102
|
self._circuit_open = False
|
|
92
|
-
self._circuit_open_time:
|
|
103
|
+
self._circuit_open_time: float | None = None
|
|
93
104
|
self._consecutive_failures = 0
|
|
94
105
|
|
|
95
106
|
# ------------------------------------------------------------------ #
|
|
96
107
|
# Serialization support for subprocess execution
|
|
97
108
|
# ------------------------------------------------------------------ #
|
|
98
|
-
def __getstate__(self) ->
|
|
109
|
+
def __getstate__(self) -> dict[str, Any]:
|
|
99
110
|
"""
|
|
100
111
|
Serialize for subprocess execution.
|
|
101
|
-
|
|
112
|
+
|
|
102
113
|
SIMPLIFIED: Only preserve essential execution state, not configuration.
|
|
103
114
|
The StreamManager will be None after deserialization - that's expected.
|
|
104
115
|
"""
|
|
105
116
|
state = self.__dict__.copy()
|
|
106
|
-
|
|
117
|
+
|
|
107
118
|
# Remove non-serializable items
|
|
108
|
-
state[
|
|
109
|
-
|
|
119
|
+
state["_sm"] = None # StreamManager will be None in subprocess
|
|
120
|
+
|
|
110
121
|
# Reset connection state for subprocess
|
|
111
122
|
if self.enable_resilience:
|
|
112
|
-
state[
|
|
113
|
-
|
|
123
|
+
state["connection_state"] = ConnectionState.DISCONNECTED
|
|
124
|
+
|
|
114
125
|
logger.debug(f"Serializing MCPTool '{self.tool_name}' for subprocess")
|
|
115
126
|
return state
|
|
116
|
-
|
|
117
|
-
def __setstate__(self, state:
|
|
127
|
+
|
|
128
|
+
def __setstate__(self, state: dict[str, Any]) -> None:
|
|
118
129
|
"""
|
|
119
130
|
Deserialize after subprocess execution.
|
|
120
|
-
|
|
131
|
+
|
|
121
132
|
SIMPLIFIED: Just restore state. StreamManager will be None and that's fine.
|
|
122
133
|
"""
|
|
123
134
|
self.__dict__.update(state)
|
|
124
|
-
|
|
135
|
+
|
|
125
136
|
# Ensure critical fields exist
|
|
126
|
-
if not hasattr(self,
|
|
137
|
+
if not hasattr(self, "tool_name") or not self.tool_name:
|
|
127
138
|
raise ValueError("Invalid MCPTool state: missing tool_name")
|
|
128
|
-
|
|
139
|
+
|
|
129
140
|
# StreamManager will be None in subprocess - that's expected
|
|
130
141
|
self._sm = None
|
|
131
|
-
|
|
142
|
+
|
|
132
143
|
# Initialize resilience state if enabled
|
|
133
144
|
if self.enable_resilience:
|
|
134
|
-
if not hasattr(self,
|
|
145
|
+
if not hasattr(self, "connection_state"):
|
|
135
146
|
self.connection_state = ConnectionState.DISCONNECTED
|
|
136
|
-
if not hasattr(self,
|
|
147
|
+
if not hasattr(self, "stats"):
|
|
137
148
|
self.stats = ConnectionStats()
|
|
138
|
-
|
|
149
|
+
|
|
139
150
|
logger.debug(f"Deserialized MCPTool '{self.tool_name}' in subprocess")
|
|
140
151
|
|
|
141
152
|
# ------------------------------------------------------------------ #
|
|
142
|
-
async def execute(self, timeout:
|
|
153
|
+
async def execute(self, timeout: float | None = None, **kwargs: Any) -> Any:
|
|
143
154
|
"""
|
|
144
155
|
Execute the tool, returning graceful errors if StreamManager unavailable.
|
|
145
|
-
|
|
156
|
+
|
|
146
157
|
SIMPLIFIED: If no StreamManager, return a structured error response
|
|
147
158
|
instead of trying to bootstrap one.
|
|
148
159
|
"""
|
|
@@ -152,21 +163,21 @@ class MCPTool:
|
|
|
152
163
|
"error": f"Tool '{self.tool_name}' is not available (no stream manager)",
|
|
153
164
|
"tool_name": self.tool_name,
|
|
154
165
|
"available": False,
|
|
155
|
-
"reason": "disconnected"
|
|
166
|
+
"reason": "disconnected",
|
|
156
167
|
}
|
|
157
168
|
|
|
158
169
|
# If resilience is disabled, use simple execution
|
|
159
170
|
if not self.enable_resilience:
|
|
160
171
|
return await self._simple_execute(timeout, **kwargs)
|
|
161
|
-
|
|
172
|
+
|
|
162
173
|
# Resilient execution
|
|
163
174
|
return await self._resilient_execute(timeout, **kwargs)
|
|
164
175
|
|
|
165
|
-
async def _simple_execute(self, timeout:
|
|
176
|
+
async def _simple_execute(self, timeout: float | None = None, **kwargs: Any) -> Any:
|
|
166
177
|
"""Simple execution without resilience features."""
|
|
167
178
|
effective_timeout = timeout if timeout is not None else self.default_timeout
|
|
168
179
|
|
|
169
|
-
call_kwargs = {
|
|
180
|
+
call_kwargs: dict[str, Any] = {
|
|
170
181
|
"tool_name": self.tool_name,
|
|
171
182
|
"arguments": kwargs,
|
|
172
183
|
}
|
|
@@ -174,8 +185,8 @@ class MCPTool:
|
|
|
174
185
|
call_kwargs["timeout"] = effective_timeout
|
|
175
186
|
|
|
176
187
|
try:
|
|
177
|
-
result = await self._sm.call_tool(**call_kwargs)
|
|
178
|
-
except
|
|
188
|
+
result = await self._sm.call_tool(**call_kwargs) # type: ignore[union-attr]
|
|
189
|
+
except TimeoutError:
|
|
179
190
|
logger.warning(f"MCP tool '{self.tool_name}' timed out after {effective_timeout}s")
|
|
180
191
|
raise
|
|
181
192
|
|
|
@@ -186,7 +197,7 @@ class MCPTool:
|
|
|
186
197
|
|
|
187
198
|
return result.get("content")
|
|
188
199
|
|
|
189
|
-
async def _resilient_execute(self, timeout:
|
|
200
|
+
async def _resilient_execute(self, timeout: float | None = None, **kwargs: Any) -> Any:
|
|
190
201
|
"""Resilient execution with circuit breaker and health checks."""
|
|
191
202
|
# Check circuit breaker
|
|
192
203
|
if self._is_circuit_open():
|
|
@@ -194,77 +205,72 @@ class MCPTool:
|
|
|
194
205
|
"error": f"Circuit breaker open for tool '{self.tool_name}' - too many recent failures",
|
|
195
206
|
"tool_name": self.tool_name,
|
|
196
207
|
"available": False,
|
|
197
|
-
"reason": "circuit_breaker"
|
|
208
|
+
"reason": "circuit_breaker",
|
|
198
209
|
}
|
|
199
|
-
|
|
210
|
+
|
|
200
211
|
effective_timeout = timeout if timeout is not None else self.default_timeout
|
|
201
212
|
self.stats.total_calls += 1
|
|
202
|
-
|
|
203
|
-
#
|
|
213
|
+
|
|
214
|
+
# FIXED: More lenient health check - trust the transport layer
|
|
204
215
|
if not await self._is_stream_manager_healthy():
|
|
205
216
|
await self._record_failure(is_connection_error=True)
|
|
206
217
|
return {
|
|
207
218
|
"error": f"Tool '{self.tool_name}' is not available (unhealthy connection)",
|
|
208
219
|
"tool_name": self.tool_name,
|
|
209
220
|
"available": False,
|
|
210
|
-
"reason": "unhealthy"
|
|
221
|
+
"reason": "unhealthy",
|
|
211
222
|
}
|
|
212
|
-
|
|
223
|
+
|
|
213
224
|
# Try execution with retries
|
|
214
225
|
max_attempts = self.recovery_config.max_retries + 1
|
|
215
226
|
backoff = self.recovery_config.initial_backoff
|
|
216
|
-
|
|
227
|
+
|
|
217
228
|
for attempt in range(max_attempts):
|
|
218
229
|
try:
|
|
219
230
|
result = await self._execute_with_timeout(effective_timeout, **kwargs)
|
|
220
231
|
await self._record_success()
|
|
221
232
|
return result
|
|
222
|
-
|
|
223
|
-
except
|
|
233
|
+
|
|
234
|
+
except TimeoutError:
|
|
224
235
|
error_msg = f"Tool '{self.tool_name}' timed out after {effective_timeout}s"
|
|
225
236
|
logger.warning(error_msg)
|
|
226
237
|
await self._record_failure()
|
|
227
|
-
|
|
238
|
+
|
|
228
239
|
if attempt == max_attempts - 1:
|
|
229
|
-
return {
|
|
230
|
-
|
|
231
|
-
"tool_name": self.tool_name,
|
|
232
|
-
"available": False,
|
|
233
|
-
"reason": "timeout"
|
|
234
|
-
}
|
|
235
|
-
|
|
240
|
+
return {"error": error_msg, "tool_name": self.tool_name, "available": False, "reason": "timeout"}
|
|
241
|
+
|
|
236
242
|
except Exception as e:
|
|
237
243
|
error_str = str(e)
|
|
238
244
|
is_connection_error = self._is_connection_error(e)
|
|
239
|
-
|
|
245
|
+
|
|
240
246
|
logger.warning(f"Tool '{self.tool_name}' attempt {attempt + 1} failed: {error_str}")
|
|
241
247
|
await self._record_failure(is_connection_error)
|
|
242
|
-
|
|
248
|
+
|
|
243
249
|
if attempt == max_attempts - 1:
|
|
244
250
|
return {
|
|
245
251
|
"error": f"Tool execution failed after {max_attempts} attempts: {error_str}",
|
|
246
252
|
"tool_name": self.tool_name,
|
|
247
253
|
"available": False,
|
|
248
|
-
"reason": "execution_failed"
|
|
254
|
+
"reason": "execution_failed",
|
|
249
255
|
}
|
|
250
|
-
|
|
256
|
+
|
|
251
257
|
# Exponential backoff
|
|
252
258
|
if attempt < max_attempts - 1:
|
|
253
259
|
logger.debug(f"Waiting {backoff:.1f}s before retry {attempt + 2}")
|
|
254
260
|
await asyncio.sleep(backoff)
|
|
255
261
|
backoff = min(backoff * self.recovery_config.backoff_multiplier, self.recovery_config.max_backoff)
|
|
256
|
-
|
|
262
|
+
|
|
257
263
|
# Should never reach here
|
|
258
264
|
return {
|
|
259
265
|
"error": f"Tool '{self.tool_name}' failed after all attempts",
|
|
260
266
|
"tool_name": self.tool_name,
|
|
261
267
|
"available": False,
|
|
262
|
-
"reason": "exhausted_retries"
|
|
268
|
+
"reason": "exhausted_retries",
|
|
263
269
|
}
|
|
264
270
|
|
|
265
271
|
async def _execute_with_timeout(self, timeout: float, **kwargs: Any) -> Any:
|
|
266
272
|
"""Execute the tool with timeout."""
|
|
267
|
-
call_kwargs = {
|
|
273
|
+
call_kwargs: dict[str, Any] = {
|
|
268
274
|
"tool_name": self.tool_name,
|
|
269
275
|
"arguments": kwargs,
|
|
270
276
|
}
|
|
@@ -273,17 +279,17 @@ class MCPTool:
|
|
|
273
279
|
|
|
274
280
|
try:
|
|
275
281
|
result = await asyncio.wait_for(
|
|
276
|
-
self._sm.call_tool(**call_kwargs),
|
|
277
|
-
timeout=(timeout + 5.0) if timeout else None
|
|
282
|
+
self._sm.call_tool(**call_kwargs), # type: ignore[union-attr]
|
|
283
|
+
timeout=(timeout + 5.0) if timeout else None,
|
|
278
284
|
)
|
|
279
|
-
|
|
285
|
+
|
|
280
286
|
if result.get("isError"):
|
|
281
287
|
error = result.get("error", "Unknown error")
|
|
282
288
|
raise RuntimeError(f"Tool execution failed: {error}")
|
|
283
|
-
|
|
289
|
+
|
|
284
290
|
return result.get("content")
|
|
285
|
-
|
|
286
|
-
except
|
|
291
|
+
|
|
292
|
+
except TimeoutError:
|
|
287
293
|
self.connection_state = ConnectionState.DEGRADED
|
|
288
294
|
raise
|
|
289
295
|
except Exception as e:
|
|
@@ -294,25 +300,62 @@ class MCPTool:
|
|
|
294
300
|
raise
|
|
295
301
|
|
|
296
302
|
async def _is_stream_manager_healthy(self) -> bool:
|
|
297
|
-
"""
|
|
303
|
+
"""
|
|
304
|
+
FIXED: Much more lenient health check.
|
|
305
|
+
|
|
306
|
+
The diagnostic proves SSE transport is healthy, so we should trust it
|
|
307
|
+
instead of doing aggressive health checking that creates false negatives.
|
|
308
|
+
|
|
309
|
+
This replaces the previous ping_servers() call which was too aggressive
|
|
310
|
+
and caused "unhealthy connection" false positives.
|
|
311
|
+
"""
|
|
298
312
|
if self._sm is None:
|
|
299
313
|
return False
|
|
300
|
-
|
|
314
|
+
|
|
315
|
+
# FIXED: Simple check - if we have a StreamManager, assume it's available
|
|
316
|
+
# The underlying SSE transport now has proper health monitoring
|
|
301
317
|
try:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
318
|
+
# Just check if the StreamManager has basic functionality
|
|
319
|
+
if hasattr(self._sm, "transports") and self._sm.transports:
|
|
320
|
+
logger.debug(
|
|
321
|
+
f"StreamManager healthy for '{self.tool_name}' - has {len(self._sm.transports)} transports"
|
|
322
|
+
)
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
# Fallback - try very quick operation with short timeout
|
|
326
|
+
server_info: list[dict[str, Any]] = await asyncio.wait_for( # type: ignore[arg-type]
|
|
327
|
+
self._sm.get_server_info(), timeout=1.0
|
|
328
|
+
)
|
|
329
|
+
healthy = len(server_info) > 0
|
|
330
|
+
logger.debug(f"StreamManager health for '{self.tool_name}': {healthy} (via server_info)")
|
|
331
|
+
return healthy
|
|
332
|
+
|
|
333
|
+
except TimeoutError:
|
|
334
|
+
logger.debug(f"StreamManager health check timed out for '{self.tool_name}' - assuming healthy")
|
|
335
|
+
# FIXED: Timeout doesn't mean unavailable, just slow
|
|
336
|
+
return True
|
|
305
337
|
except Exception as e:
|
|
306
|
-
logger.debug(f"
|
|
307
|
-
|
|
338
|
+
logger.debug(f"StreamManager health check failed for '{self.tool_name}': {e}")
|
|
339
|
+
# FIXED: Most exceptions don't mean the StreamManager is unavailable
|
|
340
|
+
# The transport layer handles real connectivity issues
|
|
341
|
+
return True
|
|
308
342
|
|
|
309
343
|
def _is_connection_error(self, exception: Exception) -> bool:
|
|
310
344
|
"""Determine if an exception indicates a connection problem."""
|
|
311
345
|
error_str = str(exception).lower()
|
|
312
346
|
connection_indicators = [
|
|
313
|
-
"connection lost",
|
|
314
|
-
"
|
|
315
|
-
"
|
|
347
|
+
"connection lost",
|
|
348
|
+
"connection closed",
|
|
349
|
+
"connection refused",
|
|
350
|
+
"broken pipe",
|
|
351
|
+
"timeout",
|
|
352
|
+
"eof",
|
|
353
|
+
"pipe closed",
|
|
354
|
+
"process died",
|
|
355
|
+
"no route to host",
|
|
356
|
+
"no server found",
|
|
357
|
+
"transport not initialized",
|
|
358
|
+
"stream manager unavailable",
|
|
316
359
|
]
|
|
317
360
|
return any(indicator in error_str for indicator in connection_indicators)
|
|
318
361
|
|
|
@@ -321,54 +364,57 @@ class MCPTool:
|
|
|
321
364
|
self.stats.successful_calls += 1
|
|
322
365
|
self.stats.last_success_time = time.time()
|
|
323
366
|
self._consecutive_failures = 0
|
|
324
|
-
|
|
367
|
+
|
|
325
368
|
# Close circuit breaker if it was open
|
|
326
369
|
if self._circuit_open:
|
|
327
370
|
self._circuit_open = False
|
|
328
371
|
self._circuit_open_time = None
|
|
329
372
|
self.connection_state = ConnectionState.HEALTHY
|
|
330
|
-
logger.
|
|
373
|
+
logger.debug(f"Circuit breaker closed for tool '{self.tool_name}' after successful execution")
|
|
331
374
|
|
|
332
375
|
async def _record_failure(self, is_connection_error: bool = False) -> None:
|
|
333
376
|
"""Record a failed execution."""
|
|
334
377
|
self.stats.failed_calls += 1
|
|
335
378
|
self.stats.last_failure_time = time.time()
|
|
336
|
-
|
|
379
|
+
|
|
337
380
|
if is_connection_error:
|
|
338
381
|
self.stats.connection_errors += 1
|
|
339
382
|
self.connection_state = ConnectionState.DISCONNECTED
|
|
340
383
|
else:
|
|
341
384
|
self.connection_state = ConnectionState.DEGRADED
|
|
342
|
-
|
|
385
|
+
|
|
343
386
|
self._consecutive_failures += 1
|
|
344
|
-
|
|
387
|
+
|
|
345
388
|
# Check if we should open the circuit breaker
|
|
346
|
-
if
|
|
347
|
-
not self._circuit_open):
|
|
389
|
+
if self._consecutive_failures >= self.recovery_config.circuit_breaker_threshold and not self._circuit_open:
|
|
348
390
|
self._circuit_open = True
|
|
349
391
|
self._circuit_open_time = time.time()
|
|
350
392
|
self.connection_state = ConnectionState.FAILED
|
|
351
|
-
logger.error(
|
|
393
|
+
logger.error(
|
|
394
|
+
f"Circuit breaker opened for tool '{self.tool_name}' after {self._consecutive_failures} consecutive failures"
|
|
395
|
+
)
|
|
352
396
|
|
|
353
397
|
def _is_circuit_open(self) -> bool:
|
|
354
398
|
"""Check if the circuit breaker is currently open."""
|
|
355
399
|
if not self._circuit_open:
|
|
356
400
|
return False
|
|
357
|
-
|
|
401
|
+
|
|
358
402
|
# Check if enough time has passed to close the circuit
|
|
359
|
-
if (
|
|
360
|
-
|
|
403
|
+
if (
|
|
404
|
+
self._circuit_open_time
|
|
405
|
+
and time.time() - self._circuit_open_time >= self.recovery_config.circuit_breaker_timeout
|
|
406
|
+
):
|
|
361
407
|
self._circuit_open = False
|
|
362
408
|
self._circuit_open_time = None
|
|
363
409
|
self.connection_state = ConnectionState.HEALTHY
|
|
364
|
-
logger.
|
|
410
|
+
logger.debug(f"Circuit breaker reset for tool '{self.tool_name}' after timeout")
|
|
365
411
|
return False
|
|
366
|
-
|
|
412
|
+
|
|
367
413
|
return True
|
|
368
414
|
|
|
369
415
|
# ------------------------------------------------------------------ #
|
|
370
416
|
# Legacy method name support
|
|
371
|
-
async def _aexecute(self, timeout:
|
|
417
|
+
async def _aexecute(self, timeout: float | None = None, **kwargs: Any) -> Any:
|
|
372
418
|
"""Legacy alias for execute() method."""
|
|
373
419
|
return await self.execute(timeout=timeout, **kwargs)
|
|
374
420
|
|
|
@@ -377,23 +423,21 @@ class MCPTool:
|
|
|
377
423
|
# ------------------------------------------------------------------ #
|
|
378
424
|
def is_available(self) -> bool:
|
|
379
425
|
"""Check if this tool is currently available."""
|
|
380
|
-
return (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
426
|
+
return (
|
|
427
|
+
self._sm is not None
|
|
428
|
+
and not self._is_circuit_open()
|
|
429
|
+
and self.connection_state in [ConnectionState.HEALTHY, ConnectionState.DEGRADED]
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def get_stats(self) -> dict[str, Any]:
|
|
385
433
|
"""Get connection and execution statistics."""
|
|
386
434
|
if not self.enable_resilience:
|
|
387
|
-
return {
|
|
388
|
-
|
|
389
|
-
"resilience_enabled": False,
|
|
390
|
-
"available": self._sm is not None
|
|
391
|
-
}
|
|
392
|
-
|
|
435
|
+
return {"tool_name": self.tool_name, "resilience_enabled": False, "available": self._sm is not None}
|
|
436
|
+
|
|
393
437
|
success_rate = 0.0
|
|
394
438
|
if self.stats.total_calls > 0:
|
|
395
439
|
success_rate = (self.stats.successful_calls / self.stats.total_calls) * 100
|
|
396
|
-
|
|
440
|
+
|
|
397
441
|
return {
|
|
398
442
|
"tool_name": self.tool_name,
|
|
399
443
|
"resilience_enabled": True,
|
|
@@ -413,22 +457,22 @@ class MCPTool:
|
|
|
413
457
|
"""Manually reset the circuit breaker."""
|
|
414
458
|
if not self.enable_resilience:
|
|
415
459
|
return
|
|
416
|
-
|
|
460
|
+
|
|
417
461
|
self._circuit_open = False
|
|
418
462
|
self._circuit_open_time = None
|
|
419
463
|
self._consecutive_failures = 0
|
|
420
464
|
self.connection_state = ConnectionState.HEALTHY
|
|
421
|
-
logger.
|
|
465
|
+
logger.debug(f"Circuit breaker manually reset for tool '{self.tool_name}'")
|
|
422
466
|
|
|
423
467
|
def disable_resilience(self) -> None:
|
|
424
468
|
"""Disable resilience features for this tool instance."""
|
|
425
469
|
self.enable_resilience = False
|
|
426
|
-
logger.
|
|
470
|
+
logger.debug(f"Resilience features disabled for tool '{self.tool_name}'")
|
|
427
471
|
|
|
428
|
-
def set_stream_manager(self, stream_manager:
|
|
472
|
+
def set_stream_manager(self, stream_manager: StreamManager | None) -> None:
|
|
429
473
|
"""
|
|
430
474
|
Set or update the StreamManager for this tool.
|
|
431
|
-
|
|
475
|
+
|
|
432
476
|
This can be used by external systems to reconnect tools after
|
|
433
477
|
StreamManager recovery at a higher level.
|
|
434
478
|
"""
|
|
@@ -438,8 +482,8 @@ class MCPTool:
|
|
|
438
482
|
if self._circuit_open:
|
|
439
483
|
self._circuit_open = False
|
|
440
484
|
self._circuit_open_time = None
|
|
441
|
-
logger.
|
|
485
|
+
logger.debug(f"Circuit breaker closed for tool '{self.tool_name}' due to new stream manager")
|
|
442
486
|
else:
|
|
443
487
|
self.connection_state = ConnectionState.DISCONNECTED
|
|
444
|
-
|
|
445
|
-
logger.debug(f"StreamManager {'set' if stream_manager else 'cleared'} for tool '{self.tool_name}'")
|
|
488
|
+
|
|
489
|
+
logger.debug(f"StreamManager {'set' if stream_manager else 'cleared'} for tool '{self.tool_name}'")
|
|
@@ -8,7 +8,7 @@ CLEAN & SIMPLE: Just the essentials - create MCPTool wrappers for remote tools.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
from chuk_tool_processor.logging import get_logger
|
|
14
14
|
from chuk_tool_processor.mcp.mcp_tool import MCPTool, RecoveryConfig
|
|
@@ -25,8 +25,8 @@ async def register_mcp_tools(
|
|
|
25
25
|
# Optional resilience configuration
|
|
26
26
|
default_timeout: float = 30.0,
|
|
27
27
|
enable_resilience: bool = True,
|
|
28
|
-
recovery_config:
|
|
29
|
-
) ->
|
|
28
|
+
recovery_config: RecoveryConfig | None = None,
|
|
29
|
+
) -> list[str]:
|
|
30
30
|
"""
|
|
31
31
|
Pull the remote tool catalogue and create local MCPTool wrappers.
|
|
32
32
|
|
|
@@ -49,10 +49,10 @@ async def register_mcp_tools(
|
|
|
49
49
|
The tool names that were registered.
|
|
50
50
|
"""
|
|
51
51
|
registry = await ToolRegistryProvider.get_registry()
|
|
52
|
-
registered:
|
|
52
|
+
registered: list[str] = []
|
|
53
53
|
|
|
54
54
|
# Get the remote tool catalogue
|
|
55
|
-
mcp_tools:
|
|
55
|
+
mcp_tools: list[dict[str, Any]] = stream_manager.get_all_tools()
|
|
56
56
|
|
|
57
57
|
for tool_def in mcp_tools:
|
|
58
58
|
tool_name = tool_def.get("name")
|
|
@@ -61,7 +61,7 @@ async def register_mcp_tools(
|
|
|
61
61
|
continue
|
|
62
62
|
|
|
63
63
|
description = tool_def.get("description") or f"MCP tool • {tool_name}"
|
|
64
|
-
meta:
|
|
64
|
+
meta: dict[str, Any] = {
|
|
65
65
|
"description": description,
|
|
66
66
|
"is_async": True,
|
|
67
67
|
"tags": {"mcp", "remote"},
|
|
@@ -95,26 +95,26 @@ async def register_mcp_tools(
|
|
|
95
95
|
except Exception as exc:
|
|
96
96
|
logger.error("Failed to register MCP tool '%s': %s", tool_name, exc)
|
|
97
97
|
|
|
98
|
-
logger.
|
|
98
|
+
logger.debug("MCP registration complete - %d tool(s) available", len(registered))
|
|
99
99
|
return registered
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
async def update_mcp_tools_stream_manager(
|
|
103
103
|
namespace: str,
|
|
104
|
-
new_stream_manager:
|
|
104
|
+
new_stream_manager: StreamManager | None,
|
|
105
105
|
) -> int:
|
|
106
106
|
"""
|
|
107
107
|
Update the StreamManager reference for all MCP tools in a namespace.
|
|
108
|
-
|
|
108
|
+
|
|
109
109
|
Useful for reconnecting tools after StreamManager recovery at the service level.
|
|
110
|
-
|
|
110
|
+
|
|
111
111
|
Parameters
|
|
112
112
|
----------
|
|
113
113
|
namespace
|
|
114
114
|
The namespace containing MCP tools to update
|
|
115
115
|
new_stream_manager
|
|
116
116
|
The new StreamManager to use, or None to disconnect
|
|
117
|
-
|
|
117
|
+
|
|
118
118
|
Returns
|
|
119
119
|
-------
|
|
120
120
|
int
|
|
@@ -122,26 +122,26 @@ async def update_mcp_tools_stream_manager(
|
|
|
122
122
|
"""
|
|
123
123
|
registry = await ToolRegistryProvider.get_registry()
|
|
124
124
|
updated_count = 0
|
|
125
|
-
|
|
125
|
+
|
|
126
126
|
try:
|
|
127
127
|
# List all tools in the namespace
|
|
128
128
|
all_tools = await registry.list_tools()
|
|
129
129
|
namespace_tools = [name for ns, name in all_tools if ns == namespace]
|
|
130
|
-
|
|
130
|
+
|
|
131
131
|
for tool_name in namespace_tools:
|
|
132
132
|
try:
|
|
133
133
|
tool = await registry.get_tool(tool_name, namespace)
|
|
134
|
-
if tool and hasattr(tool,
|
|
134
|
+
if tool and hasattr(tool, "set_stream_manager"):
|
|
135
135
|
tool.set_stream_manager(new_stream_manager)
|
|
136
136
|
updated_count += 1
|
|
137
|
-
logger.debug(
|
|
137
|
+
logger.debug("Updated StreamManager for tool '%s:%s'", namespace, tool_name)
|
|
138
138
|
except Exception as e:
|
|
139
|
-
logger.warning(
|
|
140
|
-
|
|
139
|
+
logger.warning("Failed to update StreamManager for tool '%s:%s': %s", namespace, tool_name, e)
|
|
140
|
+
|
|
141
141
|
action = "connected" if new_stream_manager else "disconnected"
|
|
142
|
-
logger.
|
|
143
|
-
|
|
142
|
+
logger.debug("StreamManager %s for %d tools in namespace '%s'", action, updated_count, namespace)
|
|
143
|
+
|
|
144
144
|
except Exception as e:
|
|
145
|
-
logger.error(
|
|
146
|
-
|
|
147
|
-
return updated_count
|
|
145
|
+
logger.error("Failed to update tools in namespace '%s': %s", namespace, e)
|
|
146
|
+
|
|
147
|
+
return updated_count
|