chuk-tool-processor 0.6.27__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.

@@ -260,7 +260,7 @@ class ToolProcessor:
260
260
  unknown_tools.append(call.tool)
261
261
 
262
262
  if unknown_tools:
263
- self.logger.warning(f"Unknown tools: {unknown_tools}")
263
+ self.logger.debug(f"Unknown tools: {unknown_tools}")
264
264
 
265
265
  # Execute tools
266
266
  results = await self.executor.execute(calls, timeout=timeout)
@@ -412,7 +412,7 @@ class ToolProcessor:
412
412
  duration=duration,
413
413
  num_calls=0,
414
414
  )
415
- self.logger.error(f"Parser {parser_name} failed: {str(e)}")
415
+ self.logger.debug(f"Parser {parser_name} failed: {str(e)}")
416
416
  return []
417
417
 
418
418
 
@@ -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.info(f"Circuit breaker closed for tool '{self.tool_name}' after successful execution")
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.info(f"Circuit breaker reset for tool '{self.tool_name}' after timeout")
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.info(f"Circuit breaker manually reset for tool '{self.tool_name}'")
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.info(f"Resilience features disabled for tool '{self.tool_name}'")
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.info(f"Circuit breaker closed for tool '{self.tool_name}' due to new stream manager")
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._shutdown_timeout = 2.0 # Maximum time to spend on shutdown
50
+ self.timeout_config = timeout_config or TimeoutConfig()
50
51
 
51
52
  # ------------------------------------------------------------------ #
52
53
  # factory helpers with enhanced error handling #
@@ -182,7 +183,7 @@ class StreamManager:
182
183
  params, connection_timeout=initialization_timeout, default_timeout=default_timeout
183
184
  )
184
185
  elif transport_type == "sse":
185
- logger.warning(
186
+ logger.debug(
186
187
  "Using SSE transport in initialize() - consider using initialize_with_sse() instead"
187
188
  )
188
189
  params = await load_config(config_file, server_name)
@@ -195,7 +196,7 @@ class StreamManager:
195
196
  sse_url = "http://localhost:8000"
196
197
  api_key = None
197
198
  headers = {}
198
- logger.warning("No URL configured for SSE transport, using default: %s", sse_url)
199
+ logger.debug("No URL configured for SSE transport, using default: %s", sse_url)
199
200
 
200
201
  # Build SSE transport with optional headers
201
202
  transport_params = {"url": sse_url, "api_key": api_key, "default_timeout": default_timeout}
@@ -205,7 +206,7 @@ class StreamManager:
205
206
  transport = SSETransport(**transport_params)
206
207
 
207
208
  elif transport_type == "http_streamable":
208
- logger.warning(
209
+ logger.debug(
209
210
  "Using HTTP Streamable transport in initialize() - consider using initialize_with_http_streamable() instead"
210
211
  )
211
212
  params = await load_config(config_file, server_name)
@@ -220,9 +221,7 @@ class StreamManager:
220
221
  api_key = None
221
222
  headers = {}
222
223
  session_id = None
223
- logger.warning(
224
- "No URL configured for HTTP Streamable transport, using default: %s", http_url
225
- )
224
+ logger.debug("No URL configured for HTTP Streamable transport, using default: %s", http_url)
226
225
 
227
226
  # Build HTTP transport (headers not supported yet)
228
227
  transport_params = {
@@ -244,7 +243,7 @@ class StreamManager:
244
243
  # Initialize with timeout protection
245
244
  try:
246
245
  if not await asyncio.wait_for(transport.initialize(), timeout=initialization_timeout):
247
- logger.error("Failed to init %s", server_name)
246
+ logger.warning("Failed to init %s", server_name)
248
247
  continue
249
248
  except TimeoutError:
250
249
  logger.error("Timeout initialising %s (timeout=%ss)", server_name, initialization_timeout)
@@ -253,8 +252,12 @@ class StreamManager:
253
252
  self.transports[server_name] = transport
254
253
 
255
254
  # Ping and get tools with timeout protection (use longer timeouts for slow servers)
256
- status = "Up" if await asyncio.wait_for(transport.send_ping(), timeout=30.0) else "Down"
257
- tools = await asyncio.wait_for(transport.get_tools(), timeout=30.0)
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)
258
261
 
259
262
  for t in tools:
260
263
  name = t.get("name")
@@ -327,7 +330,7 @@ class StreamManager:
327
330
 
328
331
  try:
329
332
  if not await asyncio.wait_for(transport.initialize(), timeout=initialization_timeout):
330
- logger.error("Failed to init SSE %s", name)
333
+ logger.warning("Failed to init SSE %s", name)
331
334
  continue
332
335
  except TimeoutError:
333
336
  logger.error("Timeout initialising SSE %s (timeout=%ss)", name, initialization_timeout)
@@ -335,8 +338,12 @@ class StreamManager:
335
338
 
336
339
  self.transports[name] = transport
337
340
  # Use longer timeouts for slow servers (ping can take time after initialization)
338
- status = "Up" if await asyncio.wait_for(transport.send_ping(), timeout=30.0) else "Down"
339
- tools = await asyncio.wait_for(transport.get_tools(), timeout=30.0)
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)
340
347
 
341
348
  for t in tools:
342
349
  tname = t.get("name")
@@ -406,7 +413,7 @@ class StreamManager:
406
413
  logger.debug(f"Calling transport.initialize() for {name} with timeout={initialization_timeout}s")
407
414
  try:
408
415
  if not await asyncio.wait_for(transport.initialize(), timeout=initialization_timeout):
409
- logger.error("Failed to init HTTP Streamable %s", name)
416
+ logger.warning("Failed to init HTTP Streamable %s", name)
410
417
  continue
411
418
  except TimeoutError:
412
419
  logger.error(
@@ -417,8 +424,12 @@ class StreamManager:
417
424
 
418
425
  self.transports[name] = transport
419
426
  # Use longer timeouts for slow servers (ping can take time after initialization)
420
- status = "Up" if await asyncio.wait_for(transport.send_ping(), timeout=30.0) else "Down"
421
- tools = await asyncio.wait_for(transport.get_tools(), timeout=30.0)
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)
422
433
 
423
434
  for t in tools:
424
435
  tname = t.get("name")
@@ -464,7 +475,7 @@ class StreamManager:
464
475
  transport = self.transports[server_name]
465
476
 
466
477
  try:
467
- tools = await asyncio.wait_for(transport.get_tools(), timeout=10.0)
478
+ tools = await asyncio.wait_for(transport.get_tools(), timeout=self.timeout_config.operation)
468
479
  logger.debug("Found %d tools for server %s", len(tools), server_name)
469
480
  return tools
470
481
  except TimeoutError:
@@ -483,7 +494,7 @@ class StreamManager:
483
494
 
484
495
  async def _ping_one(name: str, tr: MCPBaseTransport):
485
496
  try:
486
- ok = await asyncio.wait_for(tr.send_ping(), timeout=5.0)
497
+ ok = await asyncio.wait_for(tr.send_ping(), timeout=self.timeout_config.quick)
487
498
  except Exception:
488
499
  ok = False
489
500
  return {"server": name, "ok": ok}
@@ -498,7 +509,7 @@ class StreamManager:
498
509
 
499
510
  async def _one(name: str, tr: MCPBaseTransport):
500
511
  try:
501
- res = await asyncio.wait_for(tr.list_resources(), timeout=10.0)
512
+ res = await asyncio.wait_for(tr.list_resources(), timeout=self.timeout_config.operation)
502
513
  resources = res.get("resources", []) if isinstance(res, dict) else res
503
514
  for item in resources:
504
515
  item = dict(item)
@@ -518,7 +529,7 @@ class StreamManager:
518
529
 
519
530
  async def _one(name: str, tr: MCPBaseTransport):
520
531
  try:
521
- res = await asyncio.wait_for(tr.list_prompts(), timeout=10.0)
532
+ res = await asyncio.wait_for(tr.list_prompts(), timeout=self.timeout_config.operation)
522
533
  prompts = res.get("prompts", []) if isinstance(res, dict) else res
523
534
  for item in prompts:
524
535
  item = dict(item)
@@ -645,7 +656,7 @@ class StreamManager:
645
656
  try:
646
657
  results = await asyncio.wait_for(
647
658
  asyncio.gather(*[task for _, task in close_tasks], return_exceptions=True),
648
- timeout=self._shutdown_timeout,
659
+ timeout=self.timeout_config.shutdown,
649
660
  )
650
661
 
651
662
  # Process results
@@ -668,7 +679,8 @@ class StreamManager:
668
679
  # Brief wait for cancellations to complete
669
680
  with contextlib.suppress(TimeoutError):
670
681
  await asyncio.wait_for(
671
- asyncio.gather(*[task for _, task in close_tasks], return_exceptions=True), timeout=0.5
682
+ asyncio.gather(*[task for _, task in close_tasks], return_exceptions=True),
683
+ timeout=self.timeout_config.shutdown,
672
684
  )
673
685
 
674
686
  async def _sequential_close(self, transport_items: list[tuple[str, MCPBaseTransport]], close_results: list) -> None:
@@ -677,7 +689,7 @@ class StreamManager:
677
689
  try:
678
690
  await asyncio.wait_for(
679
691
  self._close_single_transport(name, transport),
680
- timeout=0.5, # Short timeout per transport
692
+ timeout=self.timeout_config.shutdown,
681
693
  )
682
694
  logger.debug("Closed transport: %s", name)
683
695
  close_results.append((name, True, None))
@@ -769,7 +781,7 @@ class StreamManager:
769
781
 
770
782
  for name, transport in self.transports.items():
771
783
  try:
772
- ping_ok = await asyncio.wait_for(transport.send_ping(), timeout=5.0)
784
+ ping_ok = await asyncio.wait_for(transport.send_ping(), timeout=self.timeout_config.quick)
773
785
  health_info["transports"][name] = {
774
786
  "status": "healthy" if ping_ok else "unhealthy",
775
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, # NEW: Headers support
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, # NEW: OAuth token refresh callback
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 (NEW)
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 (NEW)
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 {} # NEW: Store configured headers
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 # NEW: 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=5.0) as client:
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 longer timeout for initial ping - some servers (like Notion) are slow
208
- ping_timeout = max(self.default_timeout, 15.0)
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["initialization_time"] = total_init_time
223
- self._metrics["last_ping_time"] = ping_time
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)",
@@ -229,31 +226,31 @@ class HTTPStreamableTransport(MCPBaseTransport):
229
226
  )
230
227
  return True
231
228
  else:
232
- logger.warning("HTTP connection established but ping failed")
229
+ logger.debug("HTTP connection established but ping failed")
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["initialization_time"] = time.time() - start_time
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["connection_errors"] += 1
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["connection_errors"] += 1
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["recovery_attempts"] += 1
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["total_calls"] > 0:
277
- success_rate = self._metrics["successful_calls"] / self._metrics["total_calls"] * 100
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["total_calls"],
278
+ self._metrics.total_calls,
282
279
  success_rate,
283
- self._metrics["avg_response_time"],
284
- self._metrics["recovery_attempts"],
285
- self._metrics["connection_errors"],
280
+ self._metrics.avg_response_time,
281
+ self._metrics.recovery_attempts,
282
+ self._metrics.connection_errors,
286
283
  )
287
284
 
288
285
  try:
@@ -305,7 +302,7 @@ class HTTPStreamableTransport(MCPBaseTransport):
305
302
  async def send_ping(self) -> bool:
306
303
  """Enhanced ping with health monitoring (like SSE)."""
307
304
  if not self._initialized or not self._read_stream:
308
- logger.error("Cannot send ping: transport not initialized")
305
+ logger.debug("Cannot send ping: transport not initialized")
309
306
  return False
310
307
 
311
308
  start_time = time.time()
@@ -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["last_ping_time"] = ping_time
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["stream_errors"] += 1
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:
@@ -355,7 +352,7 @@ class HTTPStreamableTransport(MCPBaseTransport):
355
352
  async def get_tools(self) -> list[dict[str, Any]]:
356
353
  """Enhanced tools retrieval with error handling."""
357
354
  if not self._initialized:
358
- logger.error("Cannot get tools: transport not initialized")
355
+ logger.debug("Cannot get tools: transport not initialized")
359
356
  return []
360
357
 
361
358
  start_time = time.time()
@@ -365,9 +362,19 @@ class HTTPStreamableTransport(MCPBaseTransport):
365
362
  timeout=self.default_timeout,
366
363
  )
367
364
 
368
- # Normalize response
369
- if isinstance(tools_response, dict):
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["stream_errors"] += 1
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["total_calls"] += 1
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.info("Attempting OAuth token refresh...")
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.info("OAuth token refreshed, reconnecting...")
448
+ logger.debug("OAuth token refreshed, reconnecting...")
442
449
 
443
450
  # Reconnect with new token
444
451
  if await self._attempt_recovery():
445
- logger.info("Retrying tool call after token refresh...")
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.info("Tool call retry completed")
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["stream_errors"] += 1
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["connection_errors"] += 1
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 success:
510
- self._metrics["successful_calls"] += 1
511
- else:
512
- self._metrics["failed_calls"] += 1
516
+ if not self._metrics:
517
+ return
513
518
 
514
- self._metrics["total_time"] += response_time
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
- metrics = self._metrics.copy()
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
- preserved_init_time = self._metrics.get("initialization_time")
629
- preserved_last_ping = self._metrics.get("last_ping_time")
630
-
631
- self._metrics = {
632
- "total_calls": 0,
633
- "successful_calls": 0,
634
- "failed_calls": 0,
635
- "total_time": 0.0,
636
- "avg_response_time": 0.0,
637
- "last_ping_time": preserved_last_ping,
638
- "initialization_time": preserved_init_time,
639
- "connection_resets": self._metrics.get("connection_resets", 0),
640
- "stream_errors": 0,
641
- "connection_errors": 0,
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, # NEW: OAuth token refresh callback
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 # NEW: 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 = 10.0
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:
@@ -186,17 +184,17 @@ class SSETransport(MCPBaseTransport):
186
184
  if self.sse_task.done():
187
185
  exception = self.sse_task.exception()
188
186
  if exception:
189
- logger.error(f"SSE task died during session discovery: {exception}")
187
+ logger.debug(f"SSE task died during session discovery: {exception}")
190
188
  await self._cleanup()
191
189
  return False
192
190
 
193
191
  if not self.message_url:
194
- logger.error("Failed to discover session endpoint within %.1fs", session_timeout)
192
+ logger.warning("Failed to discover session endpoint within %.1fs", session_timeout)
195
193
  await self._cleanup()
196
194
  return False
197
195
 
198
- if self.enable_metrics:
199
- self._metrics["session_discoveries"] += 1
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
 
@@ -213,7 +211,7 @@ class SSETransport(MCPBaseTransport):
213
211
  )
214
212
 
215
213
  if "error" in init_response:
216
- logger.error("MCP initialize failed: %s", init_response["error"])
214
+ logger.warning("MCP initialize failed: %s", init_response["error"])
217
215
  await self._cleanup()
218
216
  return False
219
217
 
@@ -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["initialization_time"] = init_time
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["stream_errors"] += 1
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=10.0)
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["last_ping_time"] = ping_time
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
@@ -478,7 +476,7 @@ class SSETransport(MCPBaseTransport):
478
476
  async def get_tools(self) -> list[dict[str, Any]]:
479
477
  """Get list of available tools from the server."""
480
478
  if not self._initialized:
481
- logger.error("Cannot get tools: transport not initialized")
479
+ logger.debug("Cannot get tools: transport not initialized")
482
480
  return []
483
481
 
484
482
  start_time = time.time()
@@ -486,7 +484,7 @@ class SSETransport(MCPBaseTransport):
486
484
  response = await self._send_request("tools/list", {})
487
485
 
488
486
  if "error" in response:
489
- logger.error("Error getting tools: %s", response["error"])
487
+ logger.warning("Error getting tools: %s", response["error"])
490
488
  return []
491
489
 
492
490
  tools = response.get("result", {}).get("tools", [])
@@ -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["total_calls"] += 1
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.info("Attempting OAuth token refresh...")
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.info("OAuth token refreshed, retrying tool call...")
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.info("Tool call succeeded after token refresh")
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 success:
596
- self._metrics["successful_calls"] += 1
597
- else:
598
- self._metrics["failed_calls"] += 1
593
+ if not self._metrics:
594
+ return
599
595
 
600
- self._metrics["total_time"] += response_time
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=10.0)
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=10.0)
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["total_calls"] > 0:
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["total_calls"],
662
- (self._metrics["successful_calls"] / self._metrics["total_calls"] * 100),
663
- self._metrics["avg_response_time"],
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
- metrics = self._metrics.copy()
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
- "total_calls": 0,
734
- "successful_calls": 0,
735
- "failed_calls": 0,
736
- "total_time": 0.0,
737
- "avg_response_time": 0.0,
738
- "last_ping_time": self._metrics.get("last_ping_time"),
739
- "initialization_time": self._metrics.get("initialization_time"),
740
- "session_discoveries": self._metrics.get("session_discoveries", 0),
741
- "stream_errors": 0,
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 longer timeout for slow servers
204
- ping_success = await asyncio.wait_for(send_ping(*self._streams), timeout=30.0)
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:
@@ -221,7 +221,7 @@ class StdioTransport(MCPBaseTransport):
221
221
  )
222
222
  return True
223
223
  else:
224
- logger.warning("STDIO connection established but ping failed")
224
+ logger.debug("STDIO connection established but ping failed")
225
225
  # Still consider it initialized
226
226
  self._initialized = True
227
227
  self._consecutive_failures = 1
@@ -229,7 +229,7 @@ class StdioTransport(MCPBaseTransport):
229
229
  self._metrics["initialization_time"] = time.time() - start_time
230
230
  return True
231
231
  else:
232
- logger.error("STDIO initialization failed")
232
+ logger.warning("STDIO initialization failed")
233
233
  await self._cleanup()
234
234
  return False
235
235
 
@@ -382,7 +382,7 @@ class StdioTransport(MCPBaseTransport):
382
382
  async def get_tools(self) -> list[dict[str, Any]]:
383
383
  """Enhanced tools retrieval with recovery."""
384
384
  if not self._initialized:
385
- logger.error("Cannot get tools: transport not initialized")
385
+ logger.debug("Cannot get tools: transport not initialized")
386
386
  return []
387
387
 
388
388
  start_time = time.time()
@@ -121,7 +121,7 @@ class PluginDiscovery:
121
121
  # ------------------- Parser plugins -------------------------
122
122
  if issubclass(cls, ParserPlugin) and cls is not ParserPlugin:
123
123
  if not inspect.iscoroutinefunction(getattr(cls, "try_parse", None)):
124
- logger.warning("Skipping parser plugin %s: try_parse is not async", cls.__qualname__)
124
+ logger.debug("Skipping parser plugin %s: try_parse is not async", cls.__qualname__)
125
125
  else:
126
126
  try:
127
127
  self._registry.register_plugin("parser", cls.__name__, cls())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chuk-tool-processor
3
- Version: 0.6.27
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 |
@@ -1,7 +1,7 @@
1
1
  chuk_tool_processor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  chuk_tool_processor/core/__init__.py,sha256=O4uwbEQN6G6sGrKKQNHRW-99ROlPEVct7wOSzVoazXQ,39
3
3
  chuk_tool_processor/core/exceptions.py,sha256=s35RVMIt8PQGP10ZS7L7sS0Pddpj0kc3Ut3wISDYn_U,1559
4
- chuk_tool_processor/core/processor.py,sha256=1pv76USunPXGIfeDs-wj2amdKvBBrBgkjgI2_JOK6vU,17825
4
+ chuk_tool_processor/core/processor.py,sha256=jIMdVjG5bqlUp2nAS6qr9dss2TOSpxodagOpb_0HT_g,17823
5
5
  chuk_tool_processor/execution/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  chuk_tool_processor/execution/tool_executor.py,sha256=zVQbNS9qUNn-1J1BPaFMGptKVFt0tXxh2bKiK-o1P2E,13705
7
7
  chuk_tool_processor/execution/strategies/__init__.py,sha256=fkHvK2Ca6c4npcpmS5aYHrdqYrgFm7DYdor-t-yCwuc,286
@@ -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=zOx3YeuKyuFK-PbUt3gqdq_q8VRrHFD6sgma6qKbfPY,19170
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=GKUXqJpRn_O8008qJompwZfdFlb6nRlZZGh8jqr23y0,34184
26
- chuk_tool_processor/mcp/transport/__init__.py,sha256=Gpw9QZctxfO-tWZ8URpyFU8rePc5Xe7VZiAvXaiF8cw,657
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=PKU-s2cofagY_x5nwocfi3Q0uJjoAHh8ALP0nePWVRc,26547
29
- chuk_tool_processor/mcp/transport/sse_transport.py,sha256=WfsfsHzN579vPLl13cjHwi5sn_uKrf4ZnVxasEYHy6I,30629
30
- chuk_tool_processor/mcp/transport/stdio_transport.py,sha256=bDh3eqe9BnPvcmG7F4BWqGjGO3e8q0lwcZeswx_jK0U,29558
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
@@ -36,7 +37,7 @@ chuk_tool_processor/models/tool_export_mixin.py,sha256=aP65iHmpDquB16L2PwXvZ2glt
36
37
  chuk_tool_processor/models/tool_result.py,sha256=aq-RVRaYSIl7fwCb8nr9yjazyT0qfL408PrqY05bSVM,4615
37
38
  chuk_tool_processor/models/validated_tool.py,sha256=5O8eiEsRPuPb7SHEtCoRwGKUBUPlMx4qXe9GtsQZSGo,5681
38
39
  chuk_tool_processor/plugins/__init__.py,sha256=LsW8KTaW2LogUvpoA6538NJyMLFmN9TISdLNog2NlH8,49
39
- chuk_tool_processor/plugins/discovery.py,sha256=bDaXMIMstPZOIzhdBtQpg1HlahbX5Hf1T5LEH86fjtE,7067
40
+ chuk_tool_processor/plugins/discovery.py,sha256=Trq1P-AxWEKAtOUCB9UMwbp96mtGAuM4cBytjE-ahBU,7065
40
41
  chuk_tool_processor/plugins/parsers/__init__.py,sha256=D0NUmlObNeOevHGv_eaurFiUmoptmd1mEda2sXLcTMs,50
41
42
  chuk_tool_processor/plugins/parsers/base.py,sha256=nbBb8Nfn8Q0pXxCkhj7GSyuqnO2aoEBag-7NoobI8GA,726
42
43
  chuk_tool_processor/plugins/parsers/function_call_tool.py,sha256=1fFuP4sqT5LvQHuosqmg8LSFPreIaOnlLroRsJzwPfA,3338
@@ -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.27.dist-info/METADATA,sha256=_EJFoLfhbyqOeXNhpHu9N2z4HMLlTfkDMCb9nO97OVo,39362
58
- chuk_tool_processor-0.6.27.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
59
- chuk_tool_processor-0.6.27.dist-info/top_level.txt,sha256=7lTsnuRx4cOW4U2sNJWNxl4ZTt_J1ndkjTbj3pHPY5M,20
60
- chuk_tool_processor-0.6.27.dist-info/RECORD,,
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,,