chuk-tool-processor 0.6.7__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.
- chuk_tool_processor/mcp/stream_manager.py +53 -34
- chuk_tool_processor/mcp/transport/__init__.py +10 -27
- chuk_tool_processor/mcp/transport/base_transport.py +197 -46
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +134 -173
- chuk_tool_processor/mcp/transport/sse_transport.py +238 -119
- chuk_tool_processor/mcp/transport/stdio_transport.py +297 -81
- {chuk_tool_processor-0.6.7.dist-info → chuk_tool_processor-0.6.9.dist-info}/METADATA +1 -1
- {chuk_tool_processor-0.6.7.dist-info → chuk_tool_processor-0.6.9.dist-info}/RECORD +10 -10
- {chuk_tool_processor-0.6.7.dist-info → chuk_tool_processor-0.6.9.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.6.7.dist-info → chuk_tool_processor-0.6.9.dist-info}/top_level.txt +0 -0
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
# chuk_tool_processor/mcp/transport/sse_transport.py
|
|
2
2
|
"""
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
SSE transport for MCP communication.
|
|
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
11
|
"""
|
|
6
12
|
from __future__ import annotations
|
|
7
13
|
|
|
8
14
|
import asyncio
|
|
9
15
|
import json
|
|
16
|
+
import time
|
|
10
17
|
import uuid
|
|
11
|
-
from typing import Dict, Any, List, Optional
|
|
18
|
+
from typing import Dict, Any, List, Optional
|
|
12
19
|
import logging
|
|
13
20
|
|
|
14
21
|
import httpx
|
|
@@ -20,28 +27,43 @@ logger = logging.getLogger(__name__)
|
|
|
20
27
|
|
|
21
28
|
class SSETransport(MCPBaseTransport):
|
|
22
29
|
"""
|
|
23
|
-
SSE transport
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
SSE transport implementing the MCP protocol over Server-Sent Events.
|
|
31
|
+
|
|
32
|
+
This transport uses a dual-connection approach:
|
|
33
|
+
- SSE stream for receiving responses
|
|
34
|
+
- HTTP POST for sending requests
|
|
26
35
|
"""
|
|
27
36
|
|
|
28
37
|
def __init__(self, url: str, api_key: Optional[str] = None,
|
|
29
38
|
headers: Optional[Dict[str, str]] = None,
|
|
30
|
-
connection_timeout: float = 30.0,
|
|
31
|
-
|
|
39
|
+
connection_timeout: float = 30.0,
|
|
40
|
+
default_timeout: float = 30.0,
|
|
41
|
+
enable_metrics: bool = True):
|
|
42
|
+
"""
|
|
43
|
+
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
|
+
"""
|
|
32
53
|
self.url = url.rstrip('/')
|
|
33
54
|
self.api_key = api_key
|
|
34
55
|
self.configured_headers = headers or {}
|
|
35
56
|
self.connection_timeout = connection_timeout
|
|
36
57
|
self.default_timeout = default_timeout
|
|
58
|
+
self.enable_metrics = enable_metrics
|
|
37
59
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
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()))
|
|
43
65
|
|
|
44
|
-
#
|
|
66
|
+
# Connection state
|
|
45
67
|
self.session_id = None
|
|
46
68
|
self.message_url = None
|
|
47
69
|
self.pending_requests: Dict[str, asyncio.Future] = {}
|
|
@@ -51,10 +73,23 @@ class SSETransport(MCPBaseTransport):
|
|
|
51
73
|
self.stream_client = None
|
|
52
74
|
self.send_client = None
|
|
53
75
|
|
|
54
|
-
# SSE stream
|
|
76
|
+
# SSE stream management
|
|
55
77
|
self.sse_task = None
|
|
56
78
|
self.sse_response = None
|
|
57
79
|
self.sse_stream_context = None
|
|
80
|
+
|
|
81
|
+
# Performance metrics (consistent with other transports)
|
|
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
|
+
"session_discoveries": 0,
|
|
91
|
+
"stream_errors": 0
|
|
92
|
+
}
|
|
58
93
|
|
|
59
94
|
def _construct_sse_url(self, base_url: str) -> str:
|
|
60
95
|
"""
|
|
@@ -62,53 +97,48 @@ class SSETransport(MCPBaseTransport):
|
|
|
62
97
|
|
|
63
98
|
Smart detection to avoid double-appending /sse if already present.
|
|
64
99
|
"""
|
|
65
|
-
# Remove trailing slashes
|
|
66
100
|
base_url = base_url.rstrip('/')
|
|
67
101
|
|
|
68
|
-
# Check if URL already ends with /sse
|
|
69
102
|
if base_url.endswith('/sse'):
|
|
70
|
-
# Already has /sse, use as-is
|
|
71
103
|
logger.debug("URL already contains /sse endpoint: %s", base_url)
|
|
72
104
|
return base_url
|
|
73
105
|
|
|
74
|
-
# Append /sse to the base URL
|
|
75
106
|
sse_url = f"{base_url}/sse"
|
|
76
|
-
logger.debug("
|
|
107
|
+
logger.debug("Constructed SSE URL: %s -> %s", base_url, sse_url)
|
|
77
108
|
return sse_url
|
|
78
109
|
|
|
79
110
|
def _get_headers(self) -> Dict[str, str]:
|
|
80
|
-
"""Get headers with
|
|
111
|
+
"""Get headers with authentication and custom headers."""
|
|
81
112
|
headers = {}
|
|
82
113
|
|
|
83
114
|
# Add configured headers first
|
|
84
115
|
if self.configured_headers:
|
|
85
116
|
headers.update(self.configured_headers)
|
|
86
117
|
|
|
87
|
-
# Add API key as Bearer token if provided (
|
|
118
|
+
# Add API key as Bearer token if provided (overrides Authorization header)
|
|
88
119
|
if self.api_key:
|
|
89
120
|
headers['Authorization'] = f'Bearer {self.api_key}'
|
|
90
121
|
|
|
91
|
-
# DEBUG: Log what headers we're sending
|
|
92
|
-
logger.debug("Sending headers: %s", {k: v[:10] + "..." if len(v) > 10 else v for k, v in headers.items()})
|
|
93
|
-
|
|
94
122
|
return headers
|
|
95
123
|
|
|
96
124
|
async def initialize(self) -> bool:
|
|
97
|
-
"""Initialize SSE connection and MCP handshake."""
|
|
125
|
+
"""Initialize SSE connection and perform MCP handshake."""
|
|
98
126
|
if self._initialized:
|
|
99
127
|
logger.warning("Transport already initialized")
|
|
100
128
|
return True
|
|
101
129
|
|
|
130
|
+
start_time = time.time()
|
|
131
|
+
|
|
102
132
|
try:
|
|
103
133
|
logger.debug("Initializing SSE transport...")
|
|
104
134
|
|
|
105
|
-
# Create HTTP clients
|
|
135
|
+
# Create HTTP clients with appropriate timeouts
|
|
106
136
|
self.stream_client = httpx.AsyncClient(timeout=self.connection_timeout)
|
|
107
137
|
self.send_client = httpx.AsyncClient(timeout=self.default_timeout)
|
|
108
138
|
|
|
109
|
-
# Connect to SSE stream
|
|
139
|
+
# Connect to SSE stream
|
|
110
140
|
sse_url = self._construct_sse_url(self.url)
|
|
111
|
-
logger.debug("Connecting to SSE: %s", sse_url)
|
|
141
|
+
logger.debug("Connecting to SSE endpoint: %s", sse_url)
|
|
112
142
|
|
|
113
143
|
self.sse_stream_context = self.stream_client.stream(
|
|
114
144
|
'GET', sse_url, headers=self._get_headers()
|
|
@@ -116,28 +146,37 @@ class SSETransport(MCPBaseTransport):
|
|
|
116
146
|
self.sse_response = await self.sse_stream_context.__aenter__()
|
|
117
147
|
|
|
118
148
|
if self.sse_response.status_code != 200:
|
|
119
|
-
logger.error("SSE connection failed: %s", self.sse_response.status_code)
|
|
149
|
+
logger.error("SSE connection failed with status: %s", self.sse_response.status_code)
|
|
150
|
+
await self._cleanup()
|
|
120
151
|
return False
|
|
121
152
|
|
|
122
153
|
logger.debug("SSE streaming connection established")
|
|
123
154
|
|
|
124
155
|
# Start SSE processing task
|
|
125
|
-
self.sse_task = asyncio.create_task(
|
|
156
|
+
self.sse_task = asyncio.create_task(
|
|
157
|
+
self._process_sse_stream(),
|
|
158
|
+
name="sse_stream_processor"
|
|
159
|
+
)
|
|
126
160
|
|
|
127
|
-
# Wait for session discovery
|
|
161
|
+
# Wait for session discovery with timeout
|
|
128
162
|
logger.debug("Waiting for session discovery...")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
163
|
+
session_timeout = 5.0 # 5 seconds max for session discovery
|
|
164
|
+
session_start = time.time()
|
|
165
|
+
|
|
166
|
+
while not self.message_url and (time.time() - session_start) < session_timeout:
|
|
132
167
|
await asyncio.sleep(0.1)
|
|
133
168
|
|
|
134
169
|
if not self.message_url:
|
|
135
|
-
logger.error("Failed to
|
|
170
|
+
logger.error("Failed to discover session endpoint within %.1fs", session_timeout)
|
|
171
|
+
await self._cleanup()
|
|
136
172
|
return False
|
|
137
173
|
|
|
138
|
-
|
|
174
|
+
if self.enable_metrics:
|
|
175
|
+
self._metrics["session_discoveries"] += 1
|
|
176
|
+
|
|
177
|
+
logger.debug("Session endpoint discovered: %s", self.session_id)
|
|
139
178
|
|
|
140
|
-
#
|
|
179
|
+
# Perform MCP initialization handshake
|
|
141
180
|
try:
|
|
142
181
|
init_response = await self._send_request("initialize", {
|
|
143
182
|
"protocolVersion": "2024-11-05",
|
|
@@ -149,18 +188,25 @@ class SSETransport(MCPBaseTransport):
|
|
|
149
188
|
})
|
|
150
189
|
|
|
151
190
|
if 'error' in init_response:
|
|
152
|
-
logger.error("
|
|
191
|
+
logger.error("MCP initialize failed: %s", init_response['error'])
|
|
192
|
+
await self._cleanup()
|
|
153
193
|
return False
|
|
154
194
|
|
|
155
195
|
# Send initialized notification
|
|
156
196
|
await self._send_notification("notifications/initialized")
|
|
157
197
|
|
|
158
198
|
self._initialized = True
|
|
159
|
-
|
|
199
|
+
|
|
200
|
+
if self.enable_metrics:
|
|
201
|
+
init_time = time.time() - start_time
|
|
202
|
+
self._metrics["initialization_time"] = init_time
|
|
203
|
+
|
|
204
|
+
logger.debug("SSE transport initialized successfully in %.3fs", time.time() - start_time)
|
|
160
205
|
return True
|
|
161
206
|
|
|
162
207
|
except Exception as e:
|
|
163
|
-
logger.error("MCP
|
|
208
|
+
logger.error("MCP handshake failed: %s", e)
|
|
209
|
+
await self._cleanup()
|
|
164
210
|
return False
|
|
165
211
|
|
|
166
212
|
except Exception as e:
|
|
@@ -169,7 +215,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
169
215
|
return False
|
|
170
216
|
|
|
171
217
|
async def _process_sse_stream(self):
|
|
172
|
-
"""Process the persistent SSE stream."""
|
|
218
|
+
"""Process the persistent SSE stream for responses and session discovery."""
|
|
173
219
|
try:
|
|
174
220
|
logger.debug("Starting SSE stream processing...")
|
|
175
221
|
|
|
@@ -183,44 +229,48 @@ class SSETransport(MCPBaseTransport):
|
|
|
183
229
|
endpoint_path = line.split(':', 1)[1].strip()
|
|
184
230
|
self.message_url = f"{self.url}{endpoint_path}"
|
|
185
231
|
|
|
232
|
+
# Extract session ID if present
|
|
186
233
|
if 'session_id=' in endpoint_path:
|
|
187
234
|
self.session_id = endpoint_path.split('session_id=')[1].split('&')[0]
|
|
188
235
|
|
|
189
|
-
logger.debug("
|
|
236
|
+
logger.debug("Session endpoint discovered: %s", self.session_id)
|
|
190
237
|
continue
|
|
191
238
|
|
|
192
239
|
# Handle JSON-RPC responses
|
|
193
240
|
if line.startswith('data:'):
|
|
194
241
|
data_part = line.split(':', 1)[1].strip()
|
|
195
242
|
|
|
196
|
-
# Skip pings and empty data
|
|
243
|
+
# Skip keepalive pings and empty data
|
|
197
244
|
if not data_part or data_part.startswith('ping'):
|
|
198
245
|
continue
|
|
199
246
|
|
|
200
247
|
try:
|
|
201
248
|
response_data = json.loads(data_part)
|
|
202
249
|
|
|
250
|
+
# Handle JSON-RPC responses with request IDs
|
|
203
251
|
if 'jsonrpc' in response_data and 'id' in response_data:
|
|
204
252
|
request_id = str(response_data['id'])
|
|
205
253
|
|
|
206
|
-
# Resolve pending request
|
|
254
|
+
# Resolve pending request if found
|
|
207
255
|
if request_id in self.pending_requests:
|
|
208
256
|
future = self.pending_requests.pop(request_id)
|
|
209
257
|
if not future.done():
|
|
210
258
|
future.set_result(response_data)
|
|
211
|
-
logger.debug("Resolved request: %s", request_id)
|
|
259
|
+
logger.debug("Resolved request ID: %s", request_id)
|
|
212
260
|
|
|
213
|
-
except json.JSONDecodeError:
|
|
214
|
-
|
|
261
|
+
except json.JSONDecodeError as e:
|
|
262
|
+
logger.debug("Non-JSON data in SSE stream (ignoring): %s", e)
|
|
215
263
|
|
|
216
264
|
except Exception as e:
|
|
217
|
-
|
|
265
|
+
if self.enable_metrics:
|
|
266
|
+
self._metrics["stream_errors"] += 1
|
|
267
|
+
logger.error("SSE stream processing error: %s", e)
|
|
218
268
|
|
|
219
269
|
async def _send_request(self, method: str, params: Dict[str, Any] = None,
|
|
220
270
|
timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
221
|
-
"""Send request and wait for async response."""
|
|
271
|
+
"""Send JSON-RPC request and wait for async response via SSE."""
|
|
222
272
|
if not self.message_url:
|
|
223
|
-
raise RuntimeError("
|
|
273
|
+
raise RuntimeError("SSE transport not connected - no message URL")
|
|
224
274
|
|
|
225
275
|
request_id = str(uuid.uuid4())
|
|
226
276
|
message = {
|
|
@@ -230,12 +280,12 @@ class SSETransport(MCPBaseTransport):
|
|
|
230
280
|
"params": params or {}
|
|
231
281
|
}
|
|
232
282
|
|
|
233
|
-
# Create future for response
|
|
283
|
+
# Create future for async response
|
|
234
284
|
future = asyncio.Future()
|
|
235
285
|
self.pending_requests[request_id] = future
|
|
236
286
|
|
|
237
287
|
try:
|
|
238
|
-
# Send
|
|
288
|
+
# Send HTTP POST request
|
|
239
289
|
headers = {
|
|
240
290
|
'Content-Type': 'application/json',
|
|
241
291
|
**self._get_headers()
|
|
@@ -248,9 +298,9 @@ class SSETransport(MCPBaseTransport):
|
|
|
248
298
|
)
|
|
249
299
|
|
|
250
300
|
if response.status_code == 202:
|
|
251
|
-
#
|
|
252
|
-
|
|
253
|
-
result = await asyncio.wait_for(future, timeout=
|
|
301
|
+
# Async response - wait for result via SSE
|
|
302
|
+
request_timeout = timeout or self.default_timeout
|
|
303
|
+
result = await asyncio.wait_for(future, timeout=request_timeout)
|
|
254
304
|
return result
|
|
255
305
|
elif response.status_code == 200:
|
|
256
306
|
# Immediate response
|
|
@@ -258,7 +308,7 @@ class SSETransport(MCPBaseTransport):
|
|
|
258
308
|
return response.json()
|
|
259
309
|
else:
|
|
260
310
|
self.pending_requests.pop(request_id, None)
|
|
261
|
-
raise RuntimeError(f"
|
|
311
|
+
raise RuntimeError(f"HTTP request failed with status: {response.status_code}")
|
|
262
312
|
|
|
263
313
|
except asyncio.TimeoutError:
|
|
264
314
|
self.pending_requests.pop(request_id, None)
|
|
@@ -268,9 +318,9 @@ class SSETransport(MCPBaseTransport):
|
|
|
268
318
|
raise
|
|
269
319
|
|
|
270
320
|
async def _send_notification(self, method: str, params: Dict[str, Any] = None):
|
|
271
|
-
"""Send notification (no response expected)."""
|
|
321
|
+
"""Send JSON-RPC notification (no response expected)."""
|
|
272
322
|
if not self.message_url:
|
|
273
|
-
raise RuntimeError("
|
|
323
|
+
raise RuntimeError("SSE transport not connected - no message URL")
|
|
274
324
|
|
|
275
325
|
message = {
|
|
276
326
|
"jsonrpc": "2.0",
|
|
@@ -283,30 +333,46 @@ class SSETransport(MCPBaseTransport):
|
|
|
283
333
|
**self._get_headers()
|
|
284
334
|
}
|
|
285
335
|
|
|
286
|
-
await self.send_client.post(
|
|
336
|
+
response = await self.send_client.post(
|
|
287
337
|
self.message_url,
|
|
288
338
|
headers=headers,
|
|
289
339
|
json=message
|
|
290
340
|
)
|
|
341
|
+
|
|
342
|
+
if response.status_code not in (200, 202):
|
|
343
|
+
logger.warning("Notification failed with status: %s", response.status_code)
|
|
291
344
|
|
|
292
345
|
async def send_ping(self) -> bool:
|
|
293
|
-
"""Send ping to check connection."""
|
|
346
|
+
"""Send ping to check connection health."""
|
|
294
347
|
if not self._initialized:
|
|
295
348
|
return False
|
|
296
349
|
|
|
350
|
+
start_time = time.time()
|
|
297
351
|
try:
|
|
298
|
-
#
|
|
352
|
+
# Use tools/list as a lightweight ping since not all servers support ping
|
|
299
353
|
response = await self._send_request("tools/list", {}, timeout=5.0)
|
|
354
|
+
|
|
355
|
+
if self.enable_metrics:
|
|
356
|
+
ping_time = time.time() - start_time
|
|
357
|
+
self._metrics["last_ping_time"] = ping_time
|
|
358
|
+
logger.debug("SSE ping completed in %.3fs", ping_time)
|
|
359
|
+
|
|
300
360
|
return 'error' not in response
|
|
301
|
-
except Exception:
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.debug("SSE ping failed: %s", e)
|
|
302
363
|
return False
|
|
303
364
|
|
|
365
|
+
def is_connected(self) -> bool:
|
|
366
|
+
"""Check if the transport is connected and ready."""
|
|
367
|
+
return self._initialized and self.session_id is not None
|
|
368
|
+
|
|
304
369
|
async def get_tools(self) -> List[Dict[str, Any]]:
|
|
305
|
-
"""Get tools
|
|
370
|
+
"""Get list of available tools from the server."""
|
|
306
371
|
if not self._initialized:
|
|
307
372
|
logger.error("Cannot get tools: transport not initialized")
|
|
308
373
|
return []
|
|
309
374
|
|
|
375
|
+
start_time = time.time()
|
|
310
376
|
try:
|
|
311
377
|
response = await self._send_request("tools/list", {})
|
|
312
378
|
|
|
@@ -315,7 +381,11 @@ class SSETransport(MCPBaseTransport):
|
|
|
315
381
|
return []
|
|
316
382
|
|
|
317
383
|
tools = response.get('result', {}).get('tools', [])
|
|
318
|
-
|
|
384
|
+
|
|
385
|
+
if self.enable_metrics:
|
|
386
|
+
response_time = time.time() - start_time
|
|
387
|
+
logger.debug("Retrieved %d tools in %.3fs", len(tools), response_time)
|
|
388
|
+
|
|
319
389
|
return tools
|
|
320
390
|
|
|
321
391
|
except Exception as e:
|
|
@@ -324,15 +394,19 @@ class SSETransport(MCPBaseTransport):
|
|
|
324
394
|
|
|
325
395
|
async def call_tool(self, tool_name: str, arguments: Dict[str, Any],
|
|
326
396
|
timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
327
|
-
"""
|
|
397
|
+
"""Execute a tool with the given arguments."""
|
|
328
398
|
if not self._initialized:
|
|
329
399
|
return {
|
|
330
400
|
"isError": True,
|
|
331
401
|
"error": "Transport not initialized"
|
|
332
402
|
}
|
|
333
403
|
|
|
404
|
+
start_time = time.time()
|
|
405
|
+
if self.enable_metrics:
|
|
406
|
+
self._metrics["total_calls"] += 1 # FIXED: INCREMENT FIRST
|
|
407
|
+
|
|
334
408
|
try:
|
|
335
|
-
logger.debug("Calling tool %s with
|
|
409
|
+
logger.debug("Calling tool '%s' with arguments: %s", tool_name, arguments)
|
|
336
410
|
|
|
337
411
|
response = await self._send_request(
|
|
338
412
|
"tools/call",
|
|
@@ -344,58 +418,57 @@ class SSETransport(MCPBaseTransport):
|
|
|
344
418
|
)
|
|
345
419
|
|
|
346
420
|
if 'error' in response:
|
|
421
|
+
if self.enable_metrics:
|
|
422
|
+
self._update_metrics(time.time() - start_time, False)
|
|
423
|
+
|
|
347
424
|
return {
|
|
348
425
|
"isError": True,
|
|
349
426
|
"error": response['error'].get('message', 'Unknown error')
|
|
350
427
|
}
|
|
351
428
|
|
|
352
|
-
# Extract result
|
|
429
|
+
# Extract and normalize result using base class method
|
|
353
430
|
result = response.get('result', {})
|
|
431
|
+
normalized_result = self._normalize_mcp_response({"result": result})
|
|
354
432
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
content = result['content']
|
|
358
|
-
if isinstance(content, list) and len(content) == 1:
|
|
359
|
-
content_item = content[0]
|
|
360
|
-
if isinstance(content_item, dict) and content_item.get('type') == 'text':
|
|
361
|
-
text_content = content_item.get('text', '')
|
|
362
|
-
try:
|
|
363
|
-
# Try to parse as JSON
|
|
364
|
-
parsed_content = json.loads(text_content)
|
|
365
|
-
return {
|
|
366
|
-
"isError": False,
|
|
367
|
-
"content": parsed_content
|
|
368
|
-
}
|
|
369
|
-
except json.JSONDecodeError:
|
|
370
|
-
return {
|
|
371
|
-
"isError": False,
|
|
372
|
-
"content": text_content
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return {
|
|
376
|
-
"isError": False,
|
|
377
|
-
"content": content
|
|
378
|
-
}
|
|
433
|
+
if self.enable_metrics:
|
|
434
|
+
self._update_metrics(time.time() - start_time, True)
|
|
379
435
|
|
|
380
|
-
return
|
|
381
|
-
"isError": False,
|
|
382
|
-
"content": result
|
|
383
|
-
}
|
|
436
|
+
return normalized_result
|
|
384
437
|
|
|
385
438
|
except asyncio.TimeoutError:
|
|
439
|
+
if self.enable_metrics:
|
|
440
|
+
self._update_metrics(time.time() - start_time, False)
|
|
441
|
+
|
|
386
442
|
return {
|
|
387
443
|
"isError": True,
|
|
388
444
|
"error": "Tool execution timed out"
|
|
389
445
|
}
|
|
390
446
|
except Exception as e:
|
|
391
|
-
|
|
447
|
+
if self.enable_metrics:
|
|
448
|
+
self._update_metrics(time.time() - start_time, False)
|
|
449
|
+
|
|
450
|
+
logger.error("Error calling tool '%s': %s", tool_name, e)
|
|
392
451
|
return {
|
|
393
452
|
"isError": True,
|
|
394
453
|
"error": str(e)
|
|
395
454
|
}
|
|
396
455
|
|
|
456
|
+
def _update_metrics(self, response_time: float, success: bool) -> None:
|
|
457
|
+
"""Update performance metrics."""
|
|
458
|
+
if success:
|
|
459
|
+
self._metrics["successful_calls"] += 1
|
|
460
|
+
else:
|
|
461
|
+
self._metrics["failed_calls"] += 1
|
|
462
|
+
|
|
463
|
+
self._metrics["total_time"] += response_time
|
|
464
|
+
# FIXED: Only calculate average if we have total calls
|
|
465
|
+
if self._metrics["total_calls"] > 0:
|
|
466
|
+
self._metrics["avg_response_time"] = (
|
|
467
|
+
self._metrics["total_time"] / self._metrics["total_calls"]
|
|
468
|
+
)
|
|
469
|
+
|
|
397
470
|
async def list_resources(self) -> Dict[str, Any]:
|
|
398
|
-
"""List resources."""
|
|
471
|
+
"""List available resources from the server."""
|
|
399
472
|
if not self._initialized:
|
|
400
473
|
return {}
|
|
401
474
|
|
|
@@ -405,11 +478,12 @@ class SSETransport(MCPBaseTransport):
|
|
|
405
478
|
logger.debug("Resources not supported: %s", response['error'])
|
|
406
479
|
return {}
|
|
407
480
|
return response.get('result', {})
|
|
408
|
-
except Exception:
|
|
481
|
+
except Exception as e:
|
|
482
|
+
logger.debug("Error listing resources: %s", e)
|
|
409
483
|
return {}
|
|
410
484
|
|
|
411
485
|
async def list_prompts(self) -> Dict[str, Any]:
|
|
412
|
-
"""List prompts."""
|
|
486
|
+
"""List available prompts from the server."""
|
|
413
487
|
if not self._initialized:
|
|
414
488
|
return {}
|
|
415
489
|
|
|
@@ -419,59 +493,104 @@ class SSETransport(MCPBaseTransport):
|
|
|
419
493
|
logger.debug("Prompts not supported: %s", response['error'])
|
|
420
494
|
return {}
|
|
421
495
|
return response.get('result', {})
|
|
422
|
-
except Exception:
|
|
496
|
+
except Exception as e:
|
|
497
|
+
logger.debug("Error listing prompts: %s", e)
|
|
423
498
|
return {}
|
|
424
499
|
|
|
425
500
|
async def close(self) -> None:
|
|
426
|
-
"""Close the transport."""
|
|
501
|
+
"""Close the transport and clean up resources."""
|
|
502
|
+
if not self._initialized:
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
# Log final metrics
|
|
506
|
+
if self.enable_metrics and self._metrics["total_calls"] > 0:
|
|
507
|
+
logger.debug(
|
|
508
|
+
"SSE transport closing - Total calls: %d, Success rate: %.1f%%, Avg response time: %.3fs",
|
|
509
|
+
self._metrics["total_calls"],
|
|
510
|
+
(self._metrics["successful_calls"] / self._metrics["total_calls"] * 100),
|
|
511
|
+
self._metrics["avg_response_time"]
|
|
512
|
+
)
|
|
513
|
+
|
|
427
514
|
await self._cleanup()
|
|
428
515
|
|
|
429
516
|
async def _cleanup(self) -> None:
|
|
430
|
-
"""Clean up resources."""
|
|
431
|
-
|
|
517
|
+
"""Clean up all resources and reset state."""
|
|
518
|
+
# Cancel SSE processing task
|
|
519
|
+
if self.sse_task and not self.sse_task.done():
|
|
432
520
|
self.sse_task.cancel()
|
|
433
521
|
try:
|
|
434
522
|
await self.sse_task
|
|
435
523
|
except asyncio.CancelledError:
|
|
436
524
|
pass
|
|
437
525
|
|
|
526
|
+
# Close SSE stream context
|
|
438
527
|
if self.sse_stream_context:
|
|
439
528
|
try:
|
|
440
529
|
await self.sse_stream_context.__aexit__(None, None, None)
|
|
441
|
-
except Exception:
|
|
442
|
-
|
|
530
|
+
except Exception as e:
|
|
531
|
+
logger.debug("Error closing SSE stream: %s", e)
|
|
443
532
|
|
|
533
|
+
# Close HTTP clients
|
|
444
534
|
if self.stream_client:
|
|
445
535
|
await self.stream_client.aclose()
|
|
446
536
|
|
|
447
537
|
if self.send_client:
|
|
448
538
|
await self.send_client.aclose()
|
|
449
539
|
|
|
540
|
+
# Cancel any pending requests
|
|
541
|
+
for request_id, future in self.pending_requests.items():
|
|
542
|
+
if not future.done():
|
|
543
|
+
future.cancel()
|
|
544
|
+
|
|
545
|
+
# Reset state
|
|
450
546
|
self._initialized = False
|
|
451
547
|
self.session_id = None
|
|
452
548
|
self.message_url = None
|
|
453
549
|
self.pending_requests.clear()
|
|
550
|
+
self.sse_task = None
|
|
551
|
+
self.sse_response = None
|
|
552
|
+
self.sse_stream_context = None
|
|
553
|
+
self.stream_client = None
|
|
554
|
+
self.send_client = None
|
|
454
555
|
|
|
556
|
+
# ------------------------------------------------------------------ #
|
|
557
|
+
# Metrics and monitoring (consistent with other transports) #
|
|
558
|
+
# ------------------------------------------------------------------ #
|
|
559
|
+
def get_metrics(self) -> Dict[str, Any]:
|
|
560
|
+
"""Get performance and connection metrics."""
|
|
561
|
+
return self._metrics.copy()
|
|
562
|
+
|
|
563
|
+
def reset_metrics(self) -> None:
|
|
564
|
+
"""Reset performance metrics."""
|
|
565
|
+
self._metrics = {
|
|
566
|
+
"total_calls": 0,
|
|
567
|
+
"successful_calls": 0,
|
|
568
|
+
"failed_calls": 0,
|
|
569
|
+
"total_time": 0.0,
|
|
570
|
+
"avg_response_time": 0.0,
|
|
571
|
+
"last_ping_time": self._metrics.get("last_ping_time"),
|
|
572
|
+
"initialization_time": self._metrics.get("initialization_time"),
|
|
573
|
+
"session_discoveries": self._metrics.get("session_discoveries", 0),
|
|
574
|
+
"stream_errors": 0
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
# ------------------------------------------------------------------ #
|
|
578
|
+
# Backward compatibility #
|
|
579
|
+
# ------------------------------------------------------------------ #
|
|
455
580
|
def get_streams(self) -> List[tuple]:
|
|
456
|
-
"""
|
|
581
|
+
"""SSE transport doesn't expose raw streams."""
|
|
457
582
|
return []
|
|
458
583
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
584
|
+
# ------------------------------------------------------------------ #
|
|
585
|
+
# Context manager support (now uses base class with fixed error) #
|
|
586
|
+
# ------------------------------------------------------------------ #
|
|
463
587
|
async def __aenter__(self):
|
|
464
|
-
"""Context manager
|
|
588
|
+
"""Context manager entry."""
|
|
465
589
|
success = await self.initialize()
|
|
466
590
|
if not success:
|
|
467
|
-
raise RuntimeError("Failed to initialize
|
|
591
|
+
raise RuntimeError("Failed to initialize SSETransport") # FIXED: message
|
|
468
592
|
return self
|
|
469
593
|
|
|
470
594
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
471
595
|
"""Context manager cleanup."""
|
|
472
|
-
await self.close()
|
|
473
|
-
|
|
474
|
-
def __repr__(self) -> str:
|
|
475
|
-
"""String representation."""
|
|
476
|
-
status = "initialized" if self._initialized else "not initialized"
|
|
477
|
-
return f"SSETransport(status={status}, url={self.url}, session={self.session_id})"
|
|
596
|
+
await self.close()
|