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

@@ -2,12 +2,8 @@
2
2
  """
3
3
  SSE transport for MCP communication.
4
4
 
5
- Implements Server-Sent Events transport with two-step async pattern:
6
- 1. POST messages to /messages endpoint
7
- 2. Receive responses via SSE stream
8
-
9
- Note: This transport is deprecated in favor of HTTP Streamable (spec 2025-03-26)
10
- but remains supported for backward compatibility.
5
+ FIXED: Removed problematic gateway connectivity test that was causing 401 errors.
6
+ The SSE endpoint works perfectly, so we don't need to test the base URL.
11
7
  """
12
8
  from __future__ import annotations
13
9
 
@@ -29,26 +25,16 @@ class SSETransport(MCPBaseTransport):
29
25
  """
30
26
  SSE transport implementing the MCP protocol over Server-Sent Events.
31
27
 
32
- This transport uses a dual-connection approach:
33
- - SSE stream for receiving responses
34
- - HTTP POST for sending requests
28
+ FIXED: Uses SSE endpoint directly without problematic connectivity tests.
35
29
  """
36
30
 
37
31
  def __init__(self, url: str, api_key: Optional[str] = None,
38
32
  headers: Optional[Dict[str, str]] = None,
39
33
  connection_timeout: float = 30.0,
40
- default_timeout: float = 30.0,
34
+ default_timeout: float = 60.0,
41
35
  enable_metrics: bool = True):
42
36
  """
43
37
  Initialize SSE transport.
44
-
45
- Args:
46
- url: Base URL for the MCP server
47
- api_key: Optional API key for authentication
48
- headers: Optional custom headers
49
- connection_timeout: Timeout for initial connection setup
50
- default_timeout: Default timeout for operations
51
- enable_metrics: Whether to track performance metrics
52
38
  """
53
39
  self.url = url.rstrip('/')
54
40
  self.api_key = api_key
@@ -58,10 +44,6 @@ class SSETransport(MCPBaseTransport):
58
44
  self.enable_metrics = enable_metrics
59
45
 
60
46
  logger.debug("SSE Transport initialized with URL: %s", self.url)
61
- if self.api_key:
62
- logger.debug("API key configured for authentication")
63
- if self.configured_headers:
64
- logger.debug("Custom headers configured: %s", list(self.configured_headers.keys()))
65
47
 
66
48
  # Connection state
67
49
  self.session_id = None
@@ -78,7 +60,12 @@ class SSETransport(MCPBaseTransport):
78
60
  self.sse_response = None
79
61
  self.sse_stream_context = None
80
62
 
81
- # Performance metrics (consistent with other transports)
63
+ # Health monitoring
64
+ self._last_successful_ping = None
65
+ self._consecutive_failures = 0
66
+ self._max_consecutive_failures = 3
67
+
68
+ # Performance metrics
82
69
  self._metrics = {
83
70
  "total_calls": 0,
84
71
  "successful_calls": 0,
@@ -92,11 +79,7 @@ class SSETransport(MCPBaseTransport):
92
79
  }
93
80
 
94
81
  def _construct_sse_url(self, base_url: str) -> str:
95
- """
96
- Construct the SSE endpoint URL from the base URL.
97
-
98
- Smart detection to avoid double-appending /sse if already present.
99
- """
82
+ """Construct the SSE endpoint URL from the base URL."""
100
83
  base_url = base_url.rstrip('/')
101
84
 
102
85
  if base_url.endswith('/sse'):
@@ -109,20 +92,34 @@ class SSETransport(MCPBaseTransport):
109
92
 
110
93
  def _get_headers(self) -> Dict[str, str]:
111
94
  """Get headers with authentication and custom headers."""
112
- headers = {}
95
+ headers = {
96
+ 'User-Agent': 'chuk-tool-processor/1.0.0',
97
+ 'Accept': 'text/event-stream',
98
+ 'Cache-Control': 'no-cache',
99
+ }
113
100
 
114
101
  # Add configured headers first
115
102
  if self.configured_headers:
116
103
  headers.update(self.configured_headers)
117
104
 
118
- # Add API key as Bearer token if provided (overrides Authorization header)
105
+ # Add API key as Bearer token if provided
119
106
  if self.api_key:
120
107
  headers['Authorization'] = f'Bearer {self.api_key}'
121
108
 
122
109
  return headers
123
110
 
111
+ async def _test_gateway_connectivity(self) -> bool:
112
+ """
113
+ Skip connectivity test - we know the SSE endpoint works.
114
+
115
+ FIXED: The diagnostic proves SSE endpoint works perfectly.
116
+ No need to test base URL that causes 401 errors.
117
+ """
118
+ logger.debug("Skipping gateway connectivity test - using direct SSE connection")
119
+ return True
120
+
124
121
  async def initialize(self) -> bool:
125
- """Initialize SSE connection and perform MCP handshake."""
122
+ """Initialize SSE connection."""
126
123
  if self._initialized:
127
124
  logger.warning("Transport already initialized")
128
125
  return True
@@ -132,9 +129,22 @@ class SSETransport(MCPBaseTransport):
132
129
  try:
133
130
  logger.debug("Initializing SSE transport...")
134
131
 
135
- # Create HTTP clients with appropriate timeouts
136
- self.stream_client = httpx.AsyncClient(timeout=self.connection_timeout)
137
- self.send_client = httpx.AsyncClient(timeout=self.default_timeout)
132
+ # FIXED: Skip problematic connectivity test
133
+ if not await self._test_gateway_connectivity():
134
+ logger.error("Gateway connectivity test failed")
135
+ return False
136
+
137
+ # Create HTTP clients
138
+ self.stream_client = httpx.AsyncClient(
139
+ timeout=httpx.Timeout(self.connection_timeout),
140
+ follow_redirects=True,
141
+ limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
142
+ )
143
+ self.send_client = httpx.AsyncClient(
144
+ timeout=httpx.Timeout(self.default_timeout),
145
+ follow_redirects=True,
146
+ limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
147
+ )
138
148
 
139
149
  # Connect to SSE stream
140
150
  sse_url = self._construct_sse_url(self.url)
@@ -158,13 +168,21 @@ class SSETransport(MCPBaseTransport):
158
168
  name="sse_stream_processor"
159
169
  )
160
170
 
161
- # Wait for session discovery with timeout
171
+ # Wait for session discovery
162
172
  logger.debug("Waiting for session discovery...")
163
- session_timeout = 5.0 # 5 seconds max for session discovery
173
+ session_timeout = 10.0
164
174
  session_start = time.time()
165
175
 
166
176
  while not self.message_url and (time.time() - session_start) < session_timeout:
167
177
  await asyncio.sleep(0.1)
178
+
179
+ # Check if SSE task died
180
+ if self.sse_task.done():
181
+ exception = self.sse_task.exception()
182
+ if exception:
183
+ logger.error(f"SSE task died during session discovery: {exception}")
184
+ await self._cleanup()
185
+ return False
168
186
 
169
187
  if not self.message_url:
170
188
  logger.error("Failed to discover session endpoint within %.1fs", session_timeout)
@@ -174,7 +192,7 @@ class SSETransport(MCPBaseTransport):
174
192
  if self.enable_metrics:
175
193
  self._metrics["session_discoveries"] += 1
176
194
 
177
- logger.debug("Session endpoint discovered: %s", self.session_id)
195
+ logger.debug("Session endpoint discovered: %s", self.message_url)
178
196
 
179
197
  # Perform MCP initialization handshake
180
198
  try:
@@ -185,7 +203,7 @@ class SSETransport(MCPBaseTransport):
185
203
  "name": "chuk-tool-processor",
186
204
  "version": "1.0.0"
187
205
  }
188
- })
206
+ }, timeout=self.default_timeout)
189
207
 
190
208
  if 'error' in init_response:
191
209
  logger.error("MCP initialize failed: %s", init_response['error'])
@@ -196,6 +214,7 @@ class SSETransport(MCPBaseTransport):
196
214
  await self._send_notification("notifications/initialized")
197
215
 
198
216
  self._initialized = True
217
+ self._last_successful_ping = time.time()
199
218
 
200
219
  if self.enable_metrics:
201
220
  init_time = time.time() - start_time
@@ -215,33 +234,60 @@ class SSETransport(MCPBaseTransport):
215
234
  return False
216
235
 
217
236
  async def _process_sse_stream(self):
218
- """Process the persistent SSE stream for responses and session discovery."""
237
+ """Process the SSE stream for responses and session discovery."""
219
238
  try:
220
239
  logger.debug("Starting SSE stream processing...")
221
240
 
241
+ current_event = None
242
+
222
243
  async for line in self.sse_response.aiter_lines():
223
244
  line = line.strip()
224
245
  if not line:
225
246
  continue
226
247
 
248
+ # Handle event type declarations
249
+ if line.startswith('event:'):
250
+ current_event = line.split(':', 1)[1].strip()
251
+ logger.debug("SSE event type: %s", current_event)
252
+ continue
253
+
227
254
  # Handle session endpoint discovery
228
- if not self.message_url and line.startswith('data:') and '/messages/' in line:
229
- endpoint_path = line.split(':', 1)[1].strip()
230
- self.message_url = f"{self.url}{endpoint_path}"
255
+ if not self.message_url and line.startswith('data:'):
256
+ data_part = line.split(':', 1)[1].strip()
231
257
 
232
- # Extract session ID if present
233
- if 'session_id=' in endpoint_path:
234
- self.session_id = endpoint_path.split('session_id=')[1].split('&')[0]
258
+ # NEW FORMAT: event: endpoint + data: https://...
259
+ if current_event == "endpoint" and data_part.startswith('http'):
260
+ self.message_url = data_part
261
+
262
+ # Extract session ID from URL if present
263
+ if 'session_id=' in data_part:
264
+ self.session_id = data_part.split('session_id=')[1].split('&')[0]
265
+ else:
266
+ self.session_id = str(uuid.uuid4())
267
+
268
+ logger.debug("Session endpoint discovered via event format: %s", self.message_url)
269
+ continue
235
270
 
236
- logger.debug("Session endpoint discovered: %s", self.session_id)
237
- continue
271
+ # OLD FORMAT: data: /messages/... (backwards compatibility)
272
+ elif '/messages/' in data_part:
273
+ endpoint_path = data_part
274
+ self.message_url = f"{self.url}{endpoint_path}"
275
+
276
+ # Extract session ID if present
277
+ if 'session_id=' in endpoint_path:
278
+ self.session_id = endpoint_path.split('session_id=')[1].split('&')[0]
279
+ else:
280
+ self.session_id = str(uuid.uuid4())
281
+
282
+ logger.debug("Session endpoint discovered via old format: %s", self.message_url)
283
+ continue
238
284
 
239
285
  # Handle JSON-RPC responses
240
286
  if line.startswith('data:'):
241
287
  data_part = line.split(':', 1)[1].strip()
242
288
 
243
289
  # Skip keepalive pings and empty data
244
- if not data_part or data_part.startswith('ping'):
290
+ if not data_part or data_part.startswith('ping') or data_part in ('{}', '[]'):
245
291
  continue
246
292
 
247
293
  try:
@@ -265,6 +311,7 @@ class SSETransport(MCPBaseTransport):
265
311
  if self.enable_metrics:
266
312
  self._metrics["stream_errors"] += 1
267
313
  logger.error("SSE stream processing error: %s", e)
314
+ self._consecutive_failures += 1
268
315
 
269
316
  async def _send_request(self, method: str, params: Dict[str, Any] = None,
270
317
  timeout: Optional[float] = None) -> Dict[str, Any]:
@@ -301,20 +348,25 @@ class SSETransport(MCPBaseTransport):
301
348
  # Async response - wait for result via SSE
302
349
  request_timeout = timeout or self.default_timeout
303
350
  result = await asyncio.wait_for(future, timeout=request_timeout)
351
+ self._consecutive_failures = 0 # Reset on success
304
352
  return result
305
353
  elif response.status_code == 200:
306
354
  # Immediate response
307
355
  self.pending_requests.pop(request_id, None)
356
+ self._consecutive_failures = 0 # Reset on success
308
357
  return response.json()
309
358
  else:
310
359
  self.pending_requests.pop(request_id, None)
360
+ self._consecutive_failures += 1
311
361
  raise RuntimeError(f"HTTP request failed with status: {response.status_code}")
312
362
 
313
363
  except asyncio.TimeoutError:
314
364
  self.pending_requests.pop(request_id, None)
365
+ self._consecutive_failures += 1
315
366
  raise
316
367
  except Exception:
317
368
  self.pending_requests.pop(request_id, None)
369
+ self._consecutive_failures += 1
318
370
  raise
319
371
 
320
372
  async def _send_notification(self, method: str, params: Dict[str, Any] = None):
@@ -350,21 +402,43 @@ class SSETransport(MCPBaseTransport):
350
402
  start_time = time.time()
351
403
  try:
352
404
  # Use tools/list as a lightweight ping since not all servers support ping
353
- response = await self._send_request("tools/list", {}, timeout=5.0)
405
+ response = await self._send_request("tools/list", {}, timeout=10.0)
406
+
407
+ success = 'error' not in response
408
+
409
+ if success:
410
+ self._last_successful_ping = time.time()
411
+ self._consecutive_failures = 0
354
412
 
355
413
  if self.enable_metrics:
356
414
  ping_time = time.time() - start_time
357
415
  self._metrics["last_ping_time"] = ping_time
358
- logger.debug("SSE ping completed in %.3fs", ping_time)
416
+ logger.debug("SSE ping completed in %.3fs: %s", ping_time, success)
359
417
 
360
- return 'error' not in response
418
+ return success
361
419
  except Exception as e:
362
420
  logger.debug("SSE ping failed: %s", e)
421
+ self._consecutive_failures += 1
363
422
  return False
364
423
 
365
424
  def is_connected(self) -> bool:
366
425
  """Check if the transport is connected and ready."""
367
- return self._initialized and self.session_id is not None
426
+ if not self._initialized or not self.session_id:
427
+ return False
428
+
429
+ # Check if we've had too many consecutive failures
430
+ if self._consecutive_failures >= self._max_consecutive_failures:
431
+ logger.warning(f"Connection marked unhealthy after {self._consecutive_failures} failures")
432
+ return False
433
+
434
+ # Check if SSE task is still running
435
+ if self.sse_task and self.sse_task.done():
436
+ exception = self.sse_task.exception()
437
+ if exception:
438
+ logger.warning(f"SSE task died: {exception}")
439
+ return False
440
+
441
+ return True
368
442
 
369
443
  async def get_tools(self) -> List[Dict[str, Any]]:
370
444
  """Get list of available tools from the server."""
@@ -403,7 +477,7 @@ class SSETransport(MCPBaseTransport):
403
477
 
404
478
  start_time = time.time()
405
479
  if self.enable_metrics:
406
- self._metrics["total_calls"] += 1 # FIXED: INCREMENT FIRST
480
+ self._metrics["total_calls"] += 1
407
481
 
408
482
  try:
409
483
  logger.debug("Calling tool '%s' with arguments: %s", tool_name, arguments)
@@ -461,7 +535,6 @@ class SSETransport(MCPBaseTransport):
461
535
  self._metrics["failed_calls"] += 1
462
536
 
463
537
  self._metrics["total_time"] += response_time
464
- # FIXED: Only calculate average if we have total calls
465
538
  if self._metrics["total_calls"] > 0:
466
539
  self._metrics["avg_response_time"] = (
467
540
  self._metrics["total_time"] / self._metrics["total_calls"]
@@ -553,9 +626,6 @@ class SSETransport(MCPBaseTransport):
553
626
  self.stream_client = None
554
627
  self.send_client = None
555
628
 
556
- # ------------------------------------------------------------------ #
557
- # Metrics and monitoring (consistent with other transports) #
558
- # ------------------------------------------------------------------ #
559
629
  def get_metrics(self) -> Dict[str, Any]:
560
630
  """Get performance and connection metrics."""
561
631
  return self._metrics.copy()
@@ -574,21 +644,15 @@ class SSETransport(MCPBaseTransport):
574
644
  "stream_errors": 0
575
645
  }
576
646
 
577
- # ------------------------------------------------------------------ #
578
- # Backward compatibility #
579
- # ------------------------------------------------------------------ #
580
647
  def get_streams(self) -> List[tuple]:
581
648
  """SSE transport doesn't expose raw streams."""
582
649
  return []
583
650
 
584
- # ------------------------------------------------------------------ #
585
- # Context manager support (now uses base class with fixed error) #
586
- # ------------------------------------------------------------------ #
587
651
  async def __aenter__(self):
588
652
  """Context manager entry."""
589
653
  success = await self.initialize()
590
654
  if not success:
591
- raise RuntimeError("Failed to initialize SSETransport") # FIXED: message
655
+ raise RuntimeError("Failed to initialize SSETransport")
592
656
  return self
593
657
 
594
658
  async def __aexit__(self, exc_type, exc_val, exc_tb):