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