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.
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +229 -78
- chuk_tool_processor/mcp/transport/sse_transport.py +128 -64
- chuk_tool_processor/mcp/transport/stdio_transport.py +293 -58
- {chuk_tool_processor-0.6.9.dist-info → chuk_tool_processor-0.6.11.dist-info}/METADATA +2 -1
- {chuk_tool_processor-0.6.9.dist-info → chuk_tool_processor-0.6.11.dist-info}/RECORD +7 -7
- {chuk_tool_processor-0.6.9.dist-info → chuk_tool_processor-0.6.11.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.6.9.dist-info → chuk_tool_processor-0.6.11.dist-info}/top_level.txt +0 -0
|
@@ -2,12 +2,8 @@
|
|
|
2
2
|
"""
|
|
3
3
|
SSE transport for MCP communication.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
#
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
171
|
+
# Wait for session discovery
|
|
162
172
|
logger.debug("Waiting for session discovery...")
|
|
163
|
-
session_timeout =
|
|
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.
|
|
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
|
|
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:')
|
|
229
|
-
|
|
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
|
-
#
|
|
233
|
-
if '
|
|
234
|
-
self.
|
|
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
|
-
|
|
237
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
|
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")
|
|
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):
|