mcp-use 1.2.13__py3-none-any.whl → 1.3.1__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 mcp-use might be problematic. Click here for more details.
- mcp_use/agents/mcpagent.py +117 -22
- mcp_use/client.py +35 -9
- mcp_use/config.py +30 -4
- mcp_use/connectors/__init__.py +12 -5
- mcp_use/connectors/base.py +135 -37
- mcp_use/connectors/http.py +108 -30
- mcp_use/connectors/sandbox.py +296 -0
- mcp_use/connectors/stdio.py +7 -2
- mcp_use/connectors/utils.py +13 -0
- mcp_use/connectors/websocket.py +7 -2
- mcp_use/session.py +1 -4
- mcp_use/task_managers/__init__.py +2 -1
- mcp_use/task_managers/base.py +10 -4
- mcp_use/task_managers/streamable_http.py +81 -0
- mcp_use/task_managers/websocket.py +5 -0
- mcp_use/telemetry/__init__.py +0 -0
- mcp_use/telemetry/events.py +93 -0
- mcp_use/telemetry/posthog.py +214 -0
- mcp_use/telemetry/utils.py +48 -0
- mcp_use/types/sandbox.py +23 -0
- mcp_use/utils.py +27 -0
- {mcp_use-1.2.13.dist-info → mcp_use-1.3.1.dist-info}/METADATA +209 -32
- mcp_use-1.3.1.dist-info/RECORD +46 -0
- mcp_use-1.2.13.dist-info/RECORD +0 -37
- {mcp_use-1.2.13.dist-info → mcp_use-1.3.1.dist-info}/WHEEL +0 -0
- {mcp_use-1.2.13.dist-info → mcp_use-1.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sandbox connector for MCP implementations.
|
|
3
|
+
|
|
4
|
+
This module provides a connector for communicating with MCP implementations
|
|
5
|
+
that are executed inside a sandbox environment (currently using E2B).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
from mcp import ClientSession
|
|
15
|
+
|
|
16
|
+
from ..logging import logger
|
|
17
|
+
from ..task_managers import SseConnectionManager
|
|
18
|
+
|
|
19
|
+
# Import E2B SDK components (optional dependency)
|
|
20
|
+
try:
|
|
21
|
+
logger.debug("Attempting to import e2b_code_interpreter...")
|
|
22
|
+
from e2b_code_interpreter import (
|
|
23
|
+
CommandHandle,
|
|
24
|
+
Sandbox,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger.debug("Successfully imported e2b_code_interpreter")
|
|
28
|
+
except ImportError as e:
|
|
29
|
+
logger.debug(f"Failed to import e2b_code_interpreter: {e}")
|
|
30
|
+
CommandHandle = None
|
|
31
|
+
Sandbox = None
|
|
32
|
+
|
|
33
|
+
from ..types.sandbox import SandboxOptions
|
|
34
|
+
from .base import BaseConnector
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SandboxConnector(BaseConnector):
|
|
38
|
+
"""Connector for MCP implementations running in a sandbox environment.
|
|
39
|
+
|
|
40
|
+
This connector runs a user-defined stdio command within a sandbox environment,
|
|
41
|
+
currently implemented using E2B, potentially wrapped by a utility like 'supergateway'
|
|
42
|
+
to expose its stdio.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
command: str,
|
|
48
|
+
args: list[str],
|
|
49
|
+
env: dict[str, str] | None = None,
|
|
50
|
+
e2b_options: SandboxOptions | None = None,
|
|
51
|
+
timeout: float = 5,
|
|
52
|
+
sse_read_timeout: float = 60 * 5,
|
|
53
|
+
):
|
|
54
|
+
"""Initialize a new sandbox connector.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
command: The user's MCP server command to execute in the sandbox.
|
|
58
|
+
args: Command line arguments for the user's MCP server command.
|
|
59
|
+
env: Environment variables for the user's MCP server command.
|
|
60
|
+
e2b_options: Configuration options for the E2B sandbox environment.
|
|
61
|
+
See SandboxOptions for available options and defaults.
|
|
62
|
+
timeout: Timeout for the sandbox process in seconds.
|
|
63
|
+
sse_read_timeout: Timeout for the SSE connection in seconds.
|
|
64
|
+
"""
|
|
65
|
+
super().__init__()
|
|
66
|
+
if Sandbox is None:
|
|
67
|
+
raise ImportError(
|
|
68
|
+
"E2B SDK (e2b-code-interpreter) not found. "
|
|
69
|
+
"Please install it with 'pip install mcp-use[e2b]' "
|
|
70
|
+
"(or 'pip install e2b-code-interpreter')."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.user_command = command
|
|
74
|
+
self.user_args = args or []
|
|
75
|
+
self.user_env = env or {}
|
|
76
|
+
|
|
77
|
+
_e2b_options = e2b_options or {}
|
|
78
|
+
|
|
79
|
+
self.api_key = _e2b_options.get("api_key") or os.environ.get("E2B_API_KEY")
|
|
80
|
+
if not self.api_key:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"E2B API key is required. Provide it via 'sandbox_options.api_key' "
|
|
83
|
+
"or the E2B_API_KEY environment variable."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.sandbox_template_id = _e2b_options.get("sandbox_template_id", "base")
|
|
87
|
+
self.supergateway_cmd_parts = _e2b_options.get(
|
|
88
|
+
"supergateway_command", "npx -y supergateway"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.sandbox: Sandbox | None = None
|
|
92
|
+
self.process: CommandHandle | None = None
|
|
93
|
+
self.client: ClientSession | None = None
|
|
94
|
+
self.errlog = sys.stderr
|
|
95
|
+
self.base_url: str | None = None
|
|
96
|
+
self._connected = False
|
|
97
|
+
self._connection_manager: SseConnectionManager | None = None
|
|
98
|
+
|
|
99
|
+
# SSE connection parameters
|
|
100
|
+
self.headers = {}
|
|
101
|
+
self.timeout = timeout
|
|
102
|
+
self.sse_read_timeout = sse_read_timeout
|
|
103
|
+
|
|
104
|
+
self.stdout_lines: list[str] = []
|
|
105
|
+
self.stderr_lines: list[str] = []
|
|
106
|
+
self._server_ready = asyncio.Event()
|
|
107
|
+
|
|
108
|
+
def _handle_stdout(self, data: str) -> None:
|
|
109
|
+
"""Handle stdout data from the sandbox process."""
|
|
110
|
+
self.stdout_lines.append(data)
|
|
111
|
+
logger.debug(f"[SANDBOX STDOUT] {data}", end="", flush=True)
|
|
112
|
+
|
|
113
|
+
def _handle_stderr(self, data: str) -> None:
|
|
114
|
+
"""Handle stderr data from the sandbox process."""
|
|
115
|
+
self.stderr_lines.append(data)
|
|
116
|
+
logger.debug(f"[SANDBOX STDERR] {data}", file=self.errlog, end="", flush=True)
|
|
117
|
+
|
|
118
|
+
async def wait_for_server_response(self, base_url: str, timeout: int = 30) -> bool:
|
|
119
|
+
"""Wait for the server to respond to HTTP requests.
|
|
120
|
+
Args:
|
|
121
|
+
base_url: The base URL to check for server readiness
|
|
122
|
+
timeout: Maximum time to wait in seconds
|
|
123
|
+
Returns:
|
|
124
|
+
True if server is responding, raises TimeoutError otherwise
|
|
125
|
+
"""
|
|
126
|
+
logger.info(f"Waiting for server at {base_url} to respond...")
|
|
127
|
+
sys.stdout.flush()
|
|
128
|
+
|
|
129
|
+
start_time = time.time()
|
|
130
|
+
ping_url = f"{base_url}/sse"
|
|
131
|
+
|
|
132
|
+
# Try to connect to the server
|
|
133
|
+
while time.time() - start_time < timeout:
|
|
134
|
+
try:
|
|
135
|
+
async with aiohttp.ClientSession() as session:
|
|
136
|
+
try:
|
|
137
|
+
# First try the endpoint
|
|
138
|
+
async with session.get(ping_url, timeout=2) as response:
|
|
139
|
+
if response.status == 200:
|
|
140
|
+
elapsed = time.time() - start_time
|
|
141
|
+
logger.info(
|
|
142
|
+
f"Server is ready! "
|
|
143
|
+
f"SSE endpoint responded with 200 after {elapsed:.1f}s"
|
|
144
|
+
)
|
|
145
|
+
return True
|
|
146
|
+
except Exception:
|
|
147
|
+
# If sse endpoint doesn't work, try the base URL
|
|
148
|
+
async with session.get(base_url, timeout=2) as response:
|
|
149
|
+
if response.status < 500: # Accept any non-server error
|
|
150
|
+
elapsed = time.time() - start_time
|
|
151
|
+
logger.info(
|
|
152
|
+
f"Server is ready! Base URL responded with "
|
|
153
|
+
f"{response.status} after {elapsed:.1f}s"
|
|
154
|
+
)
|
|
155
|
+
return True
|
|
156
|
+
except Exception:
|
|
157
|
+
# Wait a bit before trying again
|
|
158
|
+
await asyncio.sleep(0.5)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
# If we get here, the request failed
|
|
162
|
+
await asyncio.sleep(0.5)
|
|
163
|
+
|
|
164
|
+
# Log status every 5 seconds
|
|
165
|
+
elapsed = time.time() - start_time
|
|
166
|
+
if int(elapsed) % 5 == 0:
|
|
167
|
+
logger.info(f"Still waiting for server to respond... ({elapsed:.1f}s elapsed)")
|
|
168
|
+
sys.stdout.flush()
|
|
169
|
+
|
|
170
|
+
# If we get here, we timed out
|
|
171
|
+
raise TimeoutError(f"Timeout waiting for server to respond (waited {timeout} seconds)")
|
|
172
|
+
|
|
173
|
+
async def connect(self):
|
|
174
|
+
"""Connect to the sandbox and start the MCP server."""
|
|
175
|
+
|
|
176
|
+
if self._connected:
|
|
177
|
+
logger.debug("Already connected to MCP implementation")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
logger.debug("Connecting to MCP implementation in sandbox")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
# Create and start the sandbox
|
|
184
|
+
self.sandbox = Sandbox(
|
|
185
|
+
template=self.sandbox_template_id,
|
|
186
|
+
api_key=self.api_key,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Get the host for the sandbox
|
|
190
|
+
host = self.sandbox.get_host(3000)
|
|
191
|
+
self.base_url = f"https://{host}".rstrip("/")
|
|
192
|
+
|
|
193
|
+
# Append command with args
|
|
194
|
+
command = f"{self.user_command} {' '.join(self.user_args)}"
|
|
195
|
+
|
|
196
|
+
# Construct the full command with supergateway
|
|
197
|
+
full_command = f'{self.supergateway_cmd_parts} \
|
|
198
|
+
--base-url {self.base_url} \
|
|
199
|
+
--port 3000 \
|
|
200
|
+
--cors \
|
|
201
|
+
--stdio "{command}"'
|
|
202
|
+
|
|
203
|
+
logger.debug(f"Full command: {full_command}")
|
|
204
|
+
|
|
205
|
+
# Start the process in the sandbox with our stdout/stderr handlers
|
|
206
|
+
self.process: CommandHandle = self.sandbox.commands.run(
|
|
207
|
+
full_command,
|
|
208
|
+
envs=self.user_env,
|
|
209
|
+
timeout=1000 * 60 * 10, # 10 minutes timeout
|
|
210
|
+
background=True,
|
|
211
|
+
on_stdout=self._handle_stdout,
|
|
212
|
+
on_stderr=self._handle_stderr,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Wait for the server to be ready
|
|
216
|
+
await self.wait_for_server_response(self.base_url, timeout=30)
|
|
217
|
+
logger.debug("Initializing connection manager...")
|
|
218
|
+
|
|
219
|
+
# Create the SSE connection URL
|
|
220
|
+
sse_url = f"{self.base_url}/sse"
|
|
221
|
+
|
|
222
|
+
# Create and start the connection manager
|
|
223
|
+
self._connection_manager = SseConnectionManager(
|
|
224
|
+
sse_url, self.headers, self.timeout, self.sse_read_timeout
|
|
225
|
+
)
|
|
226
|
+
read_stream, write_stream = await self._connection_manager.start()
|
|
227
|
+
|
|
228
|
+
# Create the client session
|
|
229
|
+
self.client = ClientSession(read_stream, write_stream, sampling_callback=None)
|
|
230
|
+
await self.client.__aenter__()
|
|
231
|
+
|
|
232
|
+
# Mark as connected
|
|
233
|
+
self._connected = True
|
|
234
|
+
logger.debug(
|
|
235
|
+
f"Successfully connected to MCP implementation via HTTP/SSE: {self.base_url}"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error(f"Failed to connect to MCP implementation: {e}")
|
|
240
|
+
|
|
241
|
+
# Clean up any resources if connection failed
|
|
242
|
+
await self._cleanup_resources()
|
|
243
|
+
|
|
244
|
+
raise e
|
|
245
|
+
|
|
246
|
+
async def _cleanup_resources(self) -> None:
|
|
247
|
+
"""Clean up all resources associated with this connector, including the sandbox.
|
|
248
|
+
This method extends the base implementation to also terminate the sandbox instance
|
|
249
|
+
and clean up any processes running in the sandbox.
|
|
250
|
+
"""
|
|
251
|
+
logger.debug("Cleaning up sandbox resources")
|
|
252
|
+
|
|
253
|
+
# Terminate any running process
|
|
254
|
+
if self.process:
|
|
255
|
+
try:
|
|
256
|
+
logger.debug("Terminating sandbox process")
|
|
257
|
+
self.process.kill()
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.warning(f"Error terminating sandbox process: {e}")
|
|
260
|
+
finally:
|
|
261
|
+
self.process = None
|
|
262
|
+
|
|
263
|
+
# Close the sandbox
|
|
264
|
+
if self.sandbox:
|
|
265
|
+
try:
|
|
266
|
+
logger.debug("Closing sandbox instance")
|
|
267
|
+
self.sandbox.kill()
|
|
268
|
+
logger.debug("Sandbox instance closed successfully")
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.warning(f"Error closing sandbox: {e}")
|
|
271
|
+
finally:
|
|
272
|
+
self.sandbox = None
|
|
273
|
+
|
|
274
|
+
# Then call the parent method to clean up the rest
|
|
275
|
+
await super()._cleanup_resources()
|
|
276
|
+
|
|
277
|
+
# Clear any collected output
|
|
278
|
+
self.stdout_lines = []
|
|
279
|
+
self.stderr_lines = []
|
|
280
|
+
self.base_url = None
|
|
281
|
+
|
|
282
|
+
async def disconnect(self) -> None:
|
|
283
|
+
"""Close the connection to the MCP implementation."""
|
|
284
|
+
if not self._connected:
|
|
285
|
+
logger.debug("Not connected to MCP implementation")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
logger.debug("Disconnecting from MCP implementation")
|
|
289
|
+
await self._cleanup_resources()
|
|
290
|
+
self._connected = False
|
|
291
|
+
logger.debug("Disconnected from MCP implementation")
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def public_identifier(self) -> str:
|
|
295
|
+
"""Get the identifier for the connector."""
|
|
296
|
+
return {"type": "sandbox", "command": self.user_command, "args": self.user_args}
|
mcp_use/connectors/stdio.py
CHANGED
|
@@ -61,8 +61,8 @@ class StdioConnector(BaseConnector):
|
|
|
61
61
|
read_stream, write_stream = await self._connection_manager.start()
|
|
62
62
|
|
|
63
63
|
# Create the client session
|
|
64
|
-
self.
|
|
65
|
-
await self.
|
|
64
|
+
self.client_session = ClientSession(read_stream, write_stream, sampling_callback=None)
|
|
65
|
+
await self.client_session.__aenter__()
|
|
66
66
|
|
|
67
67
|
# Mark as connected
|
|
68
68
|
self._connected = True
|
|
@@ -76,3 +76,8 @@ class StdioConnector(BaseConnector):
|
|
|
76
76
|
|
|
77
77
|
# Re-raise the original exception
|
|
78
78
|
raise
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def public_identifier(self) -> str:
|
|
82
|
+
"""Get the identifier for the connector."""
|
|
83
|
+
return {"type": "stdio", "command&args": f"{self.command} {' '.join(self.args)}"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def is_stdio_server(server_config: dict[str, Any]) -> bool:
|
|
5
|
+
"""Check if the server configuration is for a stdio server.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
server_config: The server configuration section
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
True if the server is a stdio server, False otherwise
|
|
12
|
+
"""
|
|
13
|
+
return "command" in server_config and "args" in server_config
|
mcp_use/connectors/websocket.py
CHANGED
|
@@ -11,7 +11,7 @@ import uuid
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from mcp.types import Tool
|
|
14
|
-
from websockets
|
|
14
|
+
from websockets import ClientConnection
|
|
15
15
|
|
|
16
16
|
from ..logging import logger
|
|
17
17
|
from ..task_managers import ConnectionManager, WebSocketConnectionManager
|
|
@@ -44,7 +44,7 @@ class WebSocketConnector(BaseConnector):
|
|
|
44
44
|
if auth_token:
|
|
45
45
|
self.headers["Authorization"] = f"Bearer {auth_token}"
|
|
46
46
|
|
|
47
|
-
self.ws:
|
|
47
|
+
self.ws: ClientConnection | None = None
|
|
48
48
|
self._connection_manager: ConnectionManager | None = None
|
|
49
49
|
self._receiver_task: asyncio.Task | None = None
|
|
50
50
|
self.pending_requests: dict[str, asyncio.Future] = {}
|
|
@@ -243,3 +243,8 @@ class WebSocketConnector(BaseConnector):
|
|
|
243
243
|
"""Send a raw request to the MCP implementation."""
|
|
244
244
|
logger.debug(f"Sending request: {method} with params: {params}")
|
|
245
245
|
return await self._send_request(method, params)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def public_identifier(self) -> str:
|
|
249
|
+
"""Get the identifier for the connector."""
|
|
250
|
+
return {"type": "websocket", "url": self.url}
|
mcp_use/session.py
CHANGED
|
@@ -7,8 +7,6 @@ which handles authentication, initialization, and tool discovery.
|
|
|
7
7
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from mcp.types import Tool
|
|
11
|
-
|
|
12
10
|
from .connectors.base import BaseConnector
|
|
13
11
|
|
|
14
12
|
|
|
@@ -32,7 +30,6 @@ class MCPSession:
|
|
|
32
30
|
"""
|
|
33
31
|
self.connector = connector
|
|
34
32
|
self.session_info: dict[str, Any] | None = None
|
|
35
|
-
self.tools: list[Tool] = []
|
|
36
33
|
self.auto_connect = auto_connect
|
|
37
34
|
|
|
38
35
|
async def __aenter__(self) -> "MCPSession":
|
|
@@ -84,4 +81,4 @@ class MCPSession:
|
|
|
84
81
|
Returns:
|
|
85
82
|
True if the connector is connected, False otherwise.
|
|
86
83
|
"""
|
|
87
|
-
return
|
|
84
|
+
return self.connector.is_connected
|
|
@@ -8,12 +8,13 @@ through different transport mechanisms.
|
|
|
8
8
|
from .base import ConnectionManager
|
|
9
9
|
from .sse import SseConnectionManager
|
|
10
10
|
from .stdio import StdioConnectionManager
|
|
11
|
+
from .streamable_http import StreamableHttpConnectionManager
|
|
11
12
|
from .websocket import WebSocketConnectionManager
|
|
12
13
|
|
|
13
14
|
__all__ = [
|
|
14
15
|
"ConnectionManager",
|
|
15
|
-
"HttpConnectionManager",
|
|
16
16
|
"StdioConnectionManager",
|
|
17
17
|
"WebSocketConnectionManager",
|
|
18
18
|
"SseConnectionManager",
|
|
19
|
+
"StreamableHttpConnectionManager",
|
|
19
20
|
]
|
mcp_use/task_managers/base.py
CHANGED
|
@@ -70,9 +70,7 @@ class ConnectionManager(Generic[T], ABC):
|
|
|
70
70
|
self._exception = None
|
|
71
71
|
|
|
72
72
|
# Create a task to establish and maintain the connection
|
|
73
|
-
self._task = asyncio.create_task(
|
|
74
|
-
self._connection_task(), name=f"{self.__class__.__name__}_task"
|
|
75
|
-
)
|
|
73
|
+
self._task = asyncio.create_task(self._connection_task(), name=f"{self.__class__.__name__}_task")
|
|
76
74
|
|
|
77
75
|
# Wait for the connection to be ready or fail
|
|
78
76
|
await self._ready_event.wait()
|
|
@@ -105,6 +103,14 @@ class ConnectionManager(Generic[T], ABC):
|
|
|
105
103
|
await self._done_event.wait()
|
|
106
104
|
logger.debug(f"{self.__class__.__name__} task completed")
|
|
107
105
|
|
|
106
|
+
def get_streams(self) -> T | None:
|
|
107
|
+
"""Get the current connection streams.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The current connection (typically a tuple of read_stream, write_stream) or None if not connected.
|
|
111
|
+
"""
|
|
112
|
+
return self._connection
|
|
113
|
+
|
|
108
114
|
async def _connection_task(self) -> None:
|
|
109
115
|
"""Run the connection task.
|
|
110
116
|
|
|
@@ -137,7 +143,7 @@ class ConnectionManager(Generic[T], ABC):
|
|
|
137
143
|
self._ready_event.set()
|
|
138
144
|
|
|
139
145
|
finally:
|
|
140
|
-
# Close the connection if it was
|
|
146
|
+
# Close the connection if it was established
|
|
141
147
|
if self._connection is not None:
|
|
142
148
|
try:
|
|
143
149
|
await self._close_connection()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Streamable HTTP connection management for MCP implementations.
|
|
3
|
+
|
|
4
|
+
This module provides a connection manager for streamable HTTP-based MCP connections
|
|
5
|
+
that ensures proper task isolation and resource cleanup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
12
|
+
|
|
13
|
+
from ..logging import logger
|
|
14
|
+
from .base import ConnectionManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StreamableHttpConnectionManager(ConnectionManager[tuple[Any, Any]]):
|
|
18
|
+
"""Connection manager for streamable HTTP-based MCP connections.
|
|
19
|
+
|
|
20
|
+
This class handles the proper task isolation for HTTP streaming connections
|
|
21
|
+
to prevent the "cancel scope in different task" error. It runs the http_stream_client
|
|
22
|
+
in a dedicated task and manages its lifecycle.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
url: str,
|
|
28
|
+
headers: dict[str, str] | None = None,
|
|
29
|
+
timeout: float = 5,
|
|
30
|
+
read_timeout: float = 60 * 5,
|
|
31
|
+
):
|
|
32
|
+
"""Initialize a new streamable HTTP connection manager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
url: The HTTP endpoint URL
|
|
36
|
+
headers: Optional HTTP headers
|
|
37
|
+
timeout: Timeout for HTTP operations in seconds
|
|
38
|
+
read_timeout: Timeout for HTTP read operations in seconds
|
|
39
|
+
"""
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.url = url
|
|
42
|
+
self.headers = headers or {}
|
|
43
|
+
self.timeout = timedelta(seconds=timeout)
|
|
44
|
+
self.read_timeout = timedelta(seconds=read_timeout)
|
|
45
|
+
self._http_ctx = None
|
|
46
|
+
|
|
47
|
+
async def _establish_connection(self) -> tuple[Any, Any]:
|
|
48
|
+
"""Establish a streamable HTTP connection.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A tuple of (read_stream, write_stream)
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
Exception: If connection cannot be established.
|
|
55
|
+
"""
|
|
56
|
+
# Create the context manager
|
|
57
|
+
self._http_ctx = streamablehttp_client(
|
|
58
|
+
url=self.url,
|
|
59
|
+
headers=self.headers,
|
|
60
|
+
timeout=self.timeout,
|
|
61
|
+
sse_read_timeout=self.read_timeout,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Enter the context manager. Ignoring the session id callback
|
|
65
|
+
read_stream, write_stream, _ = await self._http_ctx.__aenter__()
|
|
66
|
+
|
|
67
|
+
# Return the streams
|
|
68
|
+
return (read_stream, write_stream)
|
|
69
|
+
|
|
70
|
+
async def _close_connection(self) -> None:
|
|
71
|
+
"""Close the streamable HTTP connection."""
|
|
72
|
+
|
|
73
|
+
if self._http_ctx:
|
|
74
|
+
# Exit the context manager
|
|
75
|
+
try:
|
|
76
|
+
await self._http_ctx.__aexit__(None, None, None)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
# Only log if it's not a normal connection termination
|
|
79
|
+
logger.debug(f"Streamable HTTP context cleanup: {e}")
|
|
80
|
+
finally:
|
|
81
|
+
self._http_ctx = None
|
|
@@ -22,14 +22,17 @@ class WebSocketConnectionManager(ConnectionManager[tuple[Any, Any]]):
|
|
|
22
22
|
def __init__(
|
|
23
23
|
self,
|
|
24
24
|
url: str,
|
|
25
|
+
headers: dict[str, str] | None = None,
|
|
25
26
|
):
|
|
26
27
|
"""Initialize a new WebSocket connection manager.
|
|
27
28
|
|
|
28
29
|
Args:
|
|
29
30
|
url: The WebSocket URL to connect to
|
|
31
|
+
headers: Optional HTTP headers
|
|
30
32
|
"""
|
|
31
33
|
super().__init__()
|
|
32
34
|
self.url = url
|
|
35
|
+
self.headers = headers or {}
|
|
33
36
|
|
|
34
37
|
async def _establish_connection(self) -> tuple[Any, Any]:
|
|
35
38
|
"""Establish a WebSocket connection.
|
|
@@ -42,6 +45,8 @@ class WebSocketConnectionManager(ConnectionManager[tuple[Any, Any]]):
|
|
|
42
45
|
"""
|
|
43
46
|
logger.debug(f"Connecting to WebSocket: {self.url}")
|
|
44
47
|
# Create the context manager
|
|
48
|
+
# Note: The current MCP websocket_client implementation doesn't support headers
|
|
49
|
+
# If headers need to be passed, this would need to be updated when MCP supports it
|
|
45
50
|
self._ws_ctx = websocket_client(self.url)
|
|
46
51
|
|
|
47
52
|
# Enter the context manager
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseTelemetryEvent(ABC):
|
|
7
|
+
"""Base class for all telemetry events"""
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def name(self) -> str:
|
|
12
|
+
"""Event name for tracking"""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def properties(self) -> dict[str, Any]:
|
|
18
|
+
"""Event properties to send with the event"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MCPAgentExecutionEvent(BaseTelemetryEvent):
|
|
24
|
+
"""Comprehensive event for tracking complete MCP agent execution"""
|
|
25
|
+
|
|
26
|
+
# Execution method and context
|
|
27
|
+
execution_method: str # "run" or "astream"
|
|
28
|
+
query: str # The actual user query
|
|
29
|
+
success: bool
|
|
30
|
+
|
|
31
|
+
# Agent configuration
|
|
32
|
+
model_provider: str
|
|
33
|
+
model_name: str
|
|
34
|
+
server_count: int
|
|
35
|
+
server_identifiers: list[dict[str, str]]
|
|
36
|
+
total_tools_available: int
|
|
37
|
+
tools_available_names: list[str]
|
|
38
|
+
max_steps_configured: int
|
|
39
|
+
memory_enabled: bool
|
|
40
|
+
use_server_manager: bool
|
|
41
|
+
|
|
42
|
+
# Execution PARAMETERS
|
|
43
|
+
max_steps_used: int | None
|
|
44
|
+
manage_connector: bool
|
|
45
|
+
external_history_used: bool
|
|
46
|
+
|
|
47
|
+
# Execution results
|
|
48
|
+
steps_taken: int | None = None
|
|
49
|
+
tools_used_count: int | None = None
|
|
50
|
+
tools_used_names: list[str] | None = None
|
|
51
|
+
response: str | None = None # The actual response
|
|
52
|
+
execution_time_ms: int | None = None
|
|
53
|
+
error_type: str | None = None
|
|
54
|
+
|
|
55
|
+
# Context
|
|
56
|
+
conversation_history_length: int | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
return "mcp_agent_execution"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def properties(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
# Core execution info
|
|
66
|
+
"execution_method": self.execution_method,
|
|
67
|
+
"query": self.query,
|
|
68
|
+
"query_length": len(self.query),
|
|
69
|
+
"success": self.success,
|
|
70
|
+
# Agent configuration
|
|
71
|
+
"model_provider": self.model_provider,
|
|
72
|
+
"model_name": self.model_name,
|
|
73
|
+
"server_count": self.server_count,
|
|
74
|
+
"server_identifiers": self.server_identifiers,
|
|
75
|
+
"total_tools_available": self.total_tools_available,
|
|
76
|
+
"tools_available_names": self.tools_available_names,
|
|
77
|
+
"max_steps_configured": self.max_steps_configured,
|
|
78
|
+
"memory_enabled": self.memory_enabled,
|
|
79
|
+
"use_server_manager": self.use_server_manager,
|
|
80
|
+
# Execution parameters (always include, even if None)
|
|
81
|
+
"max_steps_used": self.max_steps_used,
|
|
82
|
+
"manage_connector": self.manage_connector,
|
|
83
|
+
"external_history_used": self.external_history_used,
|
|
84
|
+
# Execution results (always include, even if None)
|
|
85
|
+
"steps_taken": self.steps_taken,
|
|
86
|
+
"tools_used_count": self.tools_used_count,
|
|
87
|
+
"tools_used_names": self.tools_used_names,
|
|
88
|
+
"response": self.response,
|
|
89
|
+
"response_length": len(self.response) if self.response else None,
|
|
90
|
+
"execution_time_ms": self.execution_time_ms,
|
|
91
|
+
"error_type": self.error_type,
|
|
92
|
+
"conversation_history_length": self.conversation_history_length,
|
|
93
|
+
}
|