chuk-tool-processor 0.5.2__py3-none-any.whl → 0.5.4__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,496 +1,377 @@
1
1
  # chuk_tool_processor/mcp/transport/sse_transport.py
2
- """
3
- Proper MCP SSE transport that follows the standard MCP SSE protocol.
4
-
5
- This transport:
6
- 1. Connects to /sse for SSE stream
7
- 2. Listens for 'endpoint' event to get message URL
8
- 3. Sends MCP initialize handshake FIRST
9
- 4. Only then proceeds with tools/list and tool calls
10
- 5. Handles async responses via SSE message events
11
-
12
- FIXED: All hardcoded timeouts are now configurable parameters.
13
- FIXED: Enhanced close method to avoid cancel scope conflicts.
14
- """
15
2
  from __future__ import annotations
16
3
 
17
4
  import asyncio
18
- import contextlib
19
5
  import json
20
- import os
21
- from typing import Any, Dict, List, Optional
22
-
23
- import httpx
6
+ from typing import Dict, Any, List, Optional
7
+ import logging
24
8
 
25
9
  from .base_transport import MCPBaseTransport
26
10
 
27
- # --------------------------------------------------------------------------- #
28
- # Helpers #
29
- # --------------------------------------------------------------------------- #
30
- DEFAULT_TIMEOUT = 30.0 # Default timeout for tool calls
31
- DEFAULT_CONNECTION_TIMEOUT = 10.0 # Default timeout for connection setup
32
- HEADERS_JSON: Dict[str, str] = {"accept": "application/json"}
33
-
34
-
35
- def _url(base: str, path: str) -> str:
36
- """Join *base* and *path* with exactly one slash."""
37
- return f"{base.rstrip('/')}/{path.lstrip('/')}"
11
+ # Import latest chuk-mcp SSE transport
12
+ try:
13
+ from chuk_mcp.transports.sse import sse_client
14
+ from chuk_mcp.transports.sse.parameters import SSEParameters
15
+ from chuk_mcp.protocol.messages import (
16
+ send_initialize,
17
+ send_ping,
18
+ send_tools_list,
19
+ send_tools_call,
20
+ )
21
+ HAS_SSE_SUPPORT = True
22
+ except ImportError:
23
+ HAS_SSE_SUPPORT = False
24
+
25
+ # Import optional resource and prompt support
26
+ try:
27
+ from chuk_mcp.protocol.messages import (
28
+ send_resources_list,
29
+ send_resources_read,
30
+ send_prompts_list,
31
+ send_prompts_get,
32
+ )
33
+ HAS_RESOURCES_PROMPTS = True
34
+ except ImportError:
35
+ HAS_RESOURCES_PROMPTS = False
36
+
37
+ logger = logging.getLogger(__name__)
38
38
 
39
39
 
40
- # --------------------------------------------------------------------------- #
41
- # Transport #
42
- # --------------------------------------------------------------------------- #
43
40
  class SSETransport(MCPBaseTransport):
44
41
  """
45
- Proper MCP SSE transport that follows the standard protocol:
42
+ Updated SSE transport using latest chuk-mcp APIs.
46
43
 
47
- 1. GET /sse Establishes SSE connection
48
- 2. Waits for 'endpoint' event → Gets message URL
49
- 3. Sends MCP initialize handshake → Establishes session
50
- 4. POST to message URL → Sends tool calls
51
- 5. Waits for async responses via SSE message events
44
+ Supports all required abstract methods and provides full MCP functionality.
52
45
  """
53
46
 
54
- def __init__(
55
- self,
56
- url: str,
57
- api_key: Optional[str] = None,
58
- connection_timeout: float = DEFAULT_CONNECTION_TIMEOUT,
59
- default_timeout: float = DEFAULT_TIMEOUT
60
- ) -> None:
47
+ def __init__(self, url: str, api_key: Optional[str] = None,
48
+ connection_timeout: float = 30.0, default_timeout: float = 30.0):
61
49
  """
62
- Initialize SSE Transport with configurable timeouts.
50
+ Initialize SSE transport with latest chuk-mcp.
63
51
 
64
52
  Args:
65
- url: Base URL for the MCP server
53
+ url: SSE server URL
66
54
  api_key: Optional API key for authentication
67
- connection_timeout: Timeout for connection setup (default: 10.0s)
68
- default_timeout: Default timeout for tool calls (default: 30.0s)
55
+ connection_timeout: Timeout for initial connection
56
+ default_timeout: Default timeout for operations
69
57
  """
70
- self.base_url = url.rstrip("/")
58
+ self.url = url
71
59
  self.api_key = api_key
72
60
  self.connection_timeout = connection_timeout
73
61
  self.default_timeout = default_timeout
74
62
 
75
- # NEW: Auto-detect bearer token from environment if not provided
76
- if not self.api_key:
77
- bearer_token = os.getenv("MCP_BEARER_TOKEN")
78
- if bearer_token:
79
- self.api_key = bearer_token
80
- print(f"🔑 Using bearer token from MCP_BEARER_TOKEN environment variable")
81
-
82
- # httpx client (None until initialise)
83
- self._client: httpx.AsyncClient | None = None
84
- self.session: httpx.AsyncClient | None = None
85
-
86
- # MCP SSE state
87
- self._message_url: Optional[str] = None
88
- self._session_id: Optional[str] = None
89
- self._sse_task: Optional[asyncio.Task] = None
90
- self._connected = asyncio.Event()
91
- self._initialized = asyncio.Event() # NEW: Track MCP initialization
63
+ # State tracking
64
+ self._sse_context = None
65
+ self._read_stream = None
66
+ self._write_stream = None
67
+ self._initialized = False
92
68
 
93
- # Async message handling
94
- self._pending_requests: Dict[str, asyncio.Future] = {}
95
- self._message_lock = asyncio.Lock()
69
+ if not HAS_SSE_SUPPORT:
70
+ logger.warning("SSE transport not available - operations will fail")
96
71
 
97
- # ------------------------------------------------------------------ #
98
- # Life-cycle #
99
- # ------------------------------------------------------------------ #
100
72
  async def initialize(self) -> bool:
101
- """Initialize the MCP SSE transport."""
102
- if self._client:
73
+ """Initialize using latest chuk-mcp sse_client."""
74
+ if not HAS_SSE_SUPPORT:
75
+ logger.error("SSE transport not available in chuk-mcp")
76
+ return False
77
+
78
+ if self._initialized:
79
+ logger.warning("Transport already initialized")
103
80
  return True
104
-
105
- headers = {}
106
- if self.api_key:
107
- # NEW: Handle both "Bearer token" and just "token" formats
108
- if self.api_key.startswith("Bearer "):
109
- headers["Authorization"] = self.api_key
110
- else:
111
- headers["Authorization"] = f"Bearer {self.api_key}"
112
- print(f"🔑 Added Authorization header to httpx client")
113
-
114
- self._client = httpx.AsyncClient(
115
- headers=headers,
116
- timeout=self.default_timeout, # Use configurable timeout
117
- )
118
- self.session = self._client
119
-
120
- # Start SSE connection and wait for endpoint
121
- self._sse_task = asyncio.create_task(self._handle_sse_connection())
122
-
123
- try:
124
- # FIXED: Use configurable connection timeout instead of hardcoded 10.0
125
- await asyncio.wait_for(self._connected.wait(), timeout=self.connection_timeout)
126
81
 
127
- # NEW: Send MCP initialize handshake
128
- if await self._initialize_mcp_session():
129
- return True
130
- else:
131
- print("❌ MCP initialization failed")
132
- return False
133
-
134
- except asyncio.TimeoutError:
135
- print("❌ Timeout waiting for SSE endpoint event")
136
- return False
137
- except Exception as e:
138
- print(f"❌ SSE initialization failed: {e}")
139
- return False
140
-
141
- async def _initialize_mcp_session(self) -> bool:
142
- """Send the required MCP initialize handshake."""
143
- if not self._message_url:
144
- print("❌ No message URL available for initialization")
145
- return False
146
-
147
82
  try:
148
- print("🔄 Sending MCP initialize handshake...")
83
+ logger.info("Initializing SSE transport...")
149
84
 
150
- # Required MCP initialize message
151
- init_message = {
152
- "jsonrpc": "2.0",
153
- "id": "initialize",
154
- "method": "initialize",
155
- "params": {
156
- "protocolVersion": "2024-11-05",
157
- "capabilities": {
158
- "tools": {},
159
- "resources": {},
160
- "prompts": {},
161
- "sampling": {}
162
- },
163
- "clientInfo": {
164
- "name": "chuk-tool-processor",
165
- "version": "1.0.0"
166
- }
167
- }
168
- }
85
+ # Create SSE parameters for latest chuk-mcp
86
+ sse_params = SSEParameters(
87
+ url=self.url,
88
+ timeout=self.connection_timeout,
89
+ auto_reconnect=True,
90
+ max_reconnect_attempts=3
91
+ )
169
92
 
170
- response = await self._send_message(init_message)
93
+ # Create and enter the context - this should handle the full MCP handshake
94
+ self._sse_context = sse_client(sse_params)
171
95
 
172
- if "result" in response:
173
- server_info = response["result"]
174
- print(f"✅ MCP initialized: {server_info.get('serverInfo', {}).get('name', 'Unknown Server')}")
175
-
176
- # Send initialized notification (required by MCP spec)
177
- notification = {
178
- "jsonrpc": "2.0",
179
- "method": "notifications/initialized"
180
- }
181
-
182
- # Send notification (don't wait for response)
183
- await self._send_notification(notification)
184
- self._initialized.set()
96
+ # The sse_client should handle the entire initialization process
97
+ logger.debug("Establishing SSE connection and MCP handshake...")
98
+ self._read_stream, self._write_stream = await asyncio.wait_for(
99
+ self._sse_context.__aenter__(),
100
+ timeout=self.connection_timeout
101
+ )
102
+
103
+ # At this point, chuk-mcp should have already completed the MCP initialization
104
+ # Let's verify the connection works with a simple ping
105
+ logger.debug("Verifying connection with ping...")
106
+ ping_success = await asyncio.wait_for(
107
+ send_ping(self._read_stream, self._write_stream),
108
+ timeout=5.0
109
+ )
110
+
111
+ if ping_success:
112
+ self._initialized = True
113
+ logger.info("SSE transport initialized successfully")
185
114
  return True
186
115
  else:
187
- print(f" MCP initialization failed: {response}")
188
- return False
189
-
190
- except Exception as e:
191
- print(f"❌ MCP initialization error: {e}")
192
- return False
116
+ logger.warning("SSE connection established but ping failed")
117
+ # Still consider it initialized since connection was established
118
+ self._initialized = True
119
+ return True
193
120
 
194
- async def _send_notification(self, notification: Dict[str, Any]) -> None:
195
- """Send a JSON-RPC notification (no response expected)."""
196
- if not self._client or not self._message_url:
197
- return
198
-
199
- try:
200
- headers = {"Content-Type": "application/json"}
201
- await self._client.post(
202
- self._message_url,
203
- json=notification,
204
- headers=headers
205
- )
121
+ except asyncio.TimeoutError:
122
+ logger.error(f"SSE initialization timed out after {self.connection_timeout}s")
123
+ logger.error("This may indicate the server is not responding to MCP initialization")
124
+ await self._cleanup()
125
+ return False
206
126
  except Exception as e:
207
- print(f"⚠️ Failed to send notification: {e}")
127
+ logger.error(f"Error initializing SSE transport: {e}", exc_info=True)
128
+ await self._cleanup()
129
+ return False
208
130
 
209
131
  async def close(self) -> None:
210
- """Minimal close method with zero async operations."""
211
- # Just clear references - no async operations at all
212
- self._context_stack = None
213
- self.read_stream = None
214
- self.write_stream = None
215
- # ------------------------------------------------------------------ #
216
- # SSE Connection Handler #
217
- # ------------------------------------------------------------------ #
218
- async def _handle_sse_connection(self) -> None:
219
- """Handle the SSE connection and extract the endpoint URL."""
220
- if not self._client:
132
+ """Close the SSE transport properly."""
133
+ if not self._initialized:
221
134
  return
222
-
223
- try:
224
- headers = {
225
- "Accept": "text/event-stream",
226
- "Cache-Control": "no-cache"
227
- }
228
135
 
229
- async with self._client.stream(
230
- "GET", f"{self.base_url}/sse", headers=headers
231
- ) as response:
232
- response.raise_for_status()
136
+ try:
137
+ if self._sse_context is not None:
138
+ await self._sse_context.__aexit__(None, None, None)
139
+ logger.debug("SSE context closed")
233
140
 
234
- async for line in response.aiter_lines():
235
- if not line:
236
- continue
237
-
238
- # Parse SSE events
239
- if line.startswith("event: "):
240
- event_type = line[7:].strip()
241
-
242
- elif line.startswith("data: ") and 'event_type' in locals():
243
- data = line[6:].strip()
244
-
245
- if event_type == "endpoint":
246
- # Got the endpoint URL for messages - construct full URL
247
- # NEW: Handle URLs that need trailing slash fix
248
- if "/messages?" in data and "/messages/?" not in data:
249
- data = data.replace("/messages?", "/messages/?", 1)
250
- print(f"🔧 Fixed URL redirect: added trailing slash")
251
-
252
- self._message_url = f"{self.base_url}{data}"
253
-
254
- # Extract session_id if present
255
- if "session_id=" in data:
256
- self._session_id = data.split("session_id=")[1].split("&")[0]
257
-
258
- print(f"✅ Got message endpoint: {self._message_url}")
259
- self._connected.set()
260
-
261
- elif event_type == "message":
262
- # Handle incoming JSON-RPC responses
263
- try:
264
- message = json.loads(data)
265
- await self._handle_incoming_message(message)
266
- except json.JSONDecodeError:
267
- print(f"❌ Failed to parse message: {data}")
268
-
269
- except asyncio.CancelledError:
270
- pass
271
141
  except Exception as e:
272
- print(f" SSE connection failed: {e}")
142
+ logger.debug(f"Error during transport close: {e}")
143
+ finally:
144
+ await self._cleanup()
273
145
 
274
- async def _handle_incoming_message(self, message: Dict[str, Any]) -> None:
275
- """Handle incoming JSON-RPC response messages."""
276
- message_id = message.get("id")
277
- if message_id and message_id in self._pending_requests:
278
- # Complete the pending request
279
- future = self._pending_requests.pop(message_id)
280
- if not future.done():
281
- future.set_result(message)
146
+ async def _cleanup(self) -> None:
147
+ """Clean up internal state."""
148
+ self._sse_context = None
149
+ self._read_stream = None
150
+ self._write_stream = None
151
+ self._initialized = False
282
152
 
283
- # ------------------------------------------------------------------ #
284
- # MCP Protocol Methods #
285
- # ------------------------------------------------------------------ #
286
153
  async def send_ping(self) -> bool:
287
- """Test if we have a working and initialized connection."""
288
- return self._message_url is not None and self._initialized.is_set()
289
-
290
- async def get_tools(self) -> List[Dict[str, Any]]:
291
- """Get available tools using tools/list."""
292
- # NEW: Wait for initialization before proceeding
293
- if not self._initialized.is_set():
294
- print("⏳ Waiting for MCP initialization...")
295
- try:
296
- # FIXED: Use configurable connection timeout instead of hardcoded 10.0
297
- await asyncio.wait_for(self._initialized.wait(), timeout=self.connection_timeout)
298
- except asyncio.TimeoutError:
299
- print("❌ Timeout waiting for MCP initialization")
300
- return []
154
+ """Send ping using latest chuk-mcp."""
155
+ if not self._initialized:
156
+ logger.error("Cannot send ping: transport not initialized")
157
+ return False
301
158
 
302
- if not self._message_url:
303
- return []
304
-
305
159
  try:
306
- message = {
307
- "jsonrpc": "2.0",
308
- "id": "tools_list",
309
- "method": "tools/list",
310
- "params": {}
311
- }
312
-
313
- response = await self._send_message(message)
314
-
315
- if "result" in response and "tools" in response["result"]:
316
- return response["result"]["tools"]
317
-
160
+ result = await asyncio.wait_for(
161
+ send_ping(self._read_stream, self._write_stream),
162
+ timeout=self.default_timeout
163
+ )
164
+ logger.debug(f"Ping result: {result}")
165
+ return bool(result)
166
+ except asyncio.TimeoutError:
167
+ logger.error("Ping timed out")
168
+ return False
318
169
  except Exception as e:
319
- print(f" Failed to get tools: {e}")
320
-
321
- return []
170
+ logger.error(f"Ping failed: {e}")
171
+ return False
322
172
 
323
- async def call_tool(
324
- self,
325
- tool_name: str,
326
- arguments: Dict[str, Any],
327
- timeout: Optional[float] = None
328
- ) -> Dict[str, Any]:
329
- """
330
- Execute a tool call using the MCP protocol.
173
+ async def get_tools(self) -> List[Dict[str, Any]]:
174
+ """Get tools list using latest chuk-mcp."""
175
+ if not self._initialized:
176
+ logger.error("Cannot get tools: transport not initialized")
177
+ return []
331
178
 
332
- Args:
333
- tool_name: Name of the tool to call
334
- arguments: Arguments to pass to the tool
335
- timeout: Optional timeout for this specific call
336
-
337
- Returns:
338
- Dictionary containing the tool result or error
339
- """
340
- # NEW: Ensure initialization before tool calls
341
- if not self._initialized.is_set():
342
- return {"isError": True, "error": "SSE transport not implemented"}
343
-
344
- if not self._message_url:
345
- return {"isError": True, "error": "No message endpoint available"}
346
-
347
179
  try:
348
- message = {
349
- "jsonrpc": "2.0",
350
- "id": f"call_{tool_name}",
351
- "method": "tools/call",
352
- "params": {
353
- "name": tool_name,
354
- "arguments": arguments
355
- }
356
- }
357
-
358
- # Use provided timeout or fall back to default
359
- effective_timeout = timeout if timeout is not None else self.default_timeout
360
- response = await self._send_message(message, timeout=effective_timeout)
361
-
362
- # Process MCP response
363
- if "error" in response:
364
- return {
365
- "isError": True,
366
- "error": response["error"].get("message", "Unknown error")
367
- }
180
+ tools_response = await asyncio.wait_for(
181
+ send_tools_list(self._read_stream, self._write_stream),
182
+ timeout=self.default_timeout
183
+ )
368
184
 
369
- if "result" in response:
370
- result = response["result"]
371
-
372
- # Handle MCP tool response format
373
- if "content" in result:
374
- # Extract content from MCP format
375
- content = result["content"]
376
- if isinstance(content, list) and content:
377
- # Take first content item
378
- first_content = content[0]
379
- if isinstance(first_content, dict) and "text" in first_content:
380
- return {"isError": False, "content": first_content["text"]}
381
-
382
- return {"isError": False, "content": content}
383
-
384
- # Direct result
385
- return {"isError": False, "content": result}
185
+ # Normalize response
186
+ if isinstance(tools_response, dict):
187
+ tools = tools_response.get("tools", [])
188
+ elif isinstance(tools_response, list):
189
+ tools = tools_response
190
+ else:
191
+ logger.warning(f"Unexpected tools response type: {type(tools_response)}")
192
+ tools = []
386
193
 
387
- return {"isError": True, "error": "No result in response"}
194
+ logger.debug(f"Retrieved {len(tools)} tools")
195
+ return tools
388
196
 
197
+ except asyncio.TimeoutError:
198
+ logger.error("Get tools timed out")
199
+ return []
389
200
  except Exception as e:
390
- return {"isError": True, "error": str(e)}
391
-
392
- async def _send_message(
393
- self,
394
- message: Dict[str, Any],
395
- timeout: Optional[float] = None
396
- ) -> Dict[str, Any]:
397
- """
398
- Send a JSON-RPC message to the server and wait for async response.
399
-
400
- Args:
401
- message: JSON-RPC message to send
402
- timeout: Optional timeout for this specific message
403
-
404
- Returns:
405
- Response message from the server
406
- """
407
- if not self._client or not self._message_url:
408
- raise RuntimeError("Transport not properly initialized")
409
-
410
- message_id = message.get("id")
411
- if not message_id:
412
- raise ValueError("Message must have an ID")
201
+ logger.error(f"Error getting tools: {e}")
202
+ return []
413
203
 
414
- # Use provided timeout or fall back to default
415
- effective_timeout = timeout if timeout is not None else self.default_timeout
204
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any],
205
+ timeout: Optional[float] = None) -> Dict[str, Any]:
206
+ """Call tool using latest chuk-mcp."""
207
+ if not self._initialized:
208
+ return {
209
+ "isError": True,
210
+ "error": "Transport not initialized"
211
+ }
416
212
 
417
- # Create a future for this request
418
- future = asyncio.Future()
419
- async with self._message_lock:
420
- self._pending_requests[message_id] = future
213
+ tool_timeout = timeout or self.default_timeout
421
214
 
422
215
  try:
423
- headers = {"Content-Type": "application/json"}
216
+ logger.debug(f"Calling tool {tool_name} with args: {arguments}")
424
217
 
425
- # Send the request
426
- response = await self._client.post(
427
- self._message_url,
428
- json=message,
429
- headers=headers
218
+ raw_response = await asyncio.wait_for(
219
+ send_tools_call(
220
+ self._read_stream,
221
+ self._write_stream,
222
+ tool_name,
223
+ arguments
224
+ ),
225
+ timeout=tool_timeout
430
226
  )
431
227
 
432
- # Check if server accepted the request
433
- if response.status_code == 202:
434
- # Server accepted - wait for async response via SSE
435
- try:
436
- # FIXED: Use effective_timeout instead of hardcoded 30.0
437
- response_message = await asyncio.wait_for(future, timeout=effective_timeout)
438
- return response_message
439
- except asyncio.TimeoutError:
440
- raise RuntimeError(f"Timeout waiting for response to message {message_id}")
441
- else:
442
- # Immediate response - parse and return
443
- response.raise_for_status()
444
- return response.json()
445
-
446
- finally:
447
- # Clean up pending request
448
- async with self._message_lock:
449
- self._pending_requests.pop(message_id, None)
228
+ logger.debug(f"Tool {tool_name} raw response: {raw_response}")
229
+ return self._normalize_tool_response(raw_response)
450
230
 
451
- # ------------------------------------------------------------------ #
452
- # Additional MCP methods #
453
- # ------------------------------------------------------------------ #
454
- async def list_resources(self) -> List[Dict[str, Any]]:
455
- """List available resources."""
456
- if not self._initialized.is_set() or not self._message_url:
457
- return []
231
+ except asyncio.TimeoutError:
232
+ logger.error(f"Tool {tool_name} timed out after {tool_timeout}s")
233
+ return {
234
+ "isError": True,
235
+ "error": f"Tool execution timed out after {tool_timeout}s"
236
+ }
237
+ except Exception as e:
238
+ logger.error(f"Error calling tool {tool_name}: {e}")
239
+ return {
240
+ "isError": True,
241
+ "error": f"Tool execution failed: {str(e)}"
242
+ }
243
+
244
+ async def list_resources(self) -> Dict[str, Any]:
245
+ """List resources using latest chuk-mcp."""
246
+ if not HAS_RESOURCES_PROMPTS:
247
+ logger.debug("Resources/prompts not available in chuk-mcp")
248
+ return {}
458
249
 
250
+ if not self._initialized:
251
+ return {}
252
+
459
253
  try:
460
- message = {
461
- "jsonrpc": "2.0",
462
- "id": "resources_list",
463
- "method": "resources/list",
464
- "params": {}
465
- }
254
+ response = await asyncio.wait_for(
255
+ send_resources_list(self._read_stream, self._write_stream),
256
+ timeout=self.default_timeout
257
+ )
258
+ return response if isinstance(response, dict) else {}
259
+ except asyncio.TimeoutError:
260
+ logger.error("List resources timed out")
261
+ return {}
262
+ except Exception as e:
263
+ logger.debug(f"Error listing resources: {e}")
264
+ return {}
265
+
266
+ async def list_prompts(self) -> Dict[str, Any]:
267
+ """List prompts using latest chuk-mcp."""
268
+ if not HAS_RESOURCES_PROMPTS:
269
+ logger.debug("Resources/prompts not available in chuk-mcp")
270
+ return {}
466
271
 
467
- response = await self._send_message(message)
468
- if "result" in response and "resources" in response["result"]:
469
- return response["result"]["resources"]
470
-
471
- except Exception:
472
- pass
272
+ if not self._initialized:
273
+ return {}
274
+
275
+ try:
276
+ response = await asyncio.wait_for(
277
+ send_prompts_list(self._read_stream, self._write_stream),
278
+ timeout=self.default_timeout
279
+ )
280
+ return response if isinstance(response, dict) else {}
281
+ except asyncio.TimeoutError:
282
+ logger.error("List prompts timed out")
283
+ return {}
284
+ except Exception as e:
285
+ logger.debug(f"Error listing prompts: {e}")
286
+ return {}
287
+
288
+ def _normalize_tool_response(self, raw_response: Dict[str, Any]) -> Dict[str, Any]:
289
+ """Normalize response for backward compatibility."""
290
+ # Handle explicit error in response
291
+ if "error" in raw_response:
292
+ error_info = raw_response["error"]
293
+ if isinstance(error_info, dict):
294
+ error_msg = error_info.get("message", "Unknown error")
295
+ else:
296
+ error_msg = str(error_info)
473
297
 
474
- return []
298
+ return {
299
+ "isError": True,
300
+ "error": error_msg
301
+ }
475
302
 
476
- async def list_prompts(self) -> List[Dict[str, Any]]:
477
- """List available prompts."""
478
- if not self._initialized.is_set() or not self._message_url:
479
- return []
303
+ # Handle successful response with result
304
+ if "result" in raw_response:
305
+ result = raw_response["result"]
480
306
 
481
- try:
482
- message = {
483
- "jsonrpc": "2.0",
484
- "id": "prompts_list",
485
- "method": "prompts/list",
486
- "params": {}
307
+ if isinstance(result, dict) and "content" in result:
308
+ return {
309
+ "isError": False,
310
+ "content": self._extract_content(result["content"])
311
+ }
312
+ else:
313
+ return {
314
+ "isError": False,
315
+ "content": result
316
+ }
317
+
318
+ # Handle direct content-based response
319
+ if "content" in raw_response:
320
+ return {
321
+ "isError": False,
322
+ "content": self._extract_content(raw_response["content"])
487
323
  }
488
-
489
- response = await self._send_message(message)
490
- if "result" in response and "prompts" in response["result"]:
491
- return response["result"]["prompts"]
492
-
493
- except Exception:
494
- pass
495
-
496
- return []
324
+
325
+ # Fallback
326
+ return {
327
+ "isError": False,
328
+ "content": raw_response
329
+ }
330
+
331
+ def _extract_content(self, content_list: Any) -> Any:
332
+ """Extract content from MCP content format."""
333
+ if not isinstance(content_list, list) or not content_list:
334
+ return content_list
335
+
336
+ # Handle single content item
337
+ if len(content_list) == 1:
338
+ content_item = content_list[0]
339
+ if isinstance(content_item, dict):
340
+ if content_item.get("type") == "text":
341
+ text_content = content_item.get("text", "")
342
+ # Try to parse JSON, fall back to plain text
343
+ try:
344
+ return json.loads(text_content)
345
+ except json.JSONDecodeError:
346
+ return text_content
347
+ else:
348
+ return content_item
349
+
350
+ # Multiple content items
351
+ return content_list
352
+
353
+ def get_streams(self) -> List[tuple]:
354
+ """Provide streams for backward compatibility."""
355
+ if self._initialized and self._read_stream and self._write_stream:
356
+ return [(self._read_stream, self._write_stream)]
357
+ return []
358
+
359
+ def is_connected(self) -> bool:
360
+ """Check connection status."""
361
+ return self._initialized and self._read_stream is not None and self._write_stream is not None
362
+
363
+ async def __aenter__(self):
364
+ """Context manager support."""
365
+ success = await self.initialize()
366
+ if not success:
367
+ raise RuntimeError("Failed to initialize SSE transport")
368
+ return self
369
+
370
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
371
+ """Context manager cleanup."""
372
+ await self.close()
373
+
374
+ def __repr__(self) -> str:
375
+ """String representation for debugging."""
376
+ status = "initialized" if self._initialized else "not initialized"
377
+ return f"SSETransport(status={status}, url={self.url})"