chuk-tool-processor 0.6.12__py3-none-any.whl → 0.6.13__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/core/__init__.py +1 -1
- chuk_tool_processor/core/exceptions.py +10 -4
- chuk_tool_processor/core/processor.py +97 -97
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +142 -150
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +200 -205
- chuk_tool_processor/execution/tool_executor.py +82 -84
- chuk_tool_processor/execution/wrappers/caching.py +102 -103
- chuk_tool_processor/execution/wrappers/rate_limiting.py +45 -42
- chuk_tool_processor/execution/wrappers/retry.py +23 -25
- chuk_tool_processor/logging/__init__.py +23 -17
- chuk_tool_processor/logging/context.py +40 -45
- chuk_tool_processor/logging/formatter.py +22 -21
- chuk_tool_processor/logging/helpers.py +24 -38
- chuk_tool_processor/logging/metrics.py +11 -13
- chuk_tool_processor/mcp/__init__.py +8 -12
- chuk_tool_processor/mcp/mcp_tool.py +124 -112
- chuk_tool_processor/mcp/register_mcp_tools.py +17 -17
- chuk_tool_processor/mcp/setup_mcp_http_streamable.py +11 -13
- chuk_tool_processor/mcp/setup_mcp_sse.py +11 -13
- chuk_tool_processor/mcp/setup_mcp_stdio.py +7 -9
- chuk_tool_processor/mcp/stream_manager.py +168 -204
- chuk_tool_processor/mcp/transport/__init__.py +4 -4
- chuk_tool_processor/mcp/transport/base_transport.py +43 -58
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +145 -163
- chuk_tool_processor/mcp/transport/sse_transport.py +217 -255
- chuk_tool_processor/mcp/transport/stdio_transport.py +171 -189
- chuk_tool_processor/models/__init__.py +1 -1
- chuk_tool_processor/models/execution_strategy.py +16 -21
- chuk_tool_processor/models/streaming_tool.py +28 -25
- chuk_tool_processor/models/tool_call.py +19 -34
- chuk_tool_processor/models/tool_export_mixin.py +22 -8
- chuk_tool_processor/models/tool_result.py +40 -77
- chuk_tool_processor/models/validated_tool.py +14 -16
- chuk_tool_processor/plugins/__init__.py +1 -1
- chuk_tool_processor/plugins/discovery.py +10 -10
- chuk_tool_processor/plugins/parsers/__init__.py +1 -1
- chuk_tool_processor/plugins/parsers/base.py +1 -2
- chuk_tool_processor/plugins/parsers/function_call_tool.py +13 -8
- chuk_tool_processor/plugins/parsers/json_tool.py +4 -3
- chuk_tool_processor/plugins/parsers/openai_tool.py +12 -7
- chuk_tool_processor/plugins/parsers/xml_tool.py +4 -4
- chuk_tool_processor/registry/__init__.py +12 -12
- chuk_tool_processor/registry/auto_register.py +22 -30
- chuk_tool_processor/registry/decorators.py +127 -129
- chuk_tool_processor/registry/interface.py +26 -23
- chuk_tool_processor/registry/metadata.py +27 -22
- chuk_tool_processor/registry/provider.py +17 -18
- chuk_tool_processor/registry/providers/__init__.py +16 -19
- chuk_tool_processor/registry/providers/memory.py +18 -25
- chuk_tool_processor/registry/tool_export.py +42 -51
- chuk_tool_processor/utils/validation.py +15 -16
- {chuk_tool_processor-0.6.12.dist-info → chuk_tool_processor-0.6.13.dist-info}/METADATA +1 -1
- chuk_tool_processor-0.6.13.dist-info/RECORD +60 -0
- chuk_tool_processor-0.6.12.dist-info/RECORD +0 -60
- {chuk_tool_processor-0.6.12.dist-info → chuk_tool_processor-0.6.13.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.6.12.dist-info → chuk_tool_processor-0.6.13.dist-info}/top_level.txt +0 -0
|
@@ -5,14 +5,16 @@ SSE transport for MCP communication.
|
|
|
5
5
|
FIXED: Improved health monitoring to avoid false unhealthy states.
|
|
6
6
|
The SSE endpoint works perfectly, so we need more lenient health checks.
|
|
7
7
|
"""
|
|
8
|
+
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import asyncio
|
|
12
|
+
import contextlib
|
|
11
13
|
import json
|
|
14
|
+
import logging
|
|
12
15
|
import time
|
|
13
16
|
import uuid
|
|
14
|
-
from typing import
|
|
15
|
-
import logging
|
|
17
|
+
from typing import Any
|
|
16
18
|
|
|
17
19
|
import httpx
|
|
18
20
|
|
|
@@ -24,49 +26,53 @@ logger = logging.getLogger(__name__)
|
|
|
24
26
|
class SSETransport(MCPBaseTransport):
|
|
25
27
|
"""
|
|
26
28
|
SSE transport implementing the MCP protocol over Server-Sent Events.
|
|
27
|
-
|
|
29
|
+
|
|
28
30
|
FIXED: More lenient health monitoring to avoid false unhealthy states.
|
|
29
31
|
"""
|
|
30
32
|
|
|
31
|
-
def __init__(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
url: str,
|
|
36
|
+
api_key: str | None = None,
|
|
37
|
+
headers: dict[str, str] | None = None,
|
|
38
|
+
connection_timeout: float = 30.0,
|
|
39
|
+
default_timeout: float = 60.0,
|
|
40
|
+
enable_metrics: bool = True,
|
|
41
|
+
):
|
|
36
42
|
"""
|
|
37
43
|
Initialize SSE transport.
|
|
38
44
|
"""
|
|
39
|
-
self.url = url.rstrip(
|
|
45
|
+
self.url = url.rstrip("/")
|
|
40
46
|
self.api_key = api_key
|
|
41
47
|
self.configured_headers = headers or {}
|
|
42
48
|
self.connection_timeout = connection_timeout
|
|
43
49
|
self.default_timeout = default_timeout
|
|
44
50
|
self.enable_metrics = enable_metrics
|
|
45
|
-
|
|
51
|
+
|
|
46
52
|
logger.debug("SSE Transport initialized with URL: %s", self.url)
|
|
47
|
-
|
|
53
|
+
|
|
48
54
|
# Connection state
|
|
49
55
|
self.session_id = None
|
|
50
56
|
self.message_url = None
|
|
51
|
-
self.pending_requests:
|
|
57
|
+
self.pending_requests: dict[str, asyncio.Future] = {}
|
|
52
58
|
self._initialized = False
|
|
53
|
-
|
|
59
|
+
|
|
54
60
|
# HTTP clients
|
|
55
61
|
self.stream_client = None
|
|
56
62
|
self.send_client = None
|
|
57
|
-
|
|
63
|
+
|
|
58
64
|
# SSE stream management
|
|
59
65
|
self.sse_task = None
|
|
60
66
|
self.sse_response = None
|
|
61
67
|
self.sse_stream_context = None
|
|
62
|
-
|
|
68
|
+
|
|
63
69
|
# FIXED: More lenient health monitoring
|
|
64
70
|
self._last_successful_ping = None
|
|
65
71
|
self._consecutive_failures = 0
|
|
66
72
|
self._max_consecutive_failures = 5 # INCREASED: was 3, now 5
|
|
67
73
|
self._connection_grace_period = 30.0 # NEW: Grace period after initialization
|
|
68
74
|
self._initialization_time = None # NEW: Track when we initialized
|
|
69
|
-
|
|
75
|
+
|
|
70
76
|
# Performance metrics
|
|
71
77
|
self._metrics = {
|
|
72
78
|
"total_calls": 0,
|
|
@@ -77,43 +83,43 @@ class SSETransport(MCPBaseTransport):
|
|
|
77
83
|
"last_ping_time": None,
|
|
78
84
|
"initialization_time": None,
|
|
79
85
|
"session_discoveries": 0,
|
|
80
|
-
"stream_errors": 0
|
|
86
|
+
"stream_errors": 0,
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
def _construct_sse_url(self, base_url: str) -> str:
|
|
84
90
|
"""Construct the SSE endpoint URL from the base URL."""
|
|
85
|
-
base_url = base_url.rstrip(
|
|
86
|
-
|
|
87
|
-
if base_url.endswith(
|
|
91
|
+
base_url = base_url.rstrip("/")
|
|
92
|
+
|
|
93
|
+
if base_url.endswith("/sse"):
|
|
88
94
|
logger.debug("URL already contains /sse endpoint: %s", base_url)
|
|
89
95
|
return base_url
|
|
90
|
-
|
|
96
|
+
|
|
91
97
|
sse_url = f"{base_url}/sse"
|
|
92
98
|
logger.debug("Constructed SSE URL: %s -> %s", base_url, sse_url)
|
|
93
99
|
return sse_url
|
|
94
100
|
|
|
95
|
-
def _get_headers(self) ->
|
|
101
|
+
def _get_headers(self) -> dict[str, str]:
|
|
96
102
|
"""Get headers with authentication and custom headers."""
|
|
97
103
|
headers = {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
"User-Agent": "chuk-tool-processor/1.0.0",
|
|
105
|
+
"Accept": "text/event-stream",
|
|
106
|
+
"Cache-Control": "no-cache",
|
|
101
107
|
}
|
|
102
|
-
|
|
108
|
+
|
|
103
109
|
# Add configured headers first
|
|
104
110
|
if self.configured_headers:
|
|
105
111
|
headers.update(self.configured_headers)
|
|
106
|
-
|
|
112
|
+
|
|
107
113
|
# Add API key as Bearer token if provided
|
|
108
114
|
if self.api_key:
|
|
109
|
-
headers[
|
|
110
|
-
|
|
115
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
116
|
+
|
|
111
117
|
return headers
|
|
112
118
|
|
|
113
119
|
async def _test_gateway_connectivity(self) -> bool:
|
|
114
120
|
"""
|
|
115
121
|
Skip connectivity test - we know the SSE endpoint works.
|
|
116
|
-
|
|
122
|
+
|
|
117
123
|
FIXED: The diagnostic proves SSE endpoint works perfectly.
|
|
118
124
|
No need to test base URL that causes 401 errors.
|
|
119
125
|
"""
|
|
@@ -125,59 +131,54 @@ class SSETransport(MCPBaseTransport):
|
|
|
125
131
|
if self._initialized:
|
|
126
132
|
logger.warning("Transport already initialized")
|
|
127
133
|
return True
|
|
128
|
-
|
|
134
|
+
|
|
129
135
|
start_time = time.time()
|
|
130
|
-
|
|
136
|
+
|
|
131
137
|
try:
|
|
132
138
|
logger.debug("Initializing SSE transport...")
|
|
133
|
-
|
|
139
|
+
|
|
134
140
|
# FIXED: Skip problematic connectivity test
|
|
135
141
|
if not await self._test_gateway_connectivity():
|
|
136
142
|
logger.error("Gateway connectivity test failed")
|
|
137
143
|
return False
|
|
138
|
-
|
|
144
|
+
|
|
139
145
|
# Create HTTP clients
|
|
140
146
|
self.stream_client = httpx.AsyncClient(
|
|
141
147
|
timeout=httpx.Timeout(self.connection_timeout),
|
|
142
148
|
follow_redirects=True,
|
|
143
|
-
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
|
|
149
|
+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
|
144
150
|
)
|
|
145
151
|
self.send_client = httpx.AsyncClient(
|
|
146
152
|
timeout=httpx.Timeout(self.default_timeout),
|
|
147
153
|
follow_redirects=True,
|
|
148
|
-
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
|
|
154
|
+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
|
149
155
|
)
|
|
150
|
-
|
|
156
|
+
|
|
151
157
|
# Connect to SSE stream
|
|
152
158
|
sse_url = self._construct_sse_url(self.url)
|
|
153
159
|
logger.debug("Connecting to SSE endpoint: %s", sse_url)
|
|
154
|
-
|
|
155
|
-
self.sse_stream_context = self.stream_client.stream(
|
|
156
|
-
'GET', sse_url, headers=self._get_headers()
|
|
157
|
-
)
|
|
160
|
+
|
|
161
|
+
self.sse_stream_context = self.stream_client.stream("GET", sse_url, headers=self._get_headers())
|
|
158
162
|
self.sse_response = await self.sse_stream_context.__aenter__()
|
|
159
|
-
|
|
163
|
+
|
|
160
164
|
if self.sse_response.status_code != 200:
|
|
161
165
|
logger.error("SSE connection failed with status: %s", self.sse_response.status_code)
|
|
162
166
|
await self._cleanup()
|
|
163
167
|
return False
|
|
164
|
-
|
|
168
|
+
|
|
165
169
|
logger.debug("SSE streaming connection established")
|
|
166
|
-
|
|
170
|
+
|
|
167
171
|
# Start SSE processing task
|
|
168
|
-
self.sse_task = asyncio.create_task(
|
|
169
|
-
|
|
170
|
-
name="sse_stream_processor"
|
|
171
|
-
)
|
|
172
|
-
|
|
172
|
+
self.sse_task = asyncio.create_task(self._process_sse_stream(), name="sse_stream_processor")
|
|
173
|
+
|
|
173
174
|
# Wait for session discovery
|
|
174
175
|
logger.debug("Waiting for session discovery...")
|
|
175
176
|
session_timeout = 10.0
|
|
176
177
|
session_start = time.time()
|
|
177
|
-
|
|
178
|
+
|
|
178
179
|
while not self.message_url and (time.time() - session_start) < session_timeout:
|
|
179
180
|
await asyncio.sleep(0.1)
|
|
180
|
-
|
|
181
|
+
|
|
181
182
|
# Check if SSE task died
|
|
182
183
|
if self.sse_task.done():
|
|
183
184
|
exception = self.sse_task.exception()
|
|
@@ -185,54 +186,55 @@ class SSETransport(MCPBaseTransport):
|
|
|
185
186
|
logger.error(f"SSE task died during session discovery: {exception}")
|
|
186
187
|
await self._cleanup()
|
|
187
188
|
return False
|
|
188
|
-
|
|
189
|
+
|
|
189
190
|
if not self.message_url:
|
|
190
191
|
logger.error("Failed to discover session endpoint within %.1fs", session_timeout)
|
|
191
192
|
await self._cleanup()
|
|
192
193
|
return False
|
|
193
|
-
|
|
194
|
+
|
|
194
195
|
if self.enable_metrics:
|
|
195
196
|
self._metrics["session_discoveries"] += 1
|
|
196
|
-
|
|
197
|
+
|
|
197
198
|
logger.debug("Session endpoint discovered: %s", self.message_url)
|
|
198
|
-
|
|
199
|
+
|
|
199
200
|
# Perform MCP initialization handshake
|
|
200
201
|
try:
|
|
201
|
-
init_response = await self._send_request(
|
|
202
|
-
"
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"
|
|
206
|
-
"version": "1.0.0"
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
202
|
+
init_response = await self._send_request(
|
|
203
|
+
"initialize",
|
|
204
|
+
{
|
|
205
|
+
"protocolVersion": "2024-11-05",
|
|
206
|
+
"capabilities": {},
|
|
207
|
+
"clientInfo": {"name": "chuk-tool-processor", "version": "1.0.0"},
|
|
208
|
+
},
|
|
209
|
+
timeout=self.default_timeout,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if "error" in init_response:
|
|
213
|
+
logger.error("MCP initialize failed: %s", init_response["error"])
|
|
212
214
|
await self._cleanup()
|
|
213
215
|
return False
|
|
214
|
-
|
|
216
|
+
|
|
215
217
|
# Send initialized notification
|
|
216
218
|
await self._send_notification("notifications/initialized")
|
|
217
|
-
|
|
219
|
+
|
|
218
220
|
# FIXED: Set health tracking state
|
|
219
221
|
self._initialized = True
|
|
220
222
|
self._initialization_time = time.time()
|
|
221
223
|
self._last_successful_ping = time.time()
|
|
222
224
|
self._consecutive_failures = 0 # Reset failure count
|
|
223
|
-
|
|
225
|
+
|
|
224
226
|
if self.enable_metrics:
|
|
225
227
|
init_time = time.time() - start_time
|
|
226
228
|
self._metrics["initialization_time"] = init_time
|
|
227
|
-
|
|
229
|
+
|
|
228
230
|
logger.debug("SSE transport initialized successfully in %.3fs", time.time() - start_time)
|
|
229
231
|
return True
|
|
230
|
-
|
|
232
|
+
|
|
231
233
|
except Exception as e:
|
|
232
234
|
logger.error("MCP handshake failed: %s", e)
|
|
233
235
|
await self._cleanup()
|
|
234
236
|
return False
|
|
235
|
-
|
|
237
|
+
|
|
236
238
|
except Exception as e:
|
|
237
239
|
logger.error("Error initializing SSE transport: %s", e, exc_info=True)
|
|
238
240
|
await self._cleanup()
|
|
@@ -242,76 +244,76 @@ class SSETransport(MCPBaseTransport):
|
|
|
242
244
|
"""Process the SSE stream for responses and session discovery."""
|
|
243
245
|
try:
|
|
244
246
|
logger.debug("Starting SSE stream processing...")
|
|
245
|
-
|
|
247
|
+
|
|
246
248
|
current_event = None
|
|
247
|
-
|
|
249
|
+
|
|
248
250
|
async for line in self.sse_response.aiter_lines():
|
|
249
251
|
line = line.strip()
|
|
250
252
|
if not line:
|
|
251
253
|
continue
|
|
252
|
-
|
|
254
|
+
|
|
253
255
|
# Handle event type declarations
|
|
254
|
-
if line.startswith(
|
|
255
|
-
current_event = line.split(
|
|
256
|
+
if line.startswith("event:"):
|
|
257
|
+
current_event = line.split(":", 1)[1].strip()
|
|
256
258
|
logger.debug("SSE event type: %s", current_event)
|
|
257
259
|
continue
|
|
258
|
-
|
|
260
|
+
|
|
259
261
|
# Handle session endpoint discovery
|
|
260
|
-
if not self.message_url and line.startswith(
|
|
261
|
-
data_part = line.split(
|
|
262
|
-
|
|
262
|
+
if not self.message_url and line.startswith("data:"):
|
|
263
|
+
data_part = line.split(":", 1)[1].strip()
|
|
264
|
+
|
|
263
265
|
# NEW FORMAT: event: endpoint + data: https://...
|
|
264
|
-
if current_event == "endpoint" and data_part.startswith(
|
|
266
|
+
if current_event == "endpoint" and data_part.startswith("http"):
|
|
265
267
|
self.message_url = data_part
|
|
266
|
-
|
|
268
|
+
|
|
267
269
|
# Extract session ID from URL if present
|
|
268
|
-
if
|
|
269
|
-
self.session_id = data_part.split(
|
|
270
|
+
if "session_id=" in data_part:
|
|
271
|
+
self.session_id = data_part.split("session_id=")[1].split("&")[0]
|
|
270
272
|
else:
|
|
271
273
|
self.session_id = str(uuid.uuid4())
|
|
272
|
-
|
|
274
|
+
|
|
273
275
|
logger.debug("Session endpoint discovered via event format: %s", self.message_url)
|
|
274
276
|
continue
|
|
275
|
-
|
|
277
|
+
|
|
276
278
|
# OLD FORMAT: data: /messages/... (backwards compatibility)
|
|
277
|
-
elif
|
|
279
|
+
elif "/messages/" in data_part:
|
|
278
280
|
endpoint_path = data_part
|
|
279
281
|
self.message_url = f"{self.url}{endpoint_path}"
|
|
280
|
-
|
|
282
|
+
|
|
281
283
|
# Extract session ID if present
|
|
282
|
-
if
|
|
283
|
-
self.session_id = endpoint_path.split(
|
|
284
|
+
if "session_id=" in endpoint_path:
|
|
285
|
+
self.session_id = endpoint_path.split("session_id=")[1].split("&")[0]
|
|
284
286
|
else:
|
|
285
287
|
self.session_id = str(uuid.uuid4())
|
|
286
|
-
|
|
288
|
+
|
|
287
289
|
logger.debug("Session endpoint discovered via old format: %s", self.message_url)
|
|
288
290
|
continue
|
|
289
|
-
|
|
291
|
+
|
|
290
292
|
# Handle JSON-RPC responses
|
|
291
|
-
if line.startswith(
|
|
292
|
-
data_part = line.split(
|
|
293
|
-
|
|
293
|
+
if line.startswith("data:"):
|
|
294
|
+
data_part = line.split(":", 1)[1].strip()
|
|
295
|
+
|
|
294
296
|
# Skip keepalive pings and empty data
|
|
295
|
-
if not data_part or data_part.startswith(
|
|
297
|
+
if not data_part or data_part.startswith("ping") or data_part in ("{}", "[]"):
|
|
296
298
|
continue
|
|
297
|
-
|
|
299
|
+
|
|
298
300
|
try:
|
|
299
301
|
response_data = json.loads(data_part)
|
|
300
|
-
|
|
302
|
+
|
|
301
303
|
# Handle JSON-RPC responses with request IDs
|
|
302
|
-
if
|
|
303
|
-
request_id = str(response_data[
|
|
304
|
-
|
|
304
|
+
if "jsonrpc" in response_data and "id" in response_data:
|
|
305
|
+
request_id = str(response_data["id"])
|
|
306
|
+
|
|
305
307
|
# Resolve pending request if found
|
|
306
308
|
if request_id in self.pending_requests:
|
|
307
309
|
future = self.pending_requests.pop(request_id)
|
|
308
310
|
if not future.done():
|
|
309
311
|
future.set_result(response_data)
|
|
310
312
|
logger.debug("Resolved request ID: %s", request_id)
|
|
311
|
-
|
|
313
|
+
|
|
312
314
|
except json.JSONDecodeError as e:
|
|
313
315
|
logger.debug("Non-JSON data in SSE stream (ignoring): %s", e)
|
|
314
|
-
|
|
316
|
+
|
|
315
317
|
except Exception as e:
|
|
316
318
|
if self.enable_metrics:
|
|
317
319
|
self._metrics["stream_errors"] += 1
|
|
@@ -319,43 +321,32 @@ class SSETransport(MCPBaseTransport):
|
|
|
319
321
|
# FIXED: Don't increment consecutive failures for stream processing errors
|
|
320
322
|
# These are often temporary and don't indicate connection health
|
|
321
323
|
|
|
322
|
-
async def _send_request(
|
|
323
|
-
|
|
324
|
+
async def _send_request(
|
|
325
|
+
self, method: str, params: dict[str, Any] = None, timeout: float | None = None
|
|
326
|
+
) -> dict[str, Any]:
|
|
324
327
|
"""Send JSON-RPC request and wait for async response via SSE."""
|
|
325
328
|
if not self.message_url:
|
|
326
329
|
raise RuntimeError("SSE transport not connected - no message URL")
|
|
327
|
-
|
|
330
|
+
|
|
328
331
|
request_id = str(uuid.uuid4())
|
|
329
|
-
message = {
|
|
330
|
-
|
|
331
|
-
"id": request_id,
|
|
332
|
-
"method": method,
|
|
333
|
-
"params": params or {}
|
|
334
|
-
}
|
|
335
|
-
|
|
332
|
+
message = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}}
|
|
333
|
+
|
|
336
334
|
# Create future for async response
|
|
337
335
|
future = asyncio.Future()
|
|
338
336
|
self.pending_requests[request_id] = future
|
|
339
|
-
|
|
337
|
+
|
|
340
338
|
try:
|
|
341
339
|
# Send HTTP POST request
|
|
342
|
-
headers = {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
response = await self.send_client.post(
|
|
348
|
-
self.message_url,
|
|
349
|
-
headers=headers,
|
|
350
|
-
json=message
|
|
351
|
-
)
|
|
352
|
-
|
|
340
|
+
headers = {"Content-Type": "application/json", **self._get_headers()}
|
|
341
|
+
|
|
342
|
+
response = await self.send_client.post(self.message_url, headers=headers, json=message)
|
|
343
|
+
|
|
353
344
|
if response.status_code == 202:
|
|
354
345
|
# Async response - wait for result via SSE
|
|
355
346
|
request_timeout = timeout or self.default_timeout
|
|
356
347
|
result = await asyncio.wait_for(future, timeout=request_timeout)
|
|
357
348
|
# FIXED: Only reset failures on successful tool calls, not all requests
|
|
358
|
-
if method.startswith(
|
|
349
|
+
if method.startswith("tools/"):
|
|
359
350
|
self._consecutive_failures = 0
|
|
360
351
|
self._last_successful_ping = time.time()
|
|
361
352
|
return result
|
|
@@ -363,52 +354,41 @@ class SSETransport(MCPBaseTransport):
|
|
|
363
354
|
# Immediate response
|
|
364
355
|
self.pending_requests.pop(request_id, None)
|
|
365
356
|
# FIXED: Only reset failures on successful tool calls
|
|
366
|
-
if method.startswith(
|
|
357
|
+
if method.startswith("tools/"):
|
|
367
358
|
self._consecutive_failures = 0
|
|
368
359
|
self._last_successful_ping = time.time()
|
|
369
360
|
return response.json()
|
|
370
361
|
else:
|
|
371
362
|
self.pending_requests.pop(request_id, None)
|
|
372
363
|
# FIXED: Only increment failures for tool calls, not initialization
|
|
373
|
-
if method.startswith(
|
|
364
|
+
if method.startswith("tools/"):
|
|
374
365
|
self._consecutive_failures += 1
|
|
375
366
|
raise RuntimeError(f"HTTP request failed with status: {response.status_code}")
|
|
376
|
-
|
|
377
|
-
except
|
|
367
|
+
|
|
368
|
+
except TimeoutError:
|
|
378
369
|
self.pending_requests.pop(request_id, None)
|
|
379
370
|
# FIXED: Only increment failures for tool calls
|
|
380
|
-
if method.startswith(
|
|
371
|
+
if method.startswith("tools/"):
|
|
381
372
|
self._consecutive_failures += 1
|
|
382
373
|
raise
|
|
383
374
|
except Exception:
|
|
384
375
|
self.pending_requests.pop(request_id, None)
|
|
385
376
|
# FIXED: Only increment failures for tool calls
|
|
386
|
-
if method.startswith(
|
|
377
|
+
if method.startswith("tools/"):
|
|
387
378
|
self._consecutive_failures += 1
|
|
388
379
|
raise
|
|
389
380
|
|
|
390
|
-
async def _send_notification(self, method: str, params:
|
|
381
|
+
async def _send_notification(self, method: str, params: dict[str, Any] = None):
|
|
391
382
|
"""Send JSON-RPC notification (no response expected)."""
|
|
392
383
|
if not self.message_url:
|
|
393
384
|
raise RuntimeError("SSE transport not connected - no message URL")
|
|
394
|
-
|
|
395
|
-
message = {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
headers = {
|
|
402
|
-
'Content-Type': 'application/json',
|
|
403
|
-
**self._get_headers()
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
response = await self.send_client.post(
|
|
407
|
-
self.message_url,
|
|
408
|
-
headers=headers,
|
|
409
|
-
json=message
|
|
410
|
-
)
|
|
411
|
-
|
|
385
|
+
|
|
386
|
+
message = {"jsonrpc": "2.0", "method": method, "params": params or {}}
|
|
387
|
+
|
|
388
|
+
headers = {"Content-Type": "application/json", **self._get_headers()}
|
|
389
|
+
|
|
390
|
+
response = await self.send_client.post(self.message_url, headers=headers, json=message)
|
|
391
|
+
|
|
412
392
|
if response.status_code not in (200, 202):
|
|
413
393
|
logger.warning("Notification failed with status: %s", response.status_code)
|
|
414
394
|
|
|
@@ -416,23 +396,23 @@ class SSETransport(MCPBaseTransport):
|
|
|
416
396
|
"""Send ping to check connection health with improved logic."""
|
|
417
397
|
if not self._initialized:
|
|
418
398
|
return False
|
|
419
|
-
|
|
399
|
+
|
|
420
400
|
start_time = time.time()
|
|
421
401
|
try:
|
|
422
402
|
# Use tools/list as a lightweight ping since not all servers support ping
|
|
423
403
|
response = await self._send_request("tools/list", {}, timeout=10.0)
|
|
424
|
-
|
|
425
|
-
success =
|
|
426
|
-
|
|
404
|
+
|
|
405
|
+
success = "error" not in response
|
|
406
|
+
|
|
427
407
|
if success:
|
|
428
408
|
self._last_successful_ping = time.time()
|
|
429
409
|
# FIXED: Don't reset consecutive failures here - let tool calls do that
|
|
430
|
-
|
|
410
|
+
|
|
431
411
|
if self.enable_metrics:
|
|
432
412
|
ping_time = time.time() - start_time
|
|
433
413
|
self._metrics["last_ping_time"] = ping_time
|
|
434
414
|
logger.debug("SSE ping completed in %.3fs: %s", ping_time, success)
|
|
435
|
-
|
|
415
|
+
|
|
436
416
|
return success
|
|
437
417
|
except Exception as e:
|
|
438
418
|
logger.debug("SSE ping failed: %s", e)
|
|
@@ -442,74 +422,70 @@ class SSETransport(MCPBaseTransport):
|
|
|
442
422
|
def is_connected(self) -> bool:
|
|
443
423
|
"""
|
|
444
424
|
FIXED: More lenient connection health check.
|
|
445
|
-
|
|
425
|
+
|
|
446
426
|
The diagnostic shows the connection works fine, so we need to be less aggressive
|
|
447
427
|
about marking it as unhealthy.
|
|
448
428
|
"""
|
|
449
429
|
if not self._initialized or not self.session_id:
|
|
450
430
|
return False
|
|
451
|
-
|
|
431
|
+
|
|
452
432
|
# FIXED: Grace period after initialization - always return True for a while
|
|
453
|
-
if
|
|
454
|
-
time.time() - self._initialization_time < self._connection_grace_period):
|
|
433
|
+
if self._initialization_time and time.time() - self._initialization_time < self._connection_grace_period:
|
|
455
434
|
logger.debug("Within grace period - connection considered healthy")
|
|
456
435
|
return True
|
|
457
|
-
|
|
436
|
+
|
|
458
437
|
# FIXED: More lenient failure threshold
|
|
459
438
|
if self._consecutive_failures >= self._max_consecutive_failures:
|
|
460
439
|
logger.warning(f"Connection marked unhealthy after {self._consecutive_failures} consecutive failures")
|
|
461
440
|
return False
|
|
462
|
-
|
|
441
|
+
|
|
463
442
|
# Check if SSE task is still running
|
|
464
443
|
if self.sse_task and self.sse_task.done():
|
|
465
444
|
exception = self.sse_task.exception()
|
|
466
445
|
if exception:
|
|
467
446
|
logger.warning(f"SSE task died: {exception}")
|
|
468
447
|
return False
|
|
469
|
-
|
|
448
|
+
|
|
470
449
|
# FIXED: If we have a recent successful ping/tool call, we're healthy
|
|
471
|
-
if
|
|
472
|
-
time.time() - self._last_successful_ping < 60.0): # Success within last minute
|
|
450
|
+
if self._last_successful_ping and time.time() - self._last_successful_ping < 60.0: # Success within last minute
|
|
473
451
|
return True
|
|
474
|
-
|
|
452
|
+
|
|
475
453
|
# FIXED: Default to healthy if no clear indicators of problems
|
|
476
454
|
logger.debug("No clear health indicators - defaulting to healthy")
|
|
477
455
|
return True
|
|
478
456
|
|
|
479
|
-
async def get_tools(self) ->
|
|
457
|
+
async def get_tools(self) -> list[dict[str, Any]]:
|
|
480
458
|
"""Get list of available tools from the server."""
|
|
481
459
|
if not self._initialized:
|
|
482
460
|
logger.error("Cannot get tools: transport not initialized")
|
|
483
461
|
return []
|
|
484
|
-
|
|
462
|
+
|
|
485
463
|
start_time = time.time()
|
|
486
464
|
try:
|
|
487
465
|
response = await self._send_request("tools/list", {})
|
|
488
|
-
|
|
489
|
-
if
|
|
490
|
-
logger.error("Error getting tools: %s", response[
|
|
466
|
+
|
|
467
|
+
if "error" in response:
|
|
468
|
+
logger.error("Error getting tools: %s", response["error"])
|
|
491
469
|
return []
|
|
492
|
-
|
|
493
|
-
tools = response.get(
|
|
494
|
-
|
|
470
|
+
|
|
471
|
+
tools = response.get("result", {}).get("tools", [])
|
|
472
|
+
|
|
495
473
|
if self.enable_metrics:
|
|
496
474
|
response_time = time.time() - start_time
|
|
497
475
|
logger.debug("Retrieved %d tools in %.3fs", len(tools), response_time)
|
|
498
|
-
|
|
476
|
+
|
|
499
477
|
return tools
|
|
500
|
-
|
|
478
|
+
|
|
501
479
|
except Exception as e:
|
|
502
480
|
logger.error("Error getting tools: %s", e)
|
|
503
481
|
return []
|
|
504
482
|
|
|
505
|
-
async def call_tool(
|
|
506
|
-
|
|
483
|
+
async def call_tool(
|
|
484
|
+
self, tool_name: str, arguments: dict[str, Any], timeout: float | None = None
|
|
485
|
+
) -> dict[str, Any]:
|
|
507
486
|
"""Execute a tool with the given arguments."""
|
|
508
487
|
if not self._initialized:
|
|
509
|
-
return {
|
|
510
|
-
"isError": True,
|
|
511
|
-
"error": "Transport not initialized"
|
|
512
|
-
}
|
|
488
|
+
return {"isError": True, "error": "Transport not initialized"}
|
|
513
489
|
|
|
514
490
|
start_time = time.time()
|
|
515
491
|
if self.enable_metrics:
|
|
@@ -517,51 +493,37 @@ class SSETransport(MCPBaseTransport):
|
|
|
517
493
|
|
|
518
494
|
try:
|
|
519
495
|
logger.debug("Calling tool '%s' with arguments: %s", tool_name, arguments)
|
|
520
|
-
|
|
496
|
+
|
|
521
497
|
response = await self._send_request(
|
|
522
|
-
"tools/call",
|
|
523
|
-
{
|
|
524
|
-
"name": tool_name,
|
|
525
|
-
"arguments": arguments
|
|
526
|
-
},
|
|
527
|
-
timeout=timeout
|
|
498
|
+
"tools/call", {"name": tool_name, "arguments": arguments}, timeout=timeout
|
|
528
499
|
)
|
|
529
|
-
|
|
530
|
-
if
|
|
500
|
+
|
|
501
|
+
if "error" in response:
|
|
531
502
|
if self.enable_metrics:
|
|
532
503
|
self._update_metrics(time.time() - start_time, False)
|
|
533
|
-
|
|
534
|
-
return {
|
|
535
|
-
|
|
536
|
-
"error": response['error'].get('message', 'Unknown error')
|
|
537
|
-
}
|
|
538
|
-
|
|
504
|
+
|
|
505
|
+
return {"isError": True, "error": response["error"].get("message", "Unknown error")}
|
|
506
|
+
|
|
539
507
|
# Extract and normalize result using base class method
|
|
540
|
-
result = response.get(
|
|
508
|
+
result = response.get("result", {})
|
|
541
509
|
normalized_result = self._normalize_mcp_response({"result": result})
|
|
542
|
-
|
|
510
|
+
|
|
543
511
|
if self.enable_metrics:
|
|
544
512
|
self._update_metrics(time.time() - start_time, True)
|
|
545
|
-
|
|
513
|
+
|
|
546
514
|
return normalized_result
|
|
547
|
-
|
|
548
|
-
except
|
|
515
|
+
|
|
516
|
+
except TimeoutError:
|
|
549
517
|
if self.enable_metrics:
|
|
550
518
|
self._update_metrics(time.time() - start_time, False)
|
|
551
|
-
|
|
552
|
-
return {
|
|
553
|
-
"isError": True,
|
|
554
|
-
"error": "Tool execution timed out"
|
|
555
|
-
}
|
|
519
|
+
|
|
520
|
+
return {"isError": True, "error": "Tool execution timed out"}
|
|
556
521
|
except Exception as e:
|
|
557
522
|
if self.enable_metrics:
|
|
558
523
|
self._update_metrics(time.time() - start_time, False)
|
|
559
|
-
|
|
524
|
+
|
|
560
525
|
logger.error("Error calling tool '%s': %s", tool_name, e)
|
|
561
|
-
return {
|
|
562
|
-
"isError": True,
|
|
563
|
-
"error": str(e)
|
|
564
|
-
}
|
|
526
|
+
return {"isError": True, "error": str(e)}
|
|
565
527
|
|
|
566
528
|
def _update_metrics(self, response_time: float, success: bool) -> None:
|
|
567
529
|
"""Update performance metrics."""
|
|
@@ -569,39 +531,37 @@ class SSETransport(MCPBaseTransport):
|
|
|
569
531
|
self._metrics["successful_calls"] += 1
|
|
570
532
|
else:
|
|
571
533
|
self._metrics["failed_calls"] += 1
|
|
572
|
-
|
|
534
|
+
|
|
573
535
|
self._metrics["total_time"] += response_time
|
|
574
536
|
if self._metrics["total_calls"] > 0:
|
|
575
|
-
self._metrics["avg_response_time"] =
|
|
576
|
-
self._metrics["total_time"] / self._metrics["total_calls"]
|
|
577
|
-
)
|
|
537
|
+
self._metrics["avg_response_time"] = self._metrics["total_time"] / self._metrics["total_calls"]
|
|
578
538
|
|
|
579
|
-
async def list_resources(self) ->
|
|
539
|
+
async def list_resources(self) -> dict[str, Any]:
|
|
580
540
|
"""List available resources from the server."""
|
|
581
541
|
if not self._initialized:
|
|
582
542
|
return {}
|
|
583
|
-
|
|
543
|
+
|
|
584
544
|
try:
|
|
585
545
|
response = await self._send_request("resources/list", {}, timeout=10.0)
|
|
586
|
-
if
|
|
587
|
-
logger.debug("Resources not supported: %s", response[
|
|
546
|
+
if "error" in response:
|
|
547
|
+
logger.debug("Resources not supported: %s", response["error"])
|
|
588
548
|
return {}
|
|
589
|
-
return response.get(
|
|
549
|
+
return response.get("result", {})
|
|
590
550
|
except Exception as e:
|
|
591
551
|
logger.debug("Error listing resources: %s", e)
|
|
592
552
|
return {}
|
|
593
553
|
|
|
594
|
-
async def list_prompts(self) ->
|
|
554
|
+
async def list_prompts(self) -> dict[str, Any]:
|
|
595
555
|
"""List available prompts from the server."""
|
|
596
556
|
if not self._initialized:
|
|
597
557
|
return {}
|
|
598
|
-
|
|
558
|
+
|
|
599
559
|
try:
|
|
600
560
|
response = await self._send_request("prompts/list", {}, timeout=10.0)
|
|
601
|
-
if
|
|
602
|
-
logger.debug("Prompts not supported: %s", response[
|
|
561
|
+
if "error" in response:
|
|
562
|
+
logger.debug("Prompts not supported: %s", response["error"])
|
|
603
563
|
return {}
|
|
604
|
-
return response.get(
|
|
564
|
+
return response.get("result", {})
|
|
605
565
|
except Exception as e:
|
|
606
566
|
logger.debug("Error listing prompts: %s", e)
|
|
607
567
|
return {}
|
|
@@ -610,16 +570,16 @@ class SSETransport(MCPBaseTransport):
|
|
|
610
570
|
"""Close the transport and clean up resources."""
|
|
611
571
|
if not self._initialized:
|
|
612
572
|
return
|
|
613
|
-
|
|
573
|
+
|
|
614
574
|
# Log final metrics
|
|
615
575
|
if self.enable_metrics and self._metrics["total_calls"] > 0:
|
|
616
576
|
logger.debug(
|
|
617
577
|
"SSE transport closing - Total calls: %d, Success rate: %.1f%%, Avg response time: %.3fs",
|
|
618
578
|
self._metrics["total_calls"],
|
|
619
579
|
(self._metrics["successful_calls"] / self._metrics["total_calls"] * 100),
|
|
620
|
-
self._metrics["avg_response_time"]
|
|
580
|
+
self._metrics["avg_response_time"],
|
|
621
581
|
)
|
|
622
|
-
|
|
582
|
+
|
|
623
583
|
await self._cleanup()
|
|
624
584
|
|
|
625
585
|
async def _cleanup(self) -> None:
|
|
@@ -627,30 +587,28 @@ class SSETransport(MCPBaseTransport):
|
|
|
627
587
|
# Cancel SSE processing task
|
|
628
588
|
if self.sse_task and not self.sse_task.done():
|
|
629
589
|
self.sse_task.cancel()
|
|
630
|
-
|
|
590
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
631
591
|
await self.sse_task
|
|
632
|
-
|
|
633
|
-
pass
|
|
634
|
-
|
|
592
|
+
|
|
635
593
|
# Close SSE stream context
|
|
636
594
|
if self.sse_stream_context:
|
|
637
595
|
try:
|
|
638
596
|
await self.sse_stream_context.__aexit__(None, None, None)
|
|
639
597
|
except Exception as e:
|
|
640
598
|
logger.debug("Error closing SSE stream: %s", e)
|
|
641
|
-
|
|
599
|
+
|
|
642
600
|
# Close HTTP clients
|
|
643
601
|
if self.stream_client:
|
|
644
602
|
await self.stream_client.aclose()
|
|
645
|
-
|
|
603
|
+
|
|
646
604
|
if self.send_client:
|
|
647
605
|
await self.send_client.aclose()
|
|
648
|
-
|
|
606
|
+
|
|
649
607
|
# Cancel any pending requests
|
|
650
|
-
for
|
|
608
|
+
for _request_id, future in self.pending_requests.items():
|
|
651
609
|
if not future.done():
|
|
652
610
|
future.cancel()
|
|
653
|
-
|
|
611
|
+
|
|
654
612
|
# Reset state
|
|
655
613
|
self._initialized = False
|
|
656
614
|
self.session_id = None
|
|
@@ -666,20 +624,24 @@ class SSETransport(MCPBaseTransport):
|
|
|
666
624
|
self._last_successful_ping = None
|
|
667
625
|
self._initialization_time = None
|
|
668
626
|
|
|
669
|
-
def get_metrics(self) ->
|
|
627
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
670
628
|
"""Get performance and connection metrics with health info."""
|
|
671
629
|
metrics = self._metrics.copy()
|
|
672
|
-
metrics.update(
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
630
|
+
metrics.update(
|
|
631
|
+
{
|
|
632
|
+
"is_connected": self.is_connected(),
|
|
633
|
+
"consecutive_failures": self._consecutive_failures,
|
|
634
|
+
"max_consecutive_failures": self._max_consecutive_failures,
|
|
635
|
+
"last_successful_ping": self._last_successful_ping,
|
|
636
|
+
"initialization_time_timestamp": self._initialization_time,
|
|
637
|
+
"grace_period_active": (
|
|
638
|
+
self._initialization_time
|
|
639
|
+
and time.time() - self._initialization_time < self._connection_grace_period
|
|
640
|
+
)
|
|
641
|
+
if self._initialization_time
|
|
642
|
+
else False,
|
|
643
|
+
}
|
|
644
|
+
)
|
|
683
645
|
return metrics
|
|
684
646
|
|
|
685
647
|
def reset_metrics(self) -> None:
|
|
@@ -693,10 +655,10 @@ class SSETransport(MCPBaseTransport):
|
|
|
693
655
|
"last_ping_time": self._metrics.get("last_ping_time"),
|
|
694
656
|
"initialization_time": self._metrics.get("initialization_time"),
|
|
695
657
|
"session_discoveries": self._metrics.get("session_discoveries", 0),
|
|
696
|
-
"stream_errors": 0
|
|
658
|
+
"stream_errors": 0,
|
|
697
659
|
}
|
|
698
660
|
|
|
699
|
-
def get_streams(self) ->
|
|
661
|
+
def get_streams(self) -> list[tuple]:
|
|
700
662
|
"""SSE transport doesn't expose raw streams."""
|
|
701
663
|
return []
|
|
702
664
|
|
|
@@ -709,4 +671,4 @@ class SSETransport(MCPBaseTransport):
|
|
|
709
671
|
|
|
710
672
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
711
673
|
"""Context manager cleanup."""
|
|
712
|
-
await self.close()
|
|
674
|
+
await self.close()
|