chuk-tool-processor 0.5.2__py3-none-any.whl → 0.5.4__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.

@@ -0,0 +1,496 @@
1
+ # chuk_tool_processor/mcp/transport/http_streamable_transport.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import time
7
+ from typing import Dict, Any, List, Optional
8
+ import logging
9
+
10
+ from .base_transport import MCPBaseTransport
11
+
12
+ # Import latest chuk-mcp HTTP Streamable transport
13
+ # Try different possible naming conventions
14
+ try:
15
+ # First try the expected naming
16
+ from chuk_mcp.transports.http_streamable import http_streamable_client
17
+ from chuk_mcp.transports.http_streamable.parameters import HTTPStreamableParameters
18
+ HAS_HTTP_STREAMABLE_SUPPORT = True
19
+ STREAMABLE_CLIENT = http_streamable_client
20
+ STREAMABLE_PARAMS = HTTPStreamableParameters
21
+ except ImportError:
22
+ try:
23
+ # Try alternative naming
24
+ from chuk_mcp.transports.streamable_http import streamable_http_client
25
+ from chuk_mcp.transports.streamable_http.parameters import StreamableHttpParameters
26
+ HAS_HTTP_STREAMABLE_SUPPORT = True
27
+ STREAMABLE_CLIENT = streamable_http_client
28
+ STREAMABLE_PARAMS = StreamableHttpParameters
29
+ except ImportError:
30
+ HAS_HTTP_STREAMABLE_SUPPORT = False
31
+
32
+ # Import protocol messages
33
+ try:
34
+ from chuk_mcp.protocol.messages import (
35
+ send_ping,
36
+ send_tools_list,
37
+ send_tools_call,
38
+ send_resources_list,
39
+ send_prompts_list,
40
+ )
41
+ HAS_PROTOCOL_MESSAGES = True
42
+ except ImportError:
43
+ HAS_PROTOCOL_MESSAGES = False
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class HTTPStreamableTransport(MCPBaseTransport):
49
+ """
50
+ Enhanced HTTP Streamable transport using latest chuk-mcp APIs.
51
+
52
+ This implements the modern Streamable HTTP transport (spec 2025-03-26)
53
+ which replaces the deprecated SSE transport with a cleaner, more flexible approach.
54
+
55
+ Key features:
56
+ - Single /mcp endpoint for all communication
57
+ - Works with standard HTTP infrastructure
58
+ - Supports both immediate and streaming responses
59
+ - Better error handling and retry logic
60
+ - Stateless operation when streaming not needed
61
+ """
62
+
63
+ def __init__(self, url: str, api_key: Optional[str] = None,
64
+ connection_timeout: float = 30.0, default_timeout: float = 30.0,
65
+ session_id: Optional[str] = None, enable_metrics: bool = True):
66
+ """
67
+ Initialize HTTP Streamable transport.
68
+
69
+ Args:
70
+ url: Base URL of the MCP server (will append /mcp if needed)
71
+ api_key: Optional API key for authentication
72
+ connection_timeout: Timeout for initial connection
73
+ default_timeout: Default timeout for operations
74
+ session_id: Optional session ID for stateful connections
75
+ enable_metrics: Whether to track performance metrics
76
+ """
77
+ # Ensure URL points to the /mcp endpoint
78
+ if not url.endswith('/mcp'):
79
+ self.url = f"{url.rstrip('/')}/mcp"
80
+ else:
81
+ self.url = url
82
+
83
+ self.api_key = api_key
84
+ self.connection_timeout = connection_timeout
85
+ self.default_timeout = default_timeout
86
+ self.session_id = session_id
87
+ self.enable_metrics = enable_metrics
88
+
89
+ # State tracking
90
+ self._streamable_context = None
91
+ self._read_stream = None
92
+ self._write_stream = None
93
+ self._initialized = False
94
+
95
+ # Performance metrics
96
+ self._metrics = {
97
+ "total_calls": 0,
98
+ "successful_calls": 0,
99
+ "failed_calls": 0,
100
+ "total_time": 0.0,
101
+ "avg_response_time": 0.0,
102
+ "last_ping_time": None,
103
+ "initialization_time": None
104
+ }
105
+
106
+ if not HAS_HTTP_STREAMABLE_SUPPORT:
107
+ logger.warning("HTTP Streamable transport not available - operations will fail")
108
+ if not HAS_PROTOCOL_MESSAGES:
109
+ logger.warning("Protocol messages not available - operations will fail")
110
+
111
+ async def initialize(self) -> bool:
112
+ """Initialize using latest chuk-mcp streamable http client with enhanced monitoring."""
113
+ if not HAS_HTTP_STREAMABLE_SUPPORT or not HAS_PROTOCOL_MESSAGES:
114
+ logger.error("HTTP Streamable transport or protocol messages not available in chuk-mcp")
115
+ return False
116
+
117
+ if self._initialized:
118
+ logger.warning("Transport already initialized")
119
+ return True
120
+
121
+ start_time = time.time()
122
+
123
+ try:
124
+ logger.info(f"Initializing HTTP Streamable transport to {self.url}")
125
+
126
+ # Create HTTP Streamable parameters
127
+ streamable_params = STREAMABLE_PARAMS(
128
+ url=self.url,
129
+ timeout=self.connection_timeout,
130
+ )
131
+
132
+ # Add session ID if provided
133
+ if self.session_id:
134
+ streamable_params.session_id = self.session_id
135
+ logger.debug(f"Using session ID: {self.session_id}")
136
+
137
+ # Add API key via headers if provided
138
+ if self.api_key:
139
+ streamable_params.headers = {"Authorization": f"Bearer {self.api_key}"}
140
+ logger.debug("API key configured for authentication")
141
+
142
+ # Create and enter the context - let chuk-mcp handle MCP handshake
143
+ self._streamable_context = STREAMABLE_CLIENT(streamable_params)
144
+
145
+ logger.debug("Establishing HTTP Streamable connection...")
146
+ self._read_stream, self._write_stream = await asyncio.wait_for(
147
+ self._streamable_context.__aenter__(),
148
+ timeout=self.connection_timeout
149
+ )
150
+
151
+ # Verify the connection works with a simple ping
152
+ logger.debug("Verifying connection with ping...")
153
+ ping_start = time.time()
154
+ ping_success = await asyncio.wait_for(
155
+ send_ping(self._read_stream, self._write_stream),
156
+ timeout=5.0
157
+ )
158
+ ping_time = time.time() - ping_start
159
+
160
+ if ping_success:
161
+ self._initialized = True
162
+ init_time = time.time() - start_time
163
+ self._metrics["initialization_time"] = init_time
164
+ self._metrics["last_ping_time"] = ping_time
165
+
166
+ logger.info(f"HTTP Streamable transport initialized successfully in {init_time:.3f}s (ping: {ping_time:.3f}s)")
167
+ return True
168
+ else:
169
+ logger.warning("HTTP Streamable connection established but ping failed")
170
+ # Still consider it initialized since connection was established
171
+ self._initialized = True
172
+ self._metrics["initialization_time"] = time.time() - start_time
173
+ return True
174
+
175
+ except asyncio.TimeoutError:
176
+ logger.error(f"HTTP Streamable initialization timed out after {self.connection_timeout}s")
177
+ await self._cleanup()
178
+ return False
179
+ except Exception as e:
180
+ logger.error(f"Error initializing HTTP Streamable transport: {e}", exc_info=True)
181
+ await self._cleanup()
182
+ return False
183
+
184
+ async def close(self) -> None:
185
+ """Close the HTTP Streamable transport properly with metrics summary."""
186
+ if not self._initialized:
187
+ return
188
+
189
+ # Log final metrics
190
+ if self.enable_metrics and self._metrics["total_calls"] > 0:
191
+ logger.info(
192
+ f"HTTP Streamable transport closing - Total calls: {self._metrics['total_calls']}, "
193
+ f"Success rate: {(self._metrics['successful_calls']/self._metrics['total_calls']*100):.1f}%, "
194
+ f"Avg response time: {self._metrics['avg_response_time']:.3f}s"
195
+ )
196
+
197
+ try:
198
+ if self._streamable_context is not None:
199
+ await self._streamable_context.__aexit__(None, None, None)
200
+ logger.debug("HTTP Streamable context closed")
201
+
202
+ except Exception as e:
203
+ logger.debug(f"Error during transport close: {e}")
204
+ finally:
205
+ await self._cleanup()
206
+
207
+ async def _cleanup(self) -> None:
208
+ """Clean up internal state."""
209
+ self._streamable_context = None
210
+ self._read_stream = None
211
+ self._write_stream = None
212
+ self._initialized = False
213
+
214
+ async def send_ping(self) -> bool:
215
+ """Send ping with performance tracking."""
216
+ if not self._initialized or not self._read_stream:
217
+ return False
218
+
219
+ start_time = time.time()
220
+ try:
221
+ result = await asyncio.wait_for(
222
+ send_ping(self._read_stream, self._write_stream),
223
+ timeout=5.0
224
+ )
225
+
226
+ if self.enable_metrics:
227
+ ping_time = time.time() - start_time
228
+ self._metrics["last_ping_time"] = ping_time
229
+ logger.debug(f"Ping completed in {ping_time:.3f}s: {result}")
230
+
231
+ return bool(result)
232
+ except asyncio.TimeoutError:
233
+ logger.error("Ping timed out")
234
+ return False
235
+ except Exception as e:
236
+ logger.error(f"Ping failed: {e}")
237
+ return False
238
+
239
+ async def get_tools(self) -> List[Dict[str, Any]]:
240
+ """Get tools list with performance tracking."""
241
+ if not self._initialized:
242
+ logger.error("Cannot get tools: transport not initialized")
243
+ return []
244
+
245
+ start_time = time.time()
246
+ try:
247
+ tools_response = await asyncio.wait_for(
248
+ send_tools_list(self._read_stream, self._write_stream),
249
+ timeout=self.default_timeout
250
+ )
251
+
252
+ # Normalize response
253
+ if isinstance(tools_response, dict):
254
+ tools = tools_response.get("tools", [])
255
+ elif isinstance(tools_response, list):
256
+ tools = tools_response
257
+ else:
258
+ logger.warning(f"Unexpected tools response type: {type(tools_response)}")
259
+ tools = []
260
+
261
+ if self.enable_metrics:
262
+ response_time = time.time() - start_time
263
+ logger.debug(f"Retrieved {len(tools)} tools in {response_time:.3f}s")
264
+
265
+ return tools
266
+
267
+ except asyncio.TimeoutError:
268
+ logger.error("Get tools timed out")
269
+ return []
270
+ except Exception as e:
271
+ logger.error(f"Error getting tools: {e}")
272
+ return []
273
+
274
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any],
275
+ timeout: Optional[float] = None) -> Dict[str, Any]:
276
+ """Call tool with enhanced performance tracking and error handling."""
277
+ if not self._initialized:
278
+ return {
279
+ "isError": True,
280
+ "error": "Transport not initialized"
281
+ }
282
+
283
+ tool_timeout = timeout or self.default_timeout
284
+ start_time = time.time()
285
+
286
+ if self.enable_metrics:
287
+ self._metrics["total_calls"] += 1
288
+
289
+ try:
290
+ logger.debug(f"Calling tool '{tool_name}' with timeout {tool_timeout}s")
291
+
292
+ raw_response = await asyncio.wait_for(
293
+ send_tools_call(
294
+ self._read_stream,
295
+ self._write_stream,
296
+ tool_name,
297
+ arguments
298
+ ),
299
+ timeout=tool_timeout
300
+ )
301
+
302
+ response_time = time.time() - start_time
303
+ result = self._normalize_tool_response(raw_response)
304
+
305
+ if self.enable_metrics:
306
+ self._update_metrics(response_time, not result.get("isError", False))
307
+
308
+ if not result.get("isError", False):
309
+ logger.debug(f"Tool '{tool_name}' completed successfully in {response_time:.3f}s")
310
+ else:
311
+ logger.warning(f"Tool '{tool_name}' failed in {response_time:.3f}s: {result.get('error', 'Unknown error')}")
312
+
313
+ return result
314
+
315
+ except asyncio.TimeoutError:
316
+ response_time = time.time() - start_time
317
+ if self.enable_metrics:
318
+ self._update_metrics(response_time, False)
319
+
320
+ error_msg = f"Tool execution timed out after {tool_timeout}s"
321
+ logger.error(f"Tool '{tool_name}' {error_msg}")
322
+ return {
323
+ "isError": True,
324
+ "error": error_msg
325
+ }
326
+ except Exception as e:
327
+ response_time = time.time() - start_time
328
+ if self.enable_metrics:
329
+ self._update_metrics(response_time, False)
330
+
331
+ error_msg = f"Tool execution failed: {str(e)}"
332
+ logger.error(f"Tool '{tool_name}' error: {error_msg}")
333
+ return {
334
+ "isError": True,
335
+ "error": error_msg
336
+ }
337
+
338
+ def _update_metrics(self, response_time: float, success: bool) -> None:
339
+ """Update performance metrics."""
340
+ if success:
341
+ self._metrics["successful_calls"] += 1
342
+ else:
343
+ self._metrics["failed_calls"] += 1
344
+
345
+ self._metrics["total_time"] += response_time
346
+ self._metrics["avg_response_time"] = (
347
+ self._metrics["total_time"] / self._metrics["total_calls"]
348
+ )
349
+
350
+ async def list_resources(self) -> Dict[str, Any]:
351
+ """List resources using latest chuk-mcp."""
352
+ if not self._initialized:
353
+ return {}
354
+
355
+ try:
356
+ response = await asyncio.wait_for(
357
+ send_resources_list(self._read_stream, self._write_stream),
358
+ timeout=self.default_timeout
359
+ )
360
+ return response if isinstance(response, dict) else {}
361
+ except asyncio.TimeoutError:
362
+ logger.error("List resources timed out")
363
+ return {}
364
+ except Exception as e:
365
+ logger.debug(f"Error listing resources: {e}")
366
+ return {}
367
+
368
+ async def list_prompts(self) -> Dict[str, Any]:
369
+ """List prompts using latest chuk-mcp."""
370
+ if not self._initialized:
371
+ return {}
372
+
373
+ try:
374
+ response = await asyncio.wait_for(
375
+ send_prompts_list(self._read_stream, self._write_stream),
376
+ timeout=self.default_timeout
377
+ )
378
+ return response if isinstance(response, dict) else {}
379
+ except asyncio.TimeoutError:
380
+ logger.error("List prompts timed out")
381
+ return {}
382
+ except Exception as e:
383
+ logger.debug(f"Error listing prompts: {e}")
384
+ return {}
385
+
386
+ def _normalize_tool_response(self, raw_response: Dict[str, Any]) -> Dict[str, Any]:
387
+ """Normalize response for backward compatibility."""
388
+ # Handle explicit error in response
389
+ if "error" in raw_response:
390
+ error_info = raw_response["error"]
391
+ if isinstance(error_info, dict):
392
+ error_msg = error_info.get("message", "Unknown error")
393
+ else:
394
+ error_msg = str(error_info)
395
+
396
+ return {
397
+ "isError": True,
398
+ "error": error_msg
399
+ }
400
+
401
+ # Handle successful response with result
402
+ if "result" in raw_response:
403
+ result = raw_response["result"]
404
+
405
+ if isinstance(result, dict) and "content" in result:
406
+ return {
407
+ "isError": False,
408
+ "content": self._extract_content(result["content"])
409
+ }
410
+ else:
411
+ return {
412
+ "isError": False,
413
+ "content": result
414
+ }
415
+
416
+ # Handle direct content-based response
417
+ if "content" in raw_response:
418
+ return {
419
+ "isError": False,
420
+ "content": self._extract_content(raw_response["content"])
421
+ }
422
+
423
+ # Fallback
424
+ return {
425
+ "isError": False,
426
+ "content": raw_response
427
+ }
428
+
429
+ def _extract_content(self, content_list: Any) -> Any:
430
+ """Extract content from MCP content format with enhanced error handling."""
431
+ if not isinstance(content_list, list) or not content_list:
432
+ return content_list
433
+
434
+ # Handle single content item
435
+ if len(content_list) == 1:
436
+ content_item = content_list[0]
437
+ if isinstance(content_item, dict):
438
+ if content_item.get("type") == "text":
439
+ text_content = content_item.get("text", "")
440
+ # Try to parse JSON, fall back to plain text
441
+ try:
442
+ return json.loads(text_content)
443
+ except json.JSONDecodeError:
444
+ return text_content
445
+ else:
446
+ return content_item
447
+
448
+ # Multiple content items
449
+ return content_list
450
+
451
+ def get_streams(self) -> List[tuple]:
452
+ """Provide streams for backward compatibility."""
453
+ if self._initialized and self._read_stream and self._write_stream:
454
+ return [(self._read_stream, self._write_stream)]
455
+ return []
456
+
457
+ def is_connected(self) -> bool:
458
+ """Check connection status."""
459
+ return self._initialized and self._read_stream is not None and self._write_stream is not None
460
+
461
+ def get_metrics(self) -> Dict[str, Any]:
462
+ """Get performance metrics."""
463
+ return self._metrics.copy()
464
+
465
+ def reset_metrics(self) -> None:
466
+ """Reset performance metrics."""
467
+ self._metrics = {
468
+ "total_calls": 0,
469
+ "successful_calls": 0,
470
+ "failed_calls": 0,
471
+ "total_time": 0.0,
472
+ "avg_response_time": 0.0,
473
+ "last_ping_time": self._metrics.get("last_ping_time"),
474
+ "initialization_time": self._metrics.get("initialization_time")
475
+ }
476
+
477
+ async def __aenter__(self):
478
+ """Context manager support."""
479
+ success = await self.initialize()
480
+ if not success:
481
+ raise RuntimeError("Failed to initialize HTTP Streamable transport")
482
+ return self
483
+
484
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
485
+ """Context manager cleanup."""
486
+ await self.close()
487
+
488
+ def __repr__(self) -> str:
489
+ """Enhanced string representation for debugging."""
490
+ status = "initialized" if self._initialized else "not initialized"
491
+ metrics_info = ""
492
+ if self.enable_metrics and self._metrics["total_calls"] > 0:
493
+ success_rate = (self._metrics["successful_calls"] / self._metrics["total_calls"]) * 100
494
+ metrics_info = f", calls: {self._metrics['total_calls']}, success: {success_rate:.1f}%"
495
+
496
+ return f"HTTPStreamableTransport(status={status}, url={self.url}{metrics_info})"