chuk-tool-processor 0.6.28__py3-none-any.whl → 0.6.29__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/mcp/mcp_tool.py +5 -5
- chuk_tool_processor/mcp/stream_manager.py +30 -16
- chuk_tool_processor/mcp/transport/__init__.py +10 -0
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +90 -86
- chuk_tool_processor/mcp/transport/models.py +100 -0
- chuk_tool_processor/mcp/transport/sse_transport.py +56 -56
- chuk_tool_processor/mcp/transport/stdio_transport.py +2 -2
- {chuk_tool_processor-0.6.28.dist-info → chuk_tool_processor-0.6.29.dist-info}/METADATA +74 -1
- {chuk_tool_processor-0.6.28.dist-info → chuk_tool_processor-0.6.29.dist-info}/RECORD +11 -10
- {chuk_tool_processor-0.6.28.dist-info → chuk_tool_processor-0.6.29.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.6.28.dist-info → chuk_tool_processor-0.6.29.dist-info}/top_level.txt +0 -0
|
@@ -370,7 +370,7 @@ class MCPTool:
|
|
|
370
370
|
self._circuit_open = False
|
|
371
371
|
self._circuit_open_time = None
|
|
372
372
|
self.connection_state = ConnectionState.HEALTHY
|
|
373
|
-
logger.
|
|
373
|
+
logger.debug(f"Circuit breaker closed for tool '{self.tool_name}' after successful execution")
|
|
374
374
|
|
|
375
375
|
async def _record_failure(self, is_connection_error: bool = False) -> None:
|
|
376
376
|
"""Record a failed execution."""
|
|
@@ -407,7 +407,7 @@ class MCPTool:
|
|
|
407
407
|
self._circuit_open = False
|
|
408
408
|
self._circuit_open_time = None
|
|
409
409
|
self.connection_state = ConnectionState.HEALTHY
|
|
410
|
-
logger.
|
|
410
|
+
logger.debug(f"Circuit breaker reset for tool '{self.tool_name}' after timeout")
|
|
411
411
|
return False
|
|
412
412
|
|
|
413
413
|
return True
|
|
@@ -462,12 +462,12 @@ class MCPTool:
|
|
|
462
462
|
self._circuit_open_time = None
|
|
463
463
|
self._consecutive_failures = 0
|
|
464
464
|
self.connection_state = ConnectionState.HEALTHY
|
|
465
|
-
logger.
|
|
465
|
+
logger.debug(f"Circuit breaker manually reset for tool '{self.tool_name}'")
|
|
466
466
|
|
|
467
467
|
def disable_resilience(self) -> None:
|
|
468
468
|
"""Disable resilience features for this tool instance."""
|
|
469
469
|
self.enable_resilience = False
|
|
470
|
-
logger.
|
|
470
|
+
logger.debug(f"Resilience features disabled for tool '{self.tool_name}'")
|
|
471
471
|
|
|
472
472
|
def set_stream_manager(self, stream_manager: StreamManager | None) -> None:
|
|
473
473
|
"""
|
|
@@ -482,7 +482,7 @@ class MCPTool:
|
|
|
482
482
|
if self._circuit_open:
|
|
483
483
|
self._circuit_open = False
|
|
484
484
|
self._circuit_open_time = None
|
|
485
|
-
logger.
|
|
485
|
+
logger.debug(f"Circuit breaker closed for tool '{self.tool_name}' due to new stream manager")
|
|
486
486
|
else:
|
|
487
487
|
self.connection_state = ConnectionState.DISCONNECTED
|
|
488
488
|
|
|
@@ -21,6 +21,7 @@ from chuk_tool_processor.mcp.transport import (
|
|
|
21
21
|
MCPBaseTransport,
|
|
22
22
|
SSETransport,
|
|
23
23
|
StdioTransport,
|
|
24
|
+
TimeoutConfig,
|
|
24
25
|
)
|
|
25
26
|
|
|
26
27
|
logger = get_logger("chuk_tool_processor.mcp.stream_manager")
|
|
@@ -38,7 +39,7 @@ class StreamManager:
|
|
|
38
39
|
- HTTP Streamable (modern replacement for SSE, spec 2025-03-26) with graceful headers handling
|
|
39
40
|
"""
|
|
40
41
|
|
|
41
|
-
def __init__(self) -> None:
|
|
42
|
+
def __init__(self, timeout_config: TimeoutConfig | None = None) -> None:
|
|
42
43
|
self.transports: dict[str, MCPBaseTransport] = {}
|
|
43
44
|
self.server_info: list[dict[str, Any]] = []
|
|
44
45
|
self.tool_to_server_map: dict[str, str] = {}
|
|
@@ -46,7 +47,7 @@ class StreamManager:
|
|
|
46
47
|
self.all_tools: list[dict[str, Any]] = []
|
|
47
48
|
self._lock = asyncio.Lock()
|
|
48
49
|
self._closed = False # Track if we've been closed
|
|
49
|
-
self.
|
|
50
|
+
self.timeout_config = timeout_config or TimeoutConfig()
|
|
50
51
|
|
|
51
52
|
# ------------------------------------------------------------------ #
|
|
52
53
|
# factory helpers with enhanced error handling #
|
|
@@ -251,8 +252,12 @@ class StreamManager:
|
|
|
251
252
|
self.transports[server_name] = transport
|
|
252
253
|
|
|
253
254
|
# Ping and get tools with timeout protection (use longer timeouts for slow servers)
|
|
254
|
-
status =
|
|
255
|
-
|
|
255
|
+
status = (
|
|
256
|
+
"Up"
|
|
257
|
+
if await asyncio.wait_for(transport.send_ping(), timeout=self.timeout_config.operation)
|
|
258
|
+
else "Down"
|
|
259
|
+
)
|
|
260
|
+
tools = await asyncio.wait_for(transport.get_tools(), timeout=self.timeout_config.operation)
|
|
256
261
|
|
|
257
262
|
for t in tools:
|
|
258
263
|
name = t.get("name")
|
|
@@ -333,8 +338,12 @@ class StreamManager:
|
|
|
333
338
|
|
|
334
339
|
self.transports[name] = transport
|
|
335
340
|
# Use longer timeouts for slow servers (ping can take time after initialization)
|
|
336
|
-
status =
|
|
337
|
-
|
|
341
|
+
status = (
|
|
342
|
+
"Up"
|
|
343
|
+
if await asyncio.wait_for(transport.send_ping(), timeout=self.timeout_config.operation)
|
|
344
|
+
else "Down"
|
|
345
|
+
)
|
|
346
|
+
tools = await asyncio.wait_for(transport.get_tools(), timeout=self.timeout_config.operation)
|
|
338
347
|
|
|
339
348
|
for t in tools:
|
|
340
349
|
tname = t.get("name")
|
|
@@ -415,8 +424,12 @@ class StreamManager:
|
|
|
415
424
|
|
|
416
425
|
self.transports[name] = transport
|
|
417
426
|
# Use longer timeouts for slow servers (ping can take time after initialization)
|
|
418
|
-
status =
|
|
419
|
-
|
|
427
|
+
status = (
|
|
428
|
+
"Up"
|
|
429
|
+
if await asyncio.wait_for(transport.send_ping(), timeout=self.timeout_config.operation)
|
|
430
|
+
else "Down"
|
|
431
|
+
)
|
|
432
|
+
tools = await asyncio.wait_for(transport.get_tools(), timeout=self.timeout_config.operation)
|
|
420
433
|
|
|
421
434
|
for t in tools:
|
|
422
435
|
tname = t.get("name")
|
|
@@ -462,7 +475,7 @@ class StreamManager:
|
|
|
462
475
|
transport = self.transports[server_name]
|
|
463
476
|
|
|
464
477
|
try:
|
|
465
|
-
tools = await asyncio.wait_for(transport.get_tools(), timeout=
|
|
478
|
+
tools = await asyncio.wait_for(transport.get_tools(), timeout=self.timeout_config.operation)
|
|
466
479
|
logger.debug("Found %d tools for server %s", len(tools), server_name)
|
|
467
480
|
return tools
|
|
468
481
|
except TimeoutError:
|
|
@@ -481,7 +494,7 @@ class StreamManager:
|
|
|
481
494
|
|
|
482
495
|
async def _ping_one(name: str, tr: MCPBaseTransport):
|
|
483
496
|
try:
|
|
484
|
-
ok = await asyncio.wait_for(tr.send_ping(), timeout=
|
|
497
|
+
ok = await asyncio.wait_for(tr.send_ping(), timeout=self.timeout_config.quick)
|
|
485
498
|
except Exception:
|
|
486
499
|
ok = False
|
|
487
500
|
return {"server": name, "ok": ok}
|
|
@@ -496,7 +509,7 @@ class StreamManager:
|
|
|
496
509
|
|
|
497
510
|
async def _one(name: str, tr: MCPBaseTransport):
|
|
498
511
|
try:
|
|
499
|
-
res = await asyncio.wait_for(tr.list_resources(), timeout=
|
|
512
|
+
res = await asyncio.wait_for(tr.list_resources(), timeout=self.timeout_config.operation)
|
|
500
513
|
resources = res.get("resources", []) if isinstance(res, dict) else res
|
|
501
514
|
for item in resources:
|
|
502
515
|
item = dict(item)
|
|
@@ -516,7 +529,7 @@ class StreamManager:
|
|
|
516
529
|
|
|
517
530
|
async def _one(name: str, tr: MCPBaseTransport):
|
|
518
531
|
try:
|
|
519
|
-
res = await asyncio.wait_for(tr.list_prompts(), timeout=
|
|
532
|
+
res = await asyncio.wait_for(tr.list_prompts(), timeout=self.timeout_config.operation)
|
|
520
533
|
prompts = res.get("prompts", []) if isinstance(res, dict) else res
|
|
521
534
|
for item in prompts:
|
|
522
535
|
item = dict(item)
|
|
@@ -643,7 +656,7 @@ class StreamManager:
|
|
|
643
656
|
try:
|
|
644
657
|
results = await asyncio.wait_for(
|
|
645
658
|
asyncio.gather(*[task for _, task in close_tasks], return_exceptions=True),
|
|
646
|
-
timeout=self.
|
|
659
|
+
timeout=self.timeout_config.shutdown,
|
|
647
660
|
)
|
|
648
661
|
|
|
649
662
|
# Process results
|
|
@@ -666,7 +679,8 @@ class StreamManager:
|
|
|
666
679
|
# Brief wait for cancellations to complete
|
|
667
680
|
with contextlib.suppress(TimeoutError):
|
|
668
681
|
await asyncio.wait_for(
|
|
669
|
-
asyncio.gather(*[task for _, task in close_tasks], return_exceptions=True),
|
|
682
|
+
asyncio.gather(*[task for _, task in close_tasks], return_exceptions=True),
|
|
683
|
+
timeout=self.timeout_config.shutdown,
|
|
670
684
|
)
|
|
671
685
|
|
|
672
686
|
async def _sequential_close(self, transport_items: list[tuple[str, MCPBaseTransport]], close_results: list) -> None:
|
|
@@ -675,7 +689,7 @@ class StreamManager:
|
|
|
675
689
|
try:
|
|
676
690
|
await asyncio.wait_for(
|
|
677
691
|
self._close_single_transport(name, transport),
|
|
678
|
-
timeout=
|
|
692
|
+
timeout=self.timeout_config.shutdown,
|
|
679
693
|
)
|
|
680
694
|
logger.debug("Closed transport: %s", name)
|
|
681
695
|
close_results.append((name, True, None))
|
|
@@ -767,7 +781,7 @@ class StreamManager:
|
|
|
767
781
|
|
|
768
782
|
for name, transport in self.transports.items():
|
|
769
783
|
try:
|
|
770
|
-
ping_ok = await asyncio.wait_for(transport.send_ping(), timeout=
|
|
784
|
+
ping_ok = await asyncio.wait_for(transport.send_ping(), timeout=self.timeout_config.quick)
|
|
771
785
|
health_info["transports"][name] = {
|
|
772
786
|
"status": "healthy" if ping_ok else "unhealthy",
|
|
773
787
|
"ping_success": ping_ok,
|
|
@@ -11,6 +11,12 @@ All transports now follow the same interface and provide consistent behavior:
|
|
|
11
11
|
|
|
12
12
|
from .base_transport import MCPBaseTransport
|
|
13
13
|
from .http_streamable_transport import HTTPStreamableTransport
|
|
14
|
+
from .models import (
|
|
15
|
+
HeadersConfig,
|
|
16
|
+
ServerInfo,
|
|
17
|
+
TimeoutConfig,
|
|
18
|
+
TransportMetrics,
|
|
19
|
+
)
|
|
14
20
|
from .sse_transport import SSETransport
|
|
15
21
|
from .stdio_transport import StdioTransport
|
|
16
22
|
|
|
@@ -19,4 +25,8 @@ __all__ = [
|
|
|
19
25
|
"StdioTransport",
|
|
20
26
|
"SSETransport",
|
|
21
27
|
"HTTPStreamableTransport",
|
|
28
|
+
"TimeoutConfig",
|
|
29
|
+
"TransportMetrics",
|
|
30
|
+
"ServerInfo",
|
|
31
|
+
"HeadersConfig",
|
|
22
32
|
]
|
|
@@ -24,6 +24,7 @@ from chuk_mcp.transports.http.transport import (
|
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
from .base_transport import MCPBaseTransport
|
|
27
|
+
from .models import TimeoutConfig, TransportMetrics
|
|
27
28
|
|
|
28
29
|
logger = logging.getLogger(__name__)
|
|
29
30
|
|
|
@@ -40,12 +41,13 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
40
41
|
self,
|
|
41
42
|
url: str,
|
|
42
43
|
api_key: str | None = None,
|
|
43
|
-
headers: dict[str, str] | None = None,
|
|
44
|
+
headers: dict[str, str] | None = None,
|
|
44
45
|
connection_timeout: float = 30.0,
|
|
45
46
|
default_timeout: float = 30.0,
|
|
46
47
|
session_id: str | None = None,
|
|
47
48
|
enable_metrics: bool = True,
|
|
48
|
-
oauth_refresh_callback: Any | None = None,
|
|
49
|
+
oauth_refresh_callback: Any | None = None,
|
|
50
|
+
timeout_config: TimeoutConfig | None = None,
|
|
49
51
|
):
|
|
50
52
|
"""
|
|
51
53
|
Initialize HTTP Streamable transport with enhanced configuration.
|
|
@@ -53,12 +55,13 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
53
55
|
Args:
|
|
54
56
|
url: HTTP server URL (should end with /mcp)
|
|
55
57
|
api_key: Optional API key for authentication
|
|
56
|
-
headers: Optional custom headers
|
|
57
|
-
connection_timeout: Timeout for initial connection
|
|
58
|
-
default_timeout: Default timeout for operations
|
|
58
|
+
headers: Optional custom headers
|
|
59
|
+
connection_timeout: Timeout for initial connection (overrides timeout_config.connect)
|
|
60
|
+
default_timeout: Default timeout for operations (overrides timeout_config.operation)
|
|
59
61
|
session_id: Optional session ID for stateful connections
|
|
60
62
|
enable_metrics: Whether to track performance metrics
|
|
61
|
-
oauth_refresh_callback: Optional async callback to refresh OAuth tokens
|
|
63
|
+
oauth_refresh_callback: Optional async callback to refresh OAuth tokens
|
|
64
|
+
timeout_config: Optional timeout configuration model with connect/operation/quick/shutdown
|
|
62
65
|
"""
|
|
63
66
|
# Ensure URL points to the /mcp endpoint
|
|
64
67
|
if not url.endswith("/mcp"):
|
|
@@ -67,12 +70,18 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
67
70
|
self.url = url
|
|
68
71
|
|
|
69
72
|
self.api_key = api_key
|
|
70
|
-
self.configured_headers = headers or {}
|
|
71
|
-
self.connection_timeout = connection_timeout
|
|
72
|
-
self.default_timeout = default_timeout
|
|
73
|
+
self.configured_headers = headers or {}
|
|
73
74
|
self.session_id = session_id
|
|
74
75
|
self.enable_metrics = enable_metrics
|
|
75
|
-
self.oauth_refresh_callback = oauth_refresh_callback
|
|
76
|
+
self.oauth_refresh_callback = oauth_refresh_callback
|
|
77
|
+
|
|
78
|
+
# Use timeout config or create from individual parameters
|
|
79
|
+
if timeout_config is None:
|
|
80
|
+
timeout_config = TimeoutConfig(connect=connection_timeout, operation=default_timeout)
|
|
81
|
+
|
|
82
|
+
self.timeout_config = timeout_config
|
|
83
|
+
self.connection_timeout = timeout_config.connect
|
|
84
|
+
self.default_timeout = timeout_config.operation
|
|
76
85
|
|
|
77
86
|
logger.debug("HTTP Streamable transport initialized with URL: %s", self.url)
|
|
78
87
|
if self.api_key:
|
|
@@ -93,20 +102,8 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
93
102
|
self._consecutive_failures = 0
|
|
94
103
|
self._max_consecutive_failures = 3
|
|
95
104
|
|
|
96
|
-
# Performance metrics (enhanced like SSE)
|
|
97
|
-
self._metrics =
|
|
98
|
-
"total_calls": 0,
|
|
99
|
-
"successful_calls": 0,
|
|
100
|
-
"failed_calls": 0,
|
|
101
|
-
"total_time": 0.0,
|
|
102
|
-
"avg_response_time": 0.0,
|
|
103
|
-
"last_ping_time": None,
|
|
104
|
-
"initialization_time": None,
|
|
105
|
-
"connection_resets": 0,
|
|
106
|
-
"stream_errors": 0,
|
|
107
|
-
"connection_errors": 0, # NEW
|
|
108
|
-
"recovery_attempts": 0, # NEW
|
|
109
|
-
}
|
|
105
|
+
# Performance metrics (enhanced like SSE) - use Pydantic model
|
|
106
|
+
self._metrics = TransportMetrics() if enable_metrics else None
|
|
110
107
|
|
|
111
108
|
def _get_headers(self) -> dict[str, str]:
|
|
112
109
|
"""Get headers with authentication and custom headers (like SSE)."""
|
|
@@ -136,7 +133,7 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
136
133
|
try:
|
|
137
134
|
import httpx
|
|
138
135
|
|
|
139
|
-
async with httpx.AsyncClient(timeout=
|
|
136
|
+
async with httpx.AsyncClient(timeout=self.timeout_config.quick) as client:
|
|
140
137
|
# Test basic connectivity to base URL
|
|
141
138
|
base_url = self.url.replace("/mcp", "")
|
|
142
139
|
response = await client.get(f"{base_url}/health", headers=self._get_headers())
|
|
@@ -204,8 +201,8 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
204
201
|
# Verify connection with ping (enhanced like SSE)
|
|
205
202
|
logger.debug("Verifying connection with ping...")
|
|
206
203
|
ping_start = time.time()
|
|
207
|
-
# Use
|
|
208
|
-
ping_timeout =
|
|
204
|
+
# Use connect timeout for initial ping - some servers (like Notion) are slow
|
|
205
|
+
ping_timeout = self.timeout_config.connect
|
|
209
206
|
ping_success = await asyncio.wait_for(
|
|
210
207
|
send_ping(self._read_stream, self._write_stream, timeout=ping_timeout),
|
|
211
208
|
timeout=ping_timeout,
|
|
@@ -218,9 +215,9 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
218
215
|
self._consecutive_failures = 0
|
|
219
216
|
|
|
220
217
|
total_init_time = time.time() - start_time
|
|
221
|
-
if self.enable_metrics:
|
|
222
|
-
self._metrics
|
|
223
|
-
self._metrics
|
|
218
|
+
if self.enable_metrics and self._metrics:
|
|
219
|
+
self._metrics.initialization_time = total_init_time
|
|
220
|
+
self._metrics.last_ping_time = ping_time
|
|
224
221
|
|
|
225
222
|
logger.debug(
|
|
226
223
|
"HTTP Streamable transport initialized successfully in %.3fs (ping: %.3fs)",
|
|
@@ -233,27 +230,27 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
233
230
|
# Still consider it initialized since connection was established
|
|
234
231
|
self._initialized = True
|
|
235
232
|
self._consecutive_failures = 1 # Mark one failure
|
|
236
|
-
if self.enable_metrics:
|
|
237
|
-
self._metrics
|
|
233
|
+
if self.enable_metrics and self._metrics:
|
|
234
|
+
self._metrics.initialization_time = time.time() - start_time
|
|
238
235
|
return True
|
|
239
236
|
|
|
240
237
|
except TimeoutError:
|
|
241
238
|
logger.error("HTTP Streamable initialization timed out after %ss", self.connection_timeout)
|
|
242
239
|
await self._cleanup()
|
|
243
|
-
if self.enable_metrics:
|
|
244
|
-
self._metrics
|
|
240
|
+
if self.enable_metrics and self._metrics:
|
|
241
|
+
self._metrics.connection_errors += 1
|
|
245
242
|
return False
|
|
246
243
|
except Exception as e:
|
|
247
244
|
logger.error("Error initializing HTTP Streamable transport: %s", e, exc_info=True)
|
|
248
245
|
await self._cleanup()
|
|
249
|
-
if self.enable_metrics:
|
|
250
|
-
self._metrics
|
|
246
|
+
if self.enable_metrics and self._metrics:
|
|
247
|
+
self._metrics.connection_errors += 1
|
|
251
248
|
return False
|
|
252
249
|
|
|
253
250
|
async def _attempt_recovery(self) -> bool:
|
|
254
251
|
"""Attempt to recover from connection issues (NEW - like SSE resilience)."""
|
|
255
|
-
if self.enable_metrics:
|
|
256
|
-
self._metrics
|
|
252
|
+
if self.enable_metrics and self._metrics:
|
|
253
|
+
self._metrics.recovery_attempts += 1
|
|
257
254
|
|
|
258
255
|
logger.debug("Attempting HTTP connection recovery...")
|
|
259
256
|
|
|
@@ -273,16 +270,16 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
273
270
|
return
|
|
274
271
|
|
|
275
272
|
# Enhanced metrics logging (like SSE)
|
|
276
|
-
if self.enable_metrics and self._metrics
|
|
277
|
-
success_rate = self._metrics
|
|
273
|
+
if self.enable_metrics and self._metrics and self._metrics.total_calls > 0:
|
|
274
|
+
success_rate = self._metrics.successful_calls / self._metrics.total_calls * 100
|
|
278
275
|
logger.debug(
|
|
279
276
|
"HTTP Streamable transport closing - Calls: %d, Success: %.1f%%, "
|
|
280
277
|
"Avg time: %.3fs, Recoveries: %d, Errors: %d",
|
|
281
|
-
self._metrics
|
|
278
|
+
self._metrics.total_calls,
|
|
282
279
|
success_rate,
|
|
283
|
-
self._metrics
|
|
284
|
-
self._metrics
|
|
285
|
-
self._metrics
|
|
280
|
+
self._metrics.avg_response_time,
|
|
281
|
+
self._metrics.recovery_attempts,
|
|
282
|
+
self._metrics.connection_errors,
|
|
286
283
|
)
|
|
287
284
|
|
|
288
285
|
try:
|
|
@@ -323,9 +320,9 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
323
320
|
else:
|
|
324
321
|
self._consecutive_failures += 1
|
|
325
322
|
|
|
326
|
-
if self.enable_metrics:
|
|
323
|
+
if self.enable_metrics and self._metrics:
|
|
327
324
|
ping_time = time.time() - start_time
|
|
328
|
-
self._metrics
|
|
325
|
+
self._metrics.last_ping_time = ping_time
|
|
329
326
|
logger.debug("HTTP Streamable ping completed in %.3fs: %s", ping_time, success)
|
|
330
327
|
|
|
331
328
|
return success
|
|
@@ -336,8 +333,8 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
336
333
|
except Exception as e:
|
|
337
334
|
logger.error("HTTP Streamable ping failed: %s", e)
|
|
338
335
|
self._consecutive_failures += 1
|
|
339
|
-
if self.enable_metrics:
|
|
340
|
-
self._metrics
|
|
336
|
+
if self.enable_metrics and self._metrics:
|
|
337
|
+
self._metrics.stream_errors += 1
|
|
341
338
|
return False
|
|
342
339
|
|
|
343
340
|
def is_connected(self) -> bool:
|
|
@@ -365,9 +362,19 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
365
362
|
timeout=self.default_timeout,
|
|
366
363
|
)
|
|
367
364
|
|
|
368
|
-
# Normalize response
|
|
369
|
-
if
|
|
365
|
+
# Normalize response - handle multiple formats including Pydantic models
|
|
366
|
+
# 1. Check if it's a Pydantic model with tools attribute (e.g., ListToolsResult from chuk_mcp)
|
|
367
|
+
if hasattr(tools_response, "tools"):
|
|
368
|
+
tools = tools_response.tools
|
|
369
|
+
# Convert Pydantic Tool models to dicts if needed
|
|
370
|
+
if tools and len(tools) > 0 and hasattr(tools[0], "model_dump"):
|
|
371
|
+
tools = [t.model_dump() for t in tools]
|
|
372
|
+
elif tools and len(tools) > 0 and hasattr(tools[0], "dict"):
|
|
373
|
+
tools = [t.dict() for t in tools]
|
|
374
|
+
# 2. Check if it's a dict with "tools" key
|
|
375
|
+
elif isinstance(tools_response, dict):
|
|
370
376
|
tools = tools_response.get("tools", [])
|
|
377
|
+
# 3. Check if it's already a list
|
|
371
378
|
elif isinstance(tools_response, list):
|
|
372
379
|
tools = tools_response
|
|
373
380
|
else:
|
|
@@ -390,8 +397,8 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
390
397
|
except Exception as e:
|
|
391
398
|
logger.error("Error getting tools: %s", e)
|
|
392
399
|
self._consecutive_failures += 1
|
|
393
|
-
if self.enable_metrics:
|
|
394
|
-
self._metrics
|
|
400
|
+
if self.enable_metrics and self._metrics:
|
|
401
|
+
self._metrics.stream_errors += 1
|
|
395
402
|
return []
|
|
396
403
|
|
|
397
404
|
async def call_tool(
|
|
@@ -404,8 +411,8 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
404
411
|
tool_timeout = timeout or self.default_timeout
|
|
405
412
|
start_time = time.time()
|
|
406
413
|
|
|
407
|
-
if self.enable_metrics:
|
|
408
|
-
self._metrics
|
|
414
|
+
if self.enable_metrics and self._metrics:
|
|
415
|
+
self._metrics.total_calls += 1
|
|
409
416
|
|
|
410
417
|
try:
|
|
411
418
|
logger.debug("Calling tool '%s' with timeout %ss", tool_name, tool_timeout)
|
|
@@ -430,7 +437,7 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
430
437
|
logger.warning("OAuth error detected: %s", result.get("error"))
|
|
431
438
|
|
|
432
439
|
if self.oauth_refresh_callback:
|
|
433
|
-
logger.
|
|
440
|
+
logger.debug("Attempting OAuth token refresh...")
|
|
434
441
|
try:
|
|
435
442
|
# Call the refresh callback
|
|
436
443
|
new_headers = await self.oauth_refresh_callback()
|
|
@@ -438,18 +445,18 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
438
445
|
if new_headers and "Authorization" in new_headers:
|
|
439
446
|
# Update configured headers with new token
|
|
440
447
|
self.configured_headers.update(new_headers)
|
|
441
|
-
logger.
|
|
448
|
+
logger.debug("OAuth token refreshed, reconnecting...")
|
|
442
449
|
|
|
443
450
|
# Reconnect with new token
|
|
444
451
|
if await self._attempt_recovery():
|
|
445
|
-
logger.
|
|
452
|
+
logger.debug("Retrying tool call after token refresh...")
|
|
446
453
|
# Retry the tool call once with new token
|
|
447
454
|
raw_response = await asyncio.wait_for(
|
|
448
455
|
send_tools_call(self._read_stream, self._write_stream, tool_name, arguments),
|
|
449
456
|
timeout=tool_timeout,
|
|
450
457
|
)
|
|
451
458
|
result = self._normalize_mcp_response(raw_response)
|
|
452
|
-
logger.
|
|
459
|
+
logger.debug("Tool call retry completed")
|
|
453
460
|
else:
|
|
454
461
|
logger.error("Failed to reconnect after token refresh")
|
|
455
462
|
else:
|
|
@@ -488,17 +495,17 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
488
495
|
except Exception as e:
|
|
489
496
|
response_time = time.time() - start_time
|
|
490
497
|
self._consecutive_failures += 1
|
|
491
|
-
if self.enable_metrics:
|
|
498
|
+
if self.enable_metrics and self._metrics:
|
|
492
499
|
self._update_metrics(response_time, False)
|
|
493
|
-
self._metrics
|
|
500
|
+
self._metrics.stream_errors += 1
|
|
494
501
|
|
|
495
502
|
# Enhanced connection error detection
|
|
496
503
|
error_str = str(e).lower()
|
|
497
504
|
if any(indicator in error_str for indicator in ["connection", "disconnected", "broken pipe", "eof"]):
|
|
498
505
|
logger.warning("Connection error detected: %s", e)
|
|
499
506
|
self._initialized = False
|
|
500
|
-
if self.enable_metrics:
|
|
501
|
-
self._metrics
|
|
507
|
+
if self.enable_metrics and self._metrics:
|
|
508
|
+
self._metrics.connection_errors += 1
|
|
502
509
|
|
|
503
510
|
error_msg = f"Tool execution failed: {str(e)}"
|
|
504
511
|
logger.error("Tool '%s' error: %s", tool_name, error_msg)
|
|
@@ -506,14 +513,10 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
506
513
|
|
|
507
514
|
def _update_metrics(self, response_time: float, success: bool) -> None:
|
|
508
515
|
"""Enhanced metrics tracking (like SSE)."""
|
|
509
|
-
if
|
|
510
|
-
|
|
511
|
-
else:
|
|
512
|
-
self._metrics["failed_calls"] += 1
|
|
516
|
+
if not self._metrics:
|
|
517
|
+
return
|
|
513
518
|
|
|
514
|
-
self._metrics
|
|
515
|
-
if self._metrics["total_calls"] > 0:
|
|
516
|
-
self._metrics["avg_response_time"] = self._metrics["total_time"] / self._metrics["total_calls"]
|
|
519
|
+
self._metrics.update_call_metrics(response_time, success)
|
|
517
520
|
|
|
518
521
|
def _is_oauth_error(self, error_msg: str) -> bool:
|
|
519
522
|
"""Detect if error is OAuth-related (NEW)."""
|
|
@@ -612,7 +615,10 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
612
615
|
|
|
613
616
|
def get_metrics(self) -> dict[str, Any]:
|
|
614
617
|
"""Enhanced metrics with health information."""
|
|
615
|
-
|
|
618
|
+
if not self._metrics:
|
|
619
|
+
return {}
|
|
620
|
+
|
|
621
|
+
metrics = self._metrics.to_dict()
|
|
616
622
|
metrics.update(
|
|
617
623
|
{
|
|
618
624
|
"is_connected": self.is_connected(),
|
|
@@ -625,22 +631,20 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
625
631
|
|
|
626
632
|
def reset_metrics(self) -> None:
|
|
627
633
|
"""Enhanced metrics reset preserving health state."""
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
"recovery_attempts": 0,
|
|
643
|
-
}
|
|
634
|
+
if not self._metrics:
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
# Preserve important historical values
|
|
638
|
+
preserved_init_time = self._metrics.initialization_time
|
|
639
|
+
preserved_last_ping = self._metrics.last_ping_time
|
|
640
|
+
preserved_resets = self._metrics.connection_resets
|
|
641
|
+
|
|
642
|
+
# Create new metrics instance with preserved values
|
|
643
|
+
self._metrics = TransportMetrics(
|
|
644
|
+
initialization_time=preserved_init_time,
|
|
645
|
+
last_ping_time=preserved_last_ping,
|
|
646
|
+
connection_resets=preserved_resets,
|
|
647
|
+
)
|
|
644
648
|
|
|
645
649
|
def get_streams(self) -> list[tuple]:
|
|
646
650
|
"""Enhanced streams access with connection check."""
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# chuk_tool_processor/mcp/transport/models.py
|
|
2
|
+
"""
|
|
3
|
+
Pydantic models for MCP transport configuration and metrics.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TimeoutConfig(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
Unified timeout configuration for all MCP operations.
|
|
16
|
+
|
|
17
|
+
Just 4 simple, logical timeout categories:
|
|
18
|
+
- connect: Connection establishment, initialization, session discovery (30s default)
|
|
19
|
+
- operation: Normal operations like tool calls, listing resources (30s default)
|
|
20
|
+
- quick: Fast health checks and pings (5s default)
|
|
21
|
+
- shutdown: Cleanup and shutdown operations (2s default)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
connect: float = Field(
|
|
25
|
+
default=30.0, description="Timeout for connection establishment, initialization, and session discovery"
|
|
26
|
+
)
|
|
27
|
+
operation: float = Field(
|
|
28
|
+
default=30.0, description="Timeout for normal operations (tool calls, listing tools/resources/prompts)"
|
|
29
|
+
)
|
|
30
|
+
quick: float = Field(default=5.0, description="Timeout for quick health checks and pings")
|
|
31
|
+
shutdown: float = Field(default=2.0, description="Timeout for shutdown and cleanup operations")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TransportMetrics(BaseModel):
|
|
35
|
+
"""Performance and connection metrics for transports."""
|
|
36
|
+
|
|
37
|
+
model_config = {"validate_assignment": True}
|
|
38
|
+
|
|
39
|
+
total_calls: int = Field(default=0, description="Total number of calls made")
|
|
40
|
+
successful_calls: int = Field(default=0, description="Number of successful calls")
|
|
41
|
+
failed_calls: int = Field(default=0, description="Number of failed calls")
|
|
42
|
+
total_time: float = Field(default=0.0, description="Total time spent on calls")
|
|
43
|
+
avg_response_time: float = Field(default=0.0, description="Average response time")
|
|
44
|
+
last_ping_time: float | None = Field(default=None, description="Time taken for last ping")
|
|
45
|
+
initialization_time: float | None = Field(default=None, description="Time taken for initialization")
|
|
46
|
+
connection_resets: int = Field(default=0, description="Number of connection resets")
|
|
47
|
+
stream_errors: int = Field(default=0, description="Number of stream errors")
|
|
48
|
+
connection_errors: int = Field(default=0, description="Number of connection errors")
|
|
49
|
+
recovery_attempts: int = Field(default=0, description="Number of recovery attempts")
|
|
50
|
+
session_discoveries: int = Field(default=0, description="Number of session discoveries (SSE)")
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
"""Convert to dictionary format."""
|
|
54
|
+
return self.model_dump()
|
|
55
|
+
|
|
56
|
+
def update_call_metrics(self, response_time: float, success: bool) -> None:
|
|
57
|
+
"""Update metrics after a call."""
|
|
58
|
+
if success:
|
|
59
|
+
self.successful_calls += 1
|
|
60
|
+
else:
|
|
61
|
+
self.failed_calls += 1
|
|
62
|
+
|
|
63
|
+
self.total_time += response_time
|
|
64
|
+
if self.total_calls > 0:
|
|
65
|
+
self.avg_response_time = self.total_time / self.total_calls
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServerInfo(BaseModel):
|
|
69
|
+
"""Information about a server in StreamManager."""
|
|
70
|
+
|
|
71
|
+
id: int = Field(description="Server ID")
|
|
72
|
+
name: str = Field(description="Server name")
|
|
73
|
+
tools: int = Field(description="Number of tools available")
|
|
74
|
+
status: str = Field(description="Server status (Up/Down)")
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
"""Convert to dictionary format."""
|
|
78
|
+
return self.model_dump()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HeadersConfig(BaseModel):
|
|
82
|
+
"""Configuration for HTTP headers."""
|
|
83
|
+
|
|
84
|
+
headers: dict[str, str] = Field(default_factory=dict, description="Custom HTTP headers")
|
|
85
|
+
|
|
86
|
+
def get_headers(self) -> dict[str, str]:
|
|
87
|
+
"""Get headers as dict."""
|
|
88
|
+
return self.headers.copy()
|
|
89
|
+
|
|
90
|
+
def update_headers(self, new_headers: dict[str, str]) -> None:
|
|
91
|
+
"""Update headers with new values."""
|
|
92
|
+
self.headers.update(new_headers)
|
|
93
|
+
|
|
94
|
+
def has_authorization(self) -> bool:
|
|
95
|
+
"""Check if Authorization header is present."""
|
|
96
|
+
return "Authorization" in self.headers
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict[str, Any]:
|
|
99
|
+
"""Convert to dictionary format."""
|
|
100
|
+
return self.model_dump()
|
|
@@ -19,6 +19,7 @@ from typing import Any
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
21
|
from .base_transport import MCPBaseTransport
|
|
22
|
+
from .models import TimeoutConfig, TransportMetrics
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
@@ -38,7 +39,8 @@ class SSETransport(MCPBaseTransport):
|
|
|
38
39
|
connection_timeout: float = 30.0,
|
|
39
40
|
default_timeout: float = 60.0,
|
|
40
41
|
enable_metrics: bool = True,
|
|
41
|
-
oauth_refresh_callback: Any | None = None,
|
|
42
|
+
oauth_refresh_callback: Any | None = None,
|
|
43
|
+
timeout_config: TimeoutConfig | None = None,
|
|
42
44
|
):
|
|
43
45
|
"""
|
|
44
46
|
Initialize SSE transport.
|
|
@@ -46,10 +48,16 @@ class SSETransport(MCPBaseTransport):
|
|
|
46
48
|
self.url = url.rstrip("/")
|
|
47
49
|
self.api_key = api_key
|
|
48
50
|
self.configured_headers = headers or {}
|
|
49
|
-
self.connection_timeout = connection_timeout
|
|
50
|
-
self.default_timeout = default_timeout
|
|
51
51
|
self.enable_metrics = enable_metrics
|
|
52
|
-
self.oauth_refresh_callback = oauth_refresh_callback
|
|
52
|
+
self.oauth_refresh_callback = oauth_refresh_callback
|
|
53
|
+
|
|
54
|
+
# Use timeout config or create from individual parameters
|
|
55
|
+
if timeout_config is None:
|
|
56
|
+
timeout_config = TimeoutConfig(connect=connection_timeout, operation=default_timeout)
|
|
57
|
+
|
|
58
|
+
self.timeout_config = timeout_config
|
|
59
|
+
self.connection_timeout = timeout_config.connect
|
|
60
|
+
self.default_timeout = timeout_config.operation
|
|
53
61
|
|
|
54
62
|
logger.debug("SSE Transport initialized with URL: %s", self.url)
|
|
55
63
|
|
|
@@ -75,18 +83,8 @@ class SSETransport(MCPBaseTransport):
|
|
|
75
83
|
self._connection_grace_period = 30.0 # NEW: Grace period after initialization
|
|
76
84
|
self._initialization_time = None # NEW: Track when we initialized
|
|
77
85
|
|
|
78
|
-
# Performance metrics
|
|
79
|
-
self._metrics =
|
|
80
|
-
"total_calls": 0,
|
|
81
|
-
"successful_calls": 0,
|
|
82
|
-
"failed_calls": 0,
|
|
83
|
-
"total_time": 0.0,
|
|
84
|
-
"avg_response_time": 0.0,
|
|
85
|
-
"last_ping_time": None,
|
|
86
|
-
"initialization_time": None,
|
|
87
|
-
"session_discoveries": 0,
|
|
88
|
-
"stream_errors": 0,
|
|
89
|
-
}
|
|
86
|
+
# Performance metrics - use Pydantic model
|
|
87
|
+
self._metrics = TransportMetrics() if enable_metrics else None
|
|
90
88
|
|
|
91
89
|
def _construct_sse_url(self, base_url: str) -> str:
|
|
92
90
|
"""Construct the SSE endpoint URL from the base URL."""
|
|
@@ -176,7 +174,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
176
174
|
|
|
177
175
|
# Wait for session discovery
|
|
178
176
|
logger.debug("Waiting for session discovery...")
|
|
179
|
-
session_timeout =
|
|
177
|
+
session_timeout = self.timeout_config.connect
|
|
180
178
|
session_start = time.time()
|
|
181
179
|
|
|
182
180
|
while not self.message_url and (time.time() - session_start) < session_timeout:
|
|
@@ -195,8 +193,8 @@ class SSETransport(MCPBaseTransport):
|
|
|
195
193
|
await self._cleanup()
|
|
196
194
|
return False
|
|
197
195
|
|
|
198
|
-
if self.enable_metrics:
|
|
199
|
-
self._metrics
|
|
196
|
+
if self.enable_metrics and self._metrics:
|
|
197
|
+
self._metrics.session_discoveries += 1
|
|
200
198
|
|
|
201
199
|
logger.debug("Session endpoint discovered: %s", self.message_url)
|
|
202
200
|
|
|
@@ -226,9 +224,9 @@ class SSETransport(MCPBaseTransport):
|
|
|
226
224
|
self._last_successful_ping = time.time()
|
|
227
225
|
self._consecutive_failures = 0 # Reset failure count
|
|
228
226
|
|
|
229
|
-
if self.enable_metrics:
|
|
227
|
+
if self.enable_metrics and self._metrics:
|
|
230
228
|
init_time = time.time() - start_time
|
|
231
|
-
self._metrics
|
|
229
|
+
self._metrics.initialization_time = init_time
|
|
232
230
|
|
|
233
231
|
logger.debug("SSE transport initialized successfully in %.3fs", time.time() - start_time)
|
|
234
232
|
return True
|
|
@@ -336,8 +334,8 @@ class SSETransport(MCPBaseTransport):
|
|
|
336
334
|
logger.debug("Non-JSON data in SSE stream (ignoring): %s", e)
|
|
337
335
|
|
|
338
336
|
except Exception as e:
|
|
339
|
-
if self.enable_metrics:
|
|
340
|
-
self._metrics
|
|
337
|
+
if self.enable_metrics and self._metrics:
|
|
338
|
+
self._metrics.stream_errors += 1
|
|
341
339
|
logger.error("SSE stream processing error: %s", e)
|
|
342
340
|
# FIXED: Don't increment consecutive failures for stream processing errors
|
|
343
341
|
# These are often temporary and don't indicate connection health
|
|
@@ -421,7 +419,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
421
419
|
start_time = time.time()
|
|
422
420
|
try:
|
|
423
421
|
# Use tools/list as a lightweight ping since not all servers support ping
|
|
424
|
-
response = await self._send_request("tools/list", {}, timeout=
|
|
422
|
+
response = await self._send_request("tools/list", {}, timeout=self.timeout_config.quick)
|
|
425
423
|
|
|
426
424
|
success = "error" not in response
|
|
427
425
|
|
|
@@ -429,9 +427,9 @@ class SSETransport(MCPBaseTransport):
|
|
|
429
427
|
self._last_successful_ping = time.time()
|
|
430
428
|
# FIXED: Don't reset consecutive failures here - let tool calls do that
|
|
431
429
|
|
|
432
|
-
if self.enable_metrics:
|
|
430
|
+
if self.enable_metrics and self._metrics:
|
|
433
431
|
ping_time = time.time() - start_time
|
|
434
|
-
self._metrics
|
|
432
|
+
self._metrics.last_ping_time = ping_time
|
|
435
433
|
logger.debug("SSE ping completed in %.3fs: %s", ping_time, success)
|
|
436
434
|
|
|
437
435
|
return success
|
|
@@ -509,8 +507,8 @@ class SSETransport(MCPBaseTransport):
|
|
|
509
507
|
return {"isError": True, "error": "Transport not initialized"}
|
|
510
508
|
|
|
511
509
|
start_time = time.time()
|
|
512
|
-
if self.enable_metrics:
|
|
513
|
-
self._metrics
|
|
510
|
+
if self.enable_metrics and self._metrics:
|
|
511
|
+
self._metrics.total_calls += 1
|
|
514
512
|
|
|
515
513
|
try:
|
|
516
514
|
logger.debug("Calling tool '%s' with arguments: %s", tool_name, arguments)
|
|
@@ -528,7 +526,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
528
526
|
logger.warning("OAuth error detected: %s", error_msg)
|
|
529
527
|
|
|
530
528
|
if self.oauth_refresh_callback:
|
|
531
|
-
logger.
|
|
529
|
+
logger.debug("Attempting OAuth token refresh...")
|
|
532
530
|
try:
|
|
533
531
|
# Call the refresh callback
|
|
534
532
|
new_headers = await self.oauth_refresh_callback()
|
|
@@ -536,7 +534,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
536
534
|
if new_headers and "Authorization" in new_headers:
|
|
537
535
|
# Update configured headers with new token
|
|
538
536
|
self.configured_headers.update(new_headers)
|
|
539
|
-
logger.
|
|
537
|
+
logger.debug("OAuth token refreshed, retrying tool call...")
|
|
540
538
|
|
|
541
539
|
# Retry the tool call once with new token
|
|
542
540
|
response = await self._send_request(
|
|
@@ -545,7 +543,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
545
543
|
|
|
546
544
|
# Check if retry succeeded
|
|
547
545
|
if "error" not in response:
|
|
548
|
-
logger.
|
|
546
|
+
logger.debug("Tool call succeeded after token refresh")
|
|
549
547
|
result = response.get("result", {})
|
|
550
548
|
normalized_result = self._normalize_mcp_response({"result": result})
|
|
551
549
|
|
|
@@ -592,14 +590,10 @@ class SSETransport(MCPBaseTransport):
|
|
|
592
590
|
|
|
593
591
|
def _update_metrics(self, response_time: float, success: bool) -> None:
|
|
594
592
|
"""Update performance metrics."""
|
|
595
|
-
if
|
|
596
|
-
|
|
597
|
-
else:
|
|
598
|
-
self._metrics["failed_calls"] += 1
|
|
593
|
+
if not self._metrics:
|
|
594
|
+
return
|
|
599
595
|
|
|
600
|
-
self._metrics
|
|
601
|
-
if self._metrics["total_calls"] > 0:
|
|
602
|
-
self._metrics["avg_response_time"] = self._metrics["total_time"] / self._metrics["total_calls"]
|
|
596
|
+
self._metrics.update_call_metrics(response_time, success)
|
|
603
597
|
|
|
604
598
|
def _is_oauth_error(self, error_msg: str) -> bool:
|
|
605
599
|
"""Detect if error is OAuth-related (NEW)."""
|
|
@@ -625,7 +619,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
625
619
|
return {}
|
|
626
620
|
|
|
627
621
|
try:
|
|
628
|
-
response = await self._send_request("resources/list", {}, timeout=
|
|
622
|
+
response = await self._send_request("resources/list", {}, timeout=self.timeout_config.operation)
|
|
629
623
|
if "error" in response:
|
|
630
624
|
logger.debug("Resources not supported: %s", response["error"])
|
|
631
625
|
return {}
|
|
@@ -640,7 +634,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
640
634
|
return {}
|
|
641
635
|
|
|
642
636
|
try:
|
|
643
|
-
response = await self._send_request("prompts/list", {}, timeout=
|
|
637
|
+
response = await self._send_request("prompts/list", {}, timeout=self.timeout_config.operation)
|
|
644
638
|
if "error" in response:
|
|
645
639
|
logger.debug("Prompts not supported: %s", response["error"])
|
|
646
640
|
return {}
|
|
@@ -655,12 +649,12 @@ class SSETransport(MCPBaseTransport):
|
|
|
655
649
|
return
|
|
656
650
|
|
|
657
651
|
# Log final metrics
|
|
658
|
-
if self.enable_metrics and self._metrics
|
|
652
|
+
if self.enable_metrics and self._metrics and self._metrics.total_calls > 0:
|
|
659
653
|
logger.debug(
|
|
660
654
|
"SSE transport closing - Total calls: %d, Success rate: %.1f%%, Avg response time: %.3fs",
|
|
661
|
-
self._metrics
|
|
662
|
-
(self._metrics
|
|
663
|
-
self._metrics
|
|
655
|
+
self._metrics.total_calls,
|
|
656
|
+
(self._metrics.successful_calls / self._metrics.total_calls * 100),
|
|
657
|
+
self._metrics.avg_response_time,
|
|
664
658
|
)
|
|
665
659
|
|
|
666
660
|
await self._cleanup()
|
|
@@ -709,7 +703,10 @@ class SSETransport(MCPBaseTransport):
|
|
|
709
703
|
|
|
710
704
|
def get_metrics(self) -> dict[str, Any]:
|
|
711
705
|
"""Get performance and connection metrics with health info."""
|
|
712
|
-
|
|
706
|
+
if not self._metrics:
|
|
707
|
+
return {}
|
|
708
|
+
|
|
709
|
+
metrics = self._metrics.to_dict()
|
|
713
710
|
metrics.update(
|
|
714
711
|
{
|
|
715
712
|
"is_connected": self.is_connected(),
|
|
@@ -729,17 +726,20 @@ class SSETransport(MCPBaseTransport):
|
|
|
729
726
|
|
|
730
727
|
def reset_metrics(self) -> None:
|
|
731
728
|
"""Reset performance metrics."""
|
|
732
|
-
self._metrics
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
729
|
+
if not self._metrics:
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
# Preserve important historical values
|
|
733
|
+
preserved_last_ping = self._metrics.last_ping_time
|
|
734
|
+
preserved_init_time = self._metrics.initialization_time
|
|
735
|
+
preserved_discoveries = self._metrics.session_discoveries
|
|
736
|
+
|
|
737
|
+
# Create new metrics instance with preserved values
|
|
738
|
+
self._metrics = TransportMetrics(
|
|
739
|
+
last_ping_time=preserved_last_ping,
|
|
740
|
+
initialization_time=preserved_init_time,
|
|
741
|
+
session_discoveries=preserved_discoveries,
|
|
742
|
+
)
|
|
743
743
|
|
|
744
744
|
def get_streams(self) -> list[tuple]:
|
|
745
745
|
"""SSE transport doesn't expose raw streams."""
|
|
@@ -200,8 +200,8 @@ class StdioTransport(MCPBaseTransport):
|
|
|
200
200
|
# Enhanced health verification (like SSE)
|
|
201
201
|
logger.debug("Verifying connection with ping...")
|
|
202
202
|
ping_start = time.time()
|
|
203
|
-
# Use
|
|
204
|
-
ping_success = await asyncio.wait_for(send_ping(*self._streams), timeout=
|
|
203
|
+
# Use default timeout for initial ping verification
|
|
204
|
+
ping_success = await asyncio.wait_for(send_ping(*self._streams), timeout=self.default_timeout)
|
|
205
205
|
ping_time = time.time() - ping_start
|
|
206
206
|
|
|
207
207
|
if ping_success:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chuk-tool-processor
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.29
|
|
4
4
|
Summary: Async-native framework for registering, discovering, and executing tools referenced in LLM responses
|
|
5
5
|
Author-email: CHUK Team <chrishayuk@somejunkmailbox.com>
|
|
6
6
|
Maintainer-email: CHUK Team <chrishayuk@somejunkmailbox.com>
|
|
@@ -941,6 +941,79 @@ async def test_calculator():
|
|
|
941
941
|
|
|
942
942
|
## Configuration
|
|
943
943
|
|
|
944
|
+
### Timeout Configuration
|
|
945
|
+
|
|
946
|
+
CHUK Tool Processor uses a unified timeout configuration system that applies to all MCP transports (HTTP Streamable, SSE, STDIO) and the StreamManager. Instead of managing dozens of individual timeout values, there are just **4 logical timeout categories**:
|
|
947
|
+
|
|
948
|
+
```python
|
|
949
|
+
from chuk_tool_processor.mcp.transport import TimeoutConfig
|
|
950
|
+
|
|
951
|
+
# Create custom timeout configuration
|
|
952
|
+
timeout_config = TimeoutConfig(
|
|
953
|
+
connect=30.0, # Connection establishment, initialization, session discovery
|
|
954
|
+
operation=30.0, # Normal operations (tool calls, listing tools/resources/prompts)
|
|
955
|
+
quick=5.0, # Fast health checks and pings
|
|
956
|
+
shutdown=2.0 # Cleanup and shutdown operations
|
|
957
|
+
)
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
**Using timeout configuration with StreamManager:**
|
|
961
|
+
|
|
962
|
+
```python
|
|
963
|
+
from chuk_tool_processor.mcp.stream_manager import StreamManager
|
|
964
|
+
from chuk_tool_processor.mcp.transport import TimeoutConfig
|
|
965
|
+
|
|
966
|
+
# Create StreamManager with custom timeouts
|
|
967
|
+
timeout_config = TimeoutConfig(
|
|
968
|
+
connect=60.0, # Longer for slow initialization
|
|
969
|
+
operation=45.0, # Longer for heavy operations
|
|
970
|
+
quick=3.0, # Faster health checks
|
|
971
|
+
shutdown=5.0 # More time for cleanup
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
manager = StreamManager(timeout_config=timeout_config)
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
**Timeout categories explained:**
|
|
978
|
+
|
|
979
|
+
| Category | Default | Used For | Examples |
|
|
980
|
+
|----------|---------|----------|----------|
|
|
981
|
+
| `connect` | 30.0s | Connection setup, initialization, discovery | HTTP connection, SSE session discovery, STDIO subprocess launch |
|
|
982
|
+
| `operation` | 30.0s | Normal tool operations | Tool calls, listing tools/resources/prompts, get_tools() |
|
|
983
|
+
| `quick` | 5.0s | Fast health/status checks | Ping operations, health checks |
|
|
984
|
+
| `shutdown` | 2.0s | Cleanup and teardown | Transport close, connection cleanup |
|
|
985
|
+
|
|
986
|
+
**Why this matters:**
|
|
987
|
+
- ✅ **Simple**: 4 timeout values instead of 20+
|
|
988
|
+
- ✅ **Consistent**: Same timeout behavior across all transports
|
|
989
|
+
- ✅ **Configurable**: Adjust timeouts based on your environment (slow networks, large datasets, etc.)
|
|
990
|
+
- ✅ **Type-safe**: Pydantic validation ensures correct values
|
|
991
|
+
|
|
992
|
+
**Example: Adjusting for slow environments**
|
|
993
|
+
|
|
994
|
+
```python
|
|
995
|
+
from chuk_tool_processor.mcp import setup_mcp_stdio
|
|
996
|
+
from chuk_tool_processor.mcp.transport import TimeoutConfig
|
|
997
|
+
|
|
998
|
+
# For slow network or resource-constrained environments
|
|
999
|
+
slow_timeouts = TimeoutConfig(
|
|
1000
|
+
connect=120.0, # Allow more time for package downloads
|
|
1001
|
+
operation=60.0, # Allow more time for heavy operations
|
|
1002
|
+
quick=10.0, # Be patient with health checks
|
|
1003
|
+
shutdown=10.0 # Allow thorough cleanup
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
processor, manager = await setup_mcp_stdio(
|
|
1007
|
+
config_file="mcp_config.json",
|
|
1008
|
+
servers=["sqlite"],
|
|
1009
|
+
namespace="db",
|
|
1010
|
+
initialization_timeout=120.0
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
# Set custom timeouts on the manager
|
|
1014
|
+
manager.timeout_config = slow_timeouts
|
|
1015
|
+
```
|
|
1016
|
+
|
|
944
1017
|
### Environment Variables
|
|
945
1018
|
|
|
946
1019
|
| Variable | Default | Description |
|
|
@@ -17,17 +17,18 @@ chuk_tool_processor/logging/formatter.py,sha256=gxbkfR-J-ksRiNfxUijVm4iHUd6OAau-
|
|
|
17
17
|
chuk_tool_processor/logging/helpers.py,sha256=207AXMHGlw6kq1cFiwLOJRGYgzuuKvTWn94aCJvZZXs,5751
|
|
18
18
|
chuk_tool_processor/logging/metrics.py,sha256=8LRHjgkfdcQnh4H7AP2FJDfcRO3q1UpsBSwoHiuUqXY,3524
|
|
19
19
|
chuk_tool_processor/mcp/__init__.py,sha256=xqtoSGX1_5jZDJ6AKJpBByaytS22baDOrhzFiecvVSs,1031
|
|
20
|
-
chuk_tool_processor/mcp/mcp_tool.py,sha256=
|
|
20
|
+
chuk_tool_processor/mcp/mcp_tool.py,sha256=qCLyapwbGtqUFj7-pTIov9XJEBV8dL_qEpprjcjXWAk,19175
|
|
21
21
|
chuk_tool_processor/mcp/register_mcp_tools.py,sha256=OyHczwVnqhvBZO9g4I0T56EPMvFYBOl0Y2ivNPdKjCE,4822
|
|
22
22
|
chuk_tool_processor/mcp/setup_mcp_http_streamable.py,sha256=8NCjeEZjV0KrapCqAvGh6jr5G8B24xOxxAaKUyoiykw,4935
|
|
23
23
|
chuk_tool_processor/mcp/setup_mcp_sse.py,sha256=gvixVml8HN2BdM9Ug_JYp0yHqA1owieyX8Yxx0HNOqg,4174
|
|
24
24
|
chuk_tool_processor/mcp/setup_mcp_stdio.py,sha256=KxCC0BL0C6z5ZHxBzPhWZC9CKrGUACXqx1tkjru-UYI,2922
|
|
25
|
-
chuk_tool_processor/mcp/stream_manager.py,sha256=
|
|
26
|
-
chuk_tool_processor/mcp/transport/__init__.py,sha256=
|
|
25
|
+
chuk_tool_processor/mcp/stream_manager.py,sha256=398j65oGvMMwi_EC9qS2EPHXvGefUDJ4IR9Tkg4jaGY,34781
|
|
26
|
+
chuk_tool_processor/mcp/transport/__init__.py,sha256=od-7f_cYv31_ioZCJu57ozA8IXywMyf-0N4HlM2J_bU,841
|
|
27
27
|
chuk_tool_processor/mcp/transport/base_transport.py,sha256=rG61TlaignbVZbsqdBS38TnFzTVO666ehKEI0IUAJCM,8675
|
|
28
|
-
chuk_tool_processor/mcp/transport/http_streamable_transport.py,sha256=
|
|
29
|
-
chuk_tool_processor/mcp/transport/
|
|
30
|
-
chuk_tool_processor/mcp/transport/
|
|
28
|
+
chuk_tool_processor/mcp/transport/http_streamable_transport.py,sha256=m2_SXHWeFT2fEfpGY88YYLOQIS88YNVWxJtyWK7kJF0,27231
|
|
29
|
+
chuk_tool_processor/mcp/transport/models.py,sha256=nve0EMHXMzRIzZNdWpK_QyWSYidUF088GvQHZsmI_Eo,3994
|
|
30
|
+
chuk_tool_processor/mcp/transport/sse_transport.py,sha256=CDAVutW6X4ykgJipJAfTt4JEEpzwKteM8vV5AtRX3z4,30781
|
|
31
|
+
chuk_tool_processor/mcp/transport/stdio_transport.py,sha256=SHECMFlFxmT6XKLJ9FC1arXCO4t1yMr6kfNz7QpRQew,29588
|
|
31
32
|
chuk_tool_processor/models/__init__.py,sha256=A3ysSvRxaxso_AN57QZt5ZYahJH5zlL-IENqNaius84,41
|
|
32
33
|
chuk_tool_processor/models/execution_strategy.py,sha256=O0h8d8JSgm-tv26Cc5jAkZqup8vIgx0zfb5n0b2vpSk,1967
|
|
33
34
|
chuk_tool_processor/models/streaming_tool.py,sha256=3IXe9VV6sgPHzMeHpuNzZThFhu5BuB3MQdoYSogz348,3359
|
|
@@ -54,7 +55,7 @@ chuk_tool_processor/registry/providers/__init__.py,sha256=iGc_2JzlYJSBRQ6tFbX781
|
|
|
54
55
|
chuk_tool_processor/registry/providers/memory.py,sha256=udfboAHH0gRxtnf3GsI3wMshhobJxYnCkMwKjQ_uqkw,5017
|
|
55
56
|
chuk_tool_processor/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
56
57
|
chuk_tool_processor/utils/validation.py,sha256=jHPO65sB61ynm9P6V3th4pN7j4u0SQhYR-bstj5QjnI,4175
|
|
57
|
-
chuk_tool_processor-0.6.
|
|
58
|
-
chuk_tool_processor-0.6.
|
|
59
|
-
chuk_tool_processor-0.6.
|
|
60
|
-
chuk_tool_processor-0.6.
|
|
58
|
+
chuk_tool_processor-0.6.29.dist-info/METADATA,sha256=_kc0aseQ8XgB6T3HzaIzxhp9LTh8z7ZgnDMJ9intc2M,42164
|
|
59
|
+
chuk_tool_processor-0.6.29.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
60
|
+
chuk_tool_processor-0.6.29.dist-info/top_level.txt,sha256=7lTsnuRx4cOW4U2sNJWNxl4ZTt_J1ndkjTbj3pHPY5M,20
|
|
61
|
+
chuk_tool_processor-0.6.29.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|