chuk-tool-processor 0.5.2__py3-none-any.whl → 0.6__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/execution/wrappers/retry.py +1 -1
- chuk_tool_processor/mcp/__init__.py +16 -3
- chuk_tool_processor/mcp/setup_mcp_http_streamable.py +119 -0
- chuk_tool_processor/mcp/setup_mcp_sse.py +35 -36
- chuk_tool_processor/mcp/setup_mcp_stdio.py +3 -1
- chuk_tool_processor/mcp/stream_manager.py +157 -130
- chuk_tool_processor/mcp/transport/__init__.py +29 -4
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +497 -0
- chuk_tool_processor/mcp/transport/sse_transport.py +306 -425
- chuk_tool_processor/mcp/transport/stdio_transport.py +119 -276
- {chuk_tool_processor-0.5.2.dist-info → chuk_tool_processor-0.6.dist-info}/METADATA +2 -2
- {chuk_tool_processor-0.5.2.dist-info → chuk_tool_processor-0.6.dist-info}/RECORD +14 -12
- {chuk_tool_processor-0.5.2.dist-info → chuk_tool_processor-0.6.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.5.2.dist-info → chuk_tool_processor-0.6.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
21
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
+
Updated SSE transport using latest chuk-mcp APIs.
|
|
46
43
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
50
|
+
Initialize SSE transport with latest chuk-mcp.
|
|
63
51
|
|
|
64
52
|
Args:
|
|
65
|
-
url:
|
|
53
|
+
url: SSE server URL
|
|
66
54
|
api_key: Optional API key for authentication
|
|
67
|
-
connection_timeout: Timeout for connection
|
|
68
|
-
default_timeout: Default timeout for
|
|
55
|
+
connection_timeout: Timeout for initial connection
|
|
56
|
+
default_timeout: Default timeout for operations
|
|
69
57
|
"""
|
|
70
|
-
self.
|
|
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
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
102
|
-
if
|
|
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
|
-
|
|
83
|
+
logger.info("Initializing SSE transport...")
|
|
149
84
|
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
93
|
+
# Create and enter the context - this should handle the full MCP handshake
|
|
94
|
+
self._sse_context = sse_client(sse_params)
|
|
171
95
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
211
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
142
|
+
logger.debug(f"Error during transport close: {e}")
|
|
143
|
+
finally:
|
|
144
|
+
await self._cleanup()
|
|
273
145
|
|
|
274
|
-
async def
|
|
275
|
-
"""
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
"""
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
return []
|
|
170
|
+
logger.error(f"Ping failed: {e}")
|
|
171
|
+
return False
|
|
322
172
|
|
|
323
|
-
async def
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
logger.debug(f"Calling tool {tool_name} with args: {arguments}")
|
|
424
217
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
298
|
+
return {
|
|
299
|
+
"isError": True,
|
|
300
|
+
"error": error_msg
|
|
301
|
+
}
|
|
475
302
|
|
|
476
|
-
|
|
477
|
-
""
|
|
478
|
-
|
|
479
|
-
return []
|
|
303
|
+
# Handle successful response with result
|
|
304
|
+
if "result" in raw_response:
|
|
305
|
+
result = raw_response["result"]
|
|
480
306
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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})"
|