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

@@ -1,6 +1,6 @@
1
1
  # chuk_tool_processor/mcp/stream_manager.py
2
2
  """
3
- StreamManager for CHUK Tool Processor - Enhanced with robust shutdown handling
3
+ StreamManager for CHUK Tool Processor - Enhanced with robust shutdown handling and headers support
4
4
  """
5
5
  from __future__ import annotations
6
6
 
@@ -27,12 +27,12 @@ class StreamManager:
27
27
  """
28
28
  Manager for MCP server streams with support for multiple transport types.
29
29
 
30
- Enhanced with robust shutdown handling to prevent event loop closure issues.
30
+ Enhanced with robust shutdown handling and proper headers support.
31
31
 
32
32
  Updated to support the latest transports:
33
33
  - STDIO (process-based)
34
- - SSE (Server-Sent Events)
35
- - HTTP Streamable (modern replacement for SSE, spec 2025-03-26)
34
+ - SSE (Server-Sent Events) with headers support
35
+ - HTTP Streamable (modern replacement for SSE, spec 2025-03-26) with graceful headers handling
36
36
  """
37
37
 
38
38
  def __init__(self) -> None:
@@ -175,6 +175,7 @@ class StreamManager:
175
175
  transport_type: str = "stdio",
176
176
  default_timeout: float = 30.0,
177
177
  ) -> None:
178
+ """Initialize with graceful headers handling for all transport types."""
178
179
  if self._closed:
179
180
  raise RuntimeError("Cannot initialize a closed StreamManager")
180
181
 
@@ -193,16 +194,24 @@ class StreamManager:
193
194
  if isinstance(params, dict) and 'url' in params:
194
195
  sse_url = params['url']
195
196
  api_key = params.get('api_key')
197
+ headers = params.get('headers', {})
196
198
  else:
197
199
  sse_url = "http://localhost:8000"
198
200
  api_key = None
201
+ headers = {}
199
202
  logger.warning("No URL configured for SSE transport, using default: %s", sse_url)
200
203
 
201
- transport = SSETransport(
202
- sse_url,
203
- api_key,
204
- default_timeout=default_timeout
205
- )
204
+ # Build SSE transport with optional headers
205
+ transport_params = {
206
+ 'url': sse_url,
207
+ 'api_key': api_key,
208
+ 'default_timeout': default_timeout
209
+ }
210
+ if headers:
211
+ transport_params['headers'] = headers
212
+
213
+ transport = SSETransport(**transport_params)
214
+
206
215
  elif transport_type == "http_streamable":
207
216
  logger.warning("Using HTTP Streamable transport in initialize() - consider using initialize_with_http_streamable() instead")
208
217
  params = await load_config(config_file, server_name)
@@ -210,19 +219,28 @@ class StreamManager:
210
219
  if isinstance(params, dict) and 'url' in params:
211
220
  http_url = params['url']
212
221
  api_key = params.get('api_key')
222
+ headers = params.get('headers', {})
213
223
  session_id = params.get('session_id')
214
224
  else:
215
225
  http_url = "http://localhost:8000"
216
226
  api_key = None
227
+ headers = {}
217
228
  session_id = None
218
229
  logger.warning("No URL configured for HTTP Streamable transport, using default: %s", http_url)
219
230
 
220
- transport = HTTPStreamableTransport(
221
- http_url,
222
- api_key,
223
- default_timeout=default_timeout,
224
- session_id=session_id
225
- )
231
+ # Build HTTP transport (headers not supported yet)
232
+ transport_params = {
233
+ 'url': http_url,
234
+ 'api_key': api_key,
235
+ 'default_timeout': default_timeout,
236
+ 'session_id': session_id
237
+ }
238
+ # Note: headers not added until HTTPStreamableTransport supports them
239
+ if headers:
240
+ logger.debug("Headers provided but not supported in HTTPStreamableTransport yet")
241
+
242
+ transport = HTTPStreamableTransport(**transport_params)
243
+
226
244
  else:
227
245
  logger.error("Unsupported transport type: %s", transport_type)
228
246
  continue
@@ -271,6 +289,7 @@ class StreamManager:
271
289
  connection_timeout: float = 10.0,
272
290
  default_timeout: float = 30.0,
273
291
  ) -> None:
292
+ """Initialize with SSE transport with optional headers support."""
274
293
  if self._closed:
275
294
  raise RuntimeError("Cannot initialize a closed StreamManager")
276
295
 
@@ -283,12 +302,21 @@ class StreamManager:
283
302
  logger.error("Bad server config: %s", cfg)
284
303
  continue
285
304
  try:
286
- transport = SSETransport(
287
- url,
288
- cfg.get("api_key"),
289
- connection_timeout=connection_timeout,
290
- default_timeout=default_timeout
291
- )
305
+ # Build SSE transport parameters with optional headers
306
+ transport_params = {
307
+ 'url': url,
308
+ 'api_key': cfg.get("api_key"),
309
+ 'connection_timeout': connection_timeout,
310
+ 'default_timeout': default_timeout
311
+ }
312
+
313
+ # Add headers if provided
314
+ headers = cfg.get("headers", {})
315
+ if headers:
316
+ logger.debug("SSE %s: Using configured headers: %s", name, list(headers.keys()))
317
+ transport_params['headers'] = headers
318
+
319
+ transport = SSETransport(**transport_params)
292
320
 
293
321
  if not await asyncio.wait_for(transport.initialize(), timeout=connection_timeout):
294
322
  logger.error("Failed to init SSE %s", name)
@@ -326,7 +354,7 @@ class StreamManager:
326
354
  connection_timeout: float = 30.0,
327
355
  default_timeout: float = 30.0,
328
356
  ) -> None:
329
- """Initialize with HTTP Streamable transport (modern MCP spec 2025-03-26)."""
357
+ """Initialize with HTTP Streamable transport with graceful headers handling."""
330
358
  if self._closed:
331
359
  raise RuntimeError("Cannot initialize a closed StreamManager")
332
360
 
@@ -339,13 +367,23 @@ class StreamManager:
339
367
  logger.error("Bad server config: %s", cfg)
340
368
  continue
341
369
  try:
342
- transport = HTTPStreamableTransport(
343
- url,
344
- cfg.get("api_key"),
345
- connection_timeout=connection_timeout,
346
- default_timeout=default_timeout,
347
- session_id=cfg.get("session_id")
348
- )
370
+ # Build HTTP Streamable transport parameters
371
+ transport_params = {
372
+ 'url': url,
373
+ 'api_key': cfg.get("api_key"),
374
+ 'connection_timeout': connection_timeout,
375
+ 'default_timeout': default_timeout,
376
+ 'session_id': cfg.get("session_id")
377
+ }
378
+
379
+ # Handle headers if provided (for future HTTPStreamableTransport support)
380
+ headers = cfg.get("headers", {})
381
+ if headers:
382
+ logger.debug("HTTP Streamable %s: Headers provided but not yet supported in transport", name)
383
+ # TODO: Add headers support when HTTPStreamableTransport is updated
384
+ # transport_params['headers'] = headers
385
+
386
+ transport = HTTPStreamableTransport(**transport_params)
349
387
 
350
388
  if not await asyncio.wait_for(transport.initialize(), timeout=connection_timeout):
351
389
  logger.error("Failed to init HTTP Streamable %s", name)
@@ -1,39 +1,22 @@
1
1
  # chuk_tool_processor/mcp/transport/__init__.py
2
2
  """
3
- MCP Transport module providing multiple transport implementations.
3
+ MCP Transport module providing consistent transport implementations.
4
+
5
+ All transports now follow the same interface and provide consistent behavior:
6
+ - Standardized initialization and cleanup
7
+ - Unified metrics and monitoring
8
+ - Consistent error handling and timeouts
9
+ - Shared response normalization
4
10
  """
5
11
 
6
12
  from .base_transport import MCPBaseTransport
7
-
8
- # Always available transports
9
- try:
10
- from .stdio_transport import StdioTransport
11
- HAS_STDIO_TRANSPORT = True
12
- except ImportError:
13
- StdioTransport = None
14
- HAS_STDIO_TRANSPORT = False
15
-
16
- # Conditionally available transports
17
- try:
18
- from .sse_transport import SSETransport
19
- HAS_SSE_TRANSPORT = True
20
- except ImportError:
21
- SSETransport = None
22
- HAS_SSE_TRANSPORT = False
23
-
24
- try:
25
- from .http_streamable_transport import HTTPStreamableTransport
26
- HAS_HTTP_STREAMABLE_TRANSPORT = True
27
- except ImportError:
28
- HTTPStreamableTransport = None
29
- HAS_HTTP_STREAMABLE_TRANSPORT = False
13
+ from .stdio_transport import StdioTransport
14
+ from .sse_transport import SSETransport
15
+ from .http_streamable_transport import HTTPStreamableTransport
30
16
 
31
17
  __all__ = [
32
18
  "MCPBaseTransport",
33
19
  "StdioTransport",
34
20
  "SSETransport",
35
21
  "HTTPStreamableTransport",
36
- "HAS_STDIO_TRANSPORT",
37
- "HAS_SSE_TRANSPORT",
38
- "HAS_HTTP_STREAMABLE_TRANSPORT"
39
22
  ]
@@ -1,103 +1,254 @@
1
1
  # chuk_tool_processor/mcp/transport/base_transport.py
2
2
  """
3
- Abstract transport layer for MCP communication.
3
+ Abstract base class for MCP transports with complete interface definition.
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
7
  from abc import ABC, abstractmethod
8
- from typing import Any, Dict, List
8
+ from typing import Any, Dict, List, Optional
9
9
 
10
10
 
11
11
  class MCPBaseTransport(ABC):
12
12
  """
13
- Abstract base class for MCP transport mechanisms.
13
+ Abstract base class for all MCP transport implementations.
14
+
15
+ Defines the complete interface that all transports must implement
16
+ for consistency across stdio, SSE, and HTTP streamable transports.
14
17
  """
15
18
 
16
19
  # ------------------------------------------------------------------ #
17
- # connection lifecycle #
20
+ # Core connection lifecycle #
18
21
  # ------------------------------------------------------------------ #
19
22
  @abstractmethod
20
23
  async def initialize(self) -> bool:
21
24
  """
22
- Establish the connection.
23
-
24
- Returns
25
- -------
26
- bool
27
- ``True`` if the connection was initialised successfully.
25
+ Initialize the transport connection.
26
+
27
+ Returns:
28
+ True if initialization was successful, False otherwise.
28
29
  """
29
- raise NotImplementedError
30
+ pass
30
31
 
31
32
  @abstractmethod
32
33
  async def close(self) -> None:
33
- """Tear down the connection and release all resources."""
34
- raise NotImplementedError
34
+ """Close the transport connection and clean up all resources."""
35
+ pass
35
36
 
36
37
  # ------------------------------------------------------------------ #
37
- # diagnostics #
38
+ # Health and diagnostics #
38
39
  # ------------------------------------------------------------------ #
39
40
  @abstractmethod
40
41
  async def send_ping(self) -> bool:
41
42
  """
42
- Send a **ping** request.
43
+ Send a ping to verify the connection is alive.
44
+
45
+ Returns:
46
+ True if ping was successful, False otherwise.
47
+ """
48
+ pass
43
49
 
44
- Returns
45
- -------
46
- bool
47
- ``True`` on success, ``False`` otherwise.
50
+ @abstractmethod
51
+ def is_connected(self) -> bool:
52
+ """
53
+ Check if the transport is connected and ready for operations.
54
+
55
+ Returns:
56
+ True if connected, False otherwise.
48
57
  """
49
- raise NotImplementedError
58
+ pass
50
59
 
51
60
  # ------------------------------------------------------------------ #
52
- # tool handling #
61
+ # Core MCP operations #
53
62
  # ------------------------------------------------------------------ #
54
63
  @abstractmethod
55
64
  async def get_tools(self) -> List[Dict[str, Any]]:
56
65
  """
57
- Return a list with *all* tool definitions exposed by the server.
66
+ Get the list of available tools from the server.
67
+
68
+ Returns:
69
+ List of tool definitions.
58
70
  """
59
- raise NotImplementedError
71
+ pass
60
72
 
61
73
  @abstractmethod
62
- async def call_tool(
63
- self, tool_name: str, arguments: Dict[str, Any]
64
- ) -> Dict[str, Any]:
74
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any],
75
+ timeout: Optional[float] = None) -> Dict[str, Any]:
65
76
  """
66
- Execute *tool_name* with *arguments* and return the normalised result.
77
+ Call a tool with the given arguments.
78
+
79
+ Args:
80
+ tool_name: Name of the tool to call
81
+ arguments: Arguments to pass to the tool
82
+ timeout: Optional timeout for the operation
83
+
84
+ Returns:
85
+ Dictionary with 'isError' boolean and either 'content' or 'error'
67
86
  """
68
- raise NotImplementedError
87
+ pass
69
88
 
70
- # ------------------------------------------------------------------ #
71
- # new: resources & prompts #
72
- # ------------------------------------------------------------------ #
73
89
  @abstractmethod
74
90
  async def list_resources(self) -> Dict[str, Any]:
75
91
  """
76
- Retrieve the server's resources catalogue.
77
-
78
- Expected shape::
79
- { "resources": [ {...}, ... ], "nextCursor": "…", … }
92
+ List available resources from the server.
93
+
94
+ Returns:
95
+ Dictionary containing resources list or empty dict if not supported.
80
96
  """
81
- raise NotImplementedError
97
+ pass
82
98
 
83
99
  @abstractmethod
84
100
  async def list_prompts(self) -> Dict[str, Any]:
85
101
  """
86
- Retrieve the server's prompt catalogue.
87
-
88
- Expected shape::
89
- { "prompts": [ {...}, ... ], "nextCursor": "…", … }
102
+ List available prompts from the server.
103
+
104
+ Returns:
105
+ Dictionary containing prompts list or empty dict if not supported.
90
106
  """
91
- raise NotImplementedError
107
+ pass
92
108
 
93
109
  # ------------------------------------------------------------------ #
94
- # optional helper (non-abstract) #
110
+ # Metrics and monitoring (all transports should support these) #
95
111
  # ------------------------------------------------------------------ #
96
- def get_streams(self):
112
+ @abstractmethod
113
+ def get_metrics(self) -> Dict[str, Any]:
114
+ """
115
+ Get performance and connection metrics.
116
+
117
+ Returns:
118
+ Dictionary containing metrics data.
97
119
  """
98
- Return a list of ``(read_stream, write_stream)`` tuples.
120
+ pass
121
+
122
+ @abstractmethod
123
+ def reset_metrics(self) -> None:
124
+ """Reset performance metrics to initial state."""
125
+ pass
99
126
 
100
- Transports that do not expose their low-level streams can simply leave
101
- the default implementation (which returns an empty list).
127
+ # ------------------------------------------------------------------ #
128
+ # Backward compatibility and utility methods #
129
+ # ------------------------------------------------------------------ #
130
+ def get_streams(self) -> List[tuple]:
131
+ """
132
+ Get underlying stream objects for backward compatibility.
133
+
134
+ Returns:
135
+ List of (read_stream, write_stream) tuples, empty if not applicable.
102
136
  """
103
137
  return []
138
+
139
+ # ------------------------------------------------------------------ #
140
+ # Context manager support (all transports should support this) #
141
+ # ------------------------------------------------------------------ #
142
+ async def __aenter__(self):
143
+ """Context manager entry."""
144
+ success = await self.initialize()
145
+ if not success:
146
+ raise RuntimeError(f"Failed to initialize {self.__class__.__name__}")
147
+ return self
148
+
149
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
150
+ """Context manager cleanup."""
151
+ await self.close()
152
+
153
+ # ------------------------------------------------------------------ #
154
+ # Shared helper methods for response normalization #
155
+ # ------------------------------------------------------------------ #
156
+ def _normalize_mcp_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
157
+ """
158
+ Normalize MCP response to consistent format.
159
+
160
+ This provides shared logic for all transports to ensure consistent
161
+ response format regardless of transport type.
162
+ """
163
+ # Handle explicit error in response
164
+ if "error" in response:
165
+ error_info = response["error"]
166
+ if isinstance(error_info, dict):
167
+ error_msg = error_info.get("message", "Unknown error")
168
+ else:
169
+ error_msg = str(error_info)
170
+
171
+ return {
172
+ "isError": True,
173
+ "error": error_msg
174
+ }
175
+
176
+ # Handle successful response with result
177
+ if "result" in response:
178
+ result = response["result"]
179
+
180
+ if isinstance(result, dict) and "content" in result:
181
+ return {
182
+ "isError": False,
183
+ "content": self._extract_mcp_content(result["content"])
184
+ }
185
+ else:
186
+ return {
187
+ "isError": False,
188
+ "content": result
189
+ }
190
+
191
+ # Handle direct content-based response
192
+ if "content" in response:
193
+ return {
194
+ "isError": False,
195
+ "content": self._extract_mcp_content(response["content"])
196
+ }
197
+
198
+ # Fallback
199
+ return {
200
+ "isError": False,
201
+ "content": response
202
+ }
203
+
204
+ def _extract_mcp_content(self, content_list: Any) -> Any:
205
+ """
206
+ Extract content from MCP content format.
207
+
208
+ Handles the standard MCP content format where content is a list
209
+ of content items with type and data.
210
+ """
211
+ if not isinstance(content_list, list) or not content_list:
212
+ return content_list
213
+
214
+ # Handle single content item
215
+ if len(content_list) == 1:
216
+ content_item = content_list[0]
217
+ if isinstance(content_item, dict):
218
+ if content_item.get("type") == "text":
219
+ text_content = content_item.get("text", "")
220
+ # Try to parse JSON, fall back to plain text
221
+ try:
222
+ import json
223
+ return json.loads(text_content)
224
+ except json.JSONDecodeError:
225
+ return text_content
226
+ else:
227
+ return content_item
228
+
229
+ # Multiple content items - return as-is
230
+ return content_list
231
+
232
+ # ------------------------------------------------------------------ #
233
+ # Standard string representation #
234
+ # ------------------------------------------------------------------ #
235
+ def __repr__(self) -> str:
236
+ """Standard string representation for all transports."""
237
+ status = "initialized" if getattr(self, '_initialized', False) else "not initialized"
238
+
239
+ # Add metrics info if available
240
+ metrics_info = ""
241
+ if hasattr(self, 'enable_metrics') and getattr(self, 'enable_metrics', False):
242
+ metrics = self.get_metrics()
243
+ if metrics.get("total_calls", 0) > 0:
244
+ success_rate = (metrics.get("successful_calls", 0) / metrics["total_calls"]) * 100
245
+ metrics_info = f", calls: {metrics['total_calls']}, success: {success_rate:.1f}%"
246
+
247
+ # Add transport-specific info - FIXED FORMAT
248
+ transport_info = ""
249
+ if hasattr(self, 'url'):
250
+ transport_info = f", url={self.url}" # Fixed: was "url: "
251
+ elif hasattr(self, 'server_params') and hasattr(self.server_params, 'command'):
252
+ transport_info = f", command={self.server_params.command}" # Fixed: was "command: "
253
+
254
+ return f"{self.__class__.__name__}(status={status}{transport_info}{metrics_info})"