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