mirror of
https://github.com/mcp-use/mcp-use.git
synced 2026-09-14 18:06:01 +08:00
b45d9663ec
* fix(python): expose client capabilities to MCP servers Add list_roots_callback support to Python connectors to properly advertise the roots capability during MCP initialization handshake. Previously, the Python library was not exposing any client capabilities because the list_roots_callback was not being passed to ClientSession. This fix ensures the roots capability is always advertised, bringing the Python library in line with the TypeScript implementation. Changes: - Add roots and list_roots_callback params to BaseConnector - Add get_roots() and set_roots() methods for managing roots - Pass list_roots_callback to ClientSession in all connectors - Export Root type from connectors module - Update unit tests to expect list_roots_callback parameter Closes MCP-1063 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(python): add roots and list_roots_callback params to connectors - Add roots and list_roots_callback parameters to StdioConnector, HttpConnector, and SandboxConnector constructors - Add comprehensive unit tests for client capabilities exposure - Tests verify roots capability, get_roots(), set_roots(), and that all callbacks are correctly passed to ClientSession 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: fix import sorting and update API documentation - Fix import sorting in test_client_capabilities.py (ruff) - Regenerate API documentation for updated connector classes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(python): add list_roots() method to server Context Allow MCP servers to request the list of roots from connected clients via ctx.list_roots(). This follows the same pattern as ctx.sample() and ctx.elicit() for server-to-client requests. * feat(python): add roots params to MCPClient and config - Add roots and list_roots_callback parameters to MCPClient.__init__, from_dict(), and from_config_file() methods - Update create_connector_from_config() to pass roots params to all connector types (Stdio, HTTP, Sandbox) - Allows clients to configure roots at the MCPClient level and have them propagate to all sessions * test(python): add get_client_roots tool to primitive server Add a tool that demonstrates the roots capability by requesting and returning the list of roots from the connected client. Used for integration testing of the roots feature. * test(python): add integration tests for roots capability Add comprehensive integration tests for the roots feature: - test_roots_capability: Verify server can request/receive roots - test_roots_empty_by_default: Verify empty roots when not provided - test_roots_with_custom_callback: Verify custom callback precedence - test_set_roots_updates_server_view: Verify dynamic root updates * test(python): update unit tests for roots parameters Update mock assertions in test_client.py to expect the new roots and list_roots_callback parameters in create_connector_from_config calls. * ci(python): add roots to primitive tests and README badge - Add roots to CI workflow primitive test matrix - Add roots to PR comment report primitives list - Add Roots badge to README Primitives row * feat(docs): enhance MCP client and server documentation for roots feature - Added `roots` and `list_roots_callback` parameters to MCP client initialization documentation. - Updated `create_connector_from_config` documentation to include new parameters. - Introduced `list_roots` method documentation in server context, detailing its usage and return values. - Improved overall clarity and completeness of API reference for better developer guidance. * feat(python): add get_client_roots tool to context example - Introduced a new tool, `get_client_roots`, to the context example, demonstrating how to retrieve and display the list of roots exposed by the client. - Updated documentation to include the new tool and its functionality, enhancing the example's coverage of context features. * feat(docs): add roots documentation for client and server - Introduced new documentation for the `roots` feature, detailing how clients can expose file system roots to MCP servers and how servers can access these roots. - Added examples for configuring roots in the MCP client and using the `list_roots()` method in the server context. - Enhanced overall clarity and usability of the documentation to support developers in implementing the roots functionality. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: pietrozullo <pietro.zullo@gmail.com>
344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""
|
|
Sandbox connector for MCP implementations.
|
|
|
|
This module provides a connector for communicating with MCP implementations
|
|
that are executed inside a sandbox environment (currently using E2B).
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
import httpx
|
|
from mcp import ClientSession
|
|
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
|
|
from mcp.types import Root
|
|
|
|
from mcp_use.client.middleware import CallbackClientSession, Middleware
|
|
from mcp_use.client.task_managers import SseConnectionManager
|
|
from mcp_use.logging import logger
|
|
|
|
# Import E2B SDK components (optional dependency)
|
|
try:
|
|
logger.debug("Attempting to import e2b_code_interpreter...")
|
|
from e2b_code_interpreter import CommandHandle, Sandbox
|
|
|
|
logger.debug("Successfully imported e2b_code_interpreter")
|
|
except ImportError as e:
|
|
logger.debug(f"Failed to import e2b_code_interpreter: {e}")
|
|
CommandHandle = None
|
|
Sandbox = None
|
|
|
|
from typing import NotRequired, TypedDict
|
|
|
|
from mcp_use.client.connectors.base import BaseConnector
|
|
|
|
|
|
class SandboxOptions(TypedDict):
|
|
"""Configuration options for sandbox execution.
|
|
|
|
This type defines the configuration options available when running
|
|
MCP servers in a sandboxed environment (e.g., using E2B).
|
|
"""
|
|
|
|
api_key: str
|
|
"""Direct API key for sandbox provider (e.g., E2B API key).
|
|
If not provided, will use E2B_API_KEY environment variable."""
|
|
|
|
sandbox_template_id: NotRequired[str]
|
|
"""Template ID for the sandbox environment.
|
|
Default: 'base'"""
|
|
|
|
supergateway_command: NotRequired[str]
|
|
"""Command to run supergateway.
|
|
Default: 'npx -y supergateway'"""
|
|
|
|
|
|
class SandboxConnector(BaseConnector):
|
|
"""Connector for MCP implementations running in a sandbox environment.
|
|
|
|
This connector runs a user-defined stdio command within a sandbox environment,
|
|
currently implemented using E2B, potentially wrapped by a utility like 'supergateway'
|
|
to expose its stdio.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
command: str,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
e2b_options: SandboxOptions | None = None,
|
|
timeout: float = 5,
|
|
sse_read_timeout: float = 60 * 5,
|
|
sampling_callback: SamplingFnT | None = None,
|
|
elicitation_callback: ElicitationFnT | None = None,
|
|
message_handler: MessageHandlerFnT | None = None,
|
|
logging_callback: LoggingFnT | None = None,
|
|
middleware: list[Middleware] | None = None,
|
|
roots: list[Root] | None = None,
|
|
list_roots_callback: ListRootsFnT | None = None,
|
|
):
|
|
"""Initialize a new sandbox connector.
|
|
|
|
Args:
|
|
command: The user's MCP server command to execute in the sandbox.
|
|
args: Command line arguments for the user's MCP server command.
|
|
env: Environment variables for the user's MCP server command.
|
|
e2b_options: Configuration options for the E2B sandbox environment.
|
|
See SandboxOptions for available options and defaults.
|
|
timeout: Timeout for the sandbox process in seconds.
|
|
sse_read_timeout: Timeout for the SSE connection in seconds.
|
|
sampling_callback: Optional sampling callback.
|
|
elicitation_callback: Optional elicitation callback.
|
|
message_handler: Optional callback to handle messages.
|
|
logging_callback: Optional callback to handle log messages.
|
|
middleware: Optional list of middleware.
|
|
roots: Optional initial list of roots to advertise to the server.
|
|
list_roots_callback: Optional custom callback to handle roots/list requests.
|
|
"""
|
|
super().__init__(
|
|
sampling_callback=sampling_callback,
|
|
elicitation_callback=elicitation_callback,
|
|
message_handler=message_handler,
|
|
logging_callback=logging_callback,
|
|
middleware=middleware,
|
|
roots=roots,
|
|
list_roots_callback=list_roots_callback,
|
|
)
|
|
if Sandbox is None:
|
|
raise ImportError(
|
|
"E2B SDK (e2b-code-interpreter) not found. Please install it with "
|
|
"'pip install mcp-use[e2b]' (or 'pip install e2b-code-interpreter')."
|
|
)
|
|
|
|
self.user_command = command
|
|
self.user_args = args or []
|
|
self.user_env = env or {}
|
|
|
|
_e2b_options = e2b_options or {}
|
|
|
|
self.api_key = _e2b_options.get("api_key") or os.environ.get("E2B_API_KEY")
|
|
if not self.api_key:
|
|
raise ValueError(
|
|
"E2B API key is required. Provide it via 'sandbox_options.api_key'"
|
|
" or the E2B_API_KEY environment variable."
|
|
)
|
|
|
|
self.sandbox_template_id = _e2b_options.get("sandbox_template_id", "base")
|
|
self.supergateway_cmd_parts = _e2b_options.get("supergateway_command", "npx -y supergateway")
|
|
|
|
self.sandbox: Sandbox | None = None
|
|
self.process: CommandHandle | None = None
|
|
self.client_session: ClientSession | None = None
|
|
self.errlog = sys.stderr
|
|
self.base_url: str | None = None
|
|
self._connected = False
|
|
self._connection_manager: SseConnectionManager | None = None
|
|
|
|
# SSE connection parameters
|
|
self.headers = {}
|
|
self.timeout = timeout
|
|
self.sse_read_timeout = sse_read_timeout
|
|
|
|
self.stdout_lines: list[str] = []
|
|
self.stderr_lines: list[str] = []
|
|
self._server_ready = asyncio.Event()
|
|
|
|
def _handle_stdout(self, data: str) -> None:
|
|
"""Handle stdout data from the sandbox process."""
|
|
self.stdout_lines.append(data)
|
|
logger.debug(f"[SANDBOX STDOUT] {data}", end="", flush=True)
|
|
|
|
def _handle_stderr(self, data: str) -> None:
|
|
"""Handle stderr data from the sandbox process."""
|
|
self.stderr_lines.append(data)
|
|
logger.debug(f"[SANDBOX STDERR] {data}", file=self.errlog, end="", flush=True)
|
|
|
|
async def wait_for_server_response(self, base_url: str, timeout: int = 30) -> bool:
|
|
"""Wait for the server to respond to HTTP requests.
|
|
Args:
|
|
base_url: The base URL to check for server readiness
|
|
timeout: Maximum time to wait in seconds
|
|
Returns:
|
|
True if server is responding, raises TimeoutError otherwise
|
|
"""
|
|
logger.info(f"Waiting for server at {base_url} to respond...")
|
|
sys.stdout.flush()
|
|
|
|
start_time = time.time()
|
|
ping_url = f"{base_url}/sse"
|
|
|
|
# Try to connect to the server
|
|
while time.time() - start_time < timeout:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=2) as client:
|
|
try:
|
|
# First try the endpoint
|
|
response = await client.get(ping_url)
|
|
if response.status_code == 200:
|
|
elapsed = time.time() - start_time
|
|
logger.info(f"Server is ready! SSE endpoint responded with 200 after {elapsed:.1f}s")
|
|
return True
|
|
except Exception:
|
|
# If sse endpoint doesn't work, try the base URL
|
|
response = await client.get(base_url)
|
|
if response.status_code < 500: # Accept any non-server error
|
|
elapsed = time.time() - start_time
|
|
logger.info(
|
|
f"Server is ready! Base URL responded with {response.status_code} after {elapsed:.1f}s"
|
|
)
|
|
return True
|
|
except Exception:
|
|
# Wait a bit before trying again
|
|
await asyncio.sleep(0.5)
|
|
continue
|
|
|
|
# If we get here, the request failed
|
|
await asyncio.sleep(0.5)
|
|
|
|
# Log status every 5 seconds
|
|
elapsed = time.time() - start_time
|
|
if int(elapsed) % 5 == 0:
|
|
logger.info(f"Still waiting for server to respond... ({elapsed:.1f}s elapsed)")
|
|
sys.stdout.flush()
|
|
|
|
# If we get here, we timed out
|
|
raise TimeoutError(f"Timeout waiting for server to respond (waited {timeout} seconds)")
|
|
|
|
async def connect(self):
|
|
"""Connect to the sandbox and start the MCP server."""
|
|
|
|
if self._connected:
|
|
logger.debug("Already connected to MCP implementation")
|
|
return
|
|
|
|
logger.debug("Connecting to MCP implementation in sandbox")
|
|
|
|
try:
|
|
# Create and start the sandbox
|
|
self.sandbox = Sandbox(
|
|
template=self.sandbox_template_id,
|
|
api_key=self.api_key,
|
|
)
|
|
|
|
# Get the host for the sandbox
|
|
host = self.sandbox.get_host(3000)
|
|
self.base_url = f"https://{host}".rstrip("/")
|
|
|
|
# Append command with args
|
|
command = f"{self.user_command} {' '.join(self.user_args)}"
|
|
|
|
# Construct the full command with supergateway
|
|
full_command = f'{self.supergateway_cmd_parts} \
|
|
--base-url {self.base_url} \
|
|
--port 3000 \
|
|
--cors \
|
|
--stdio "{command}"'
|
|
|
|
logger.debug(f"Full command: {full_command}")
|
|
|
|
# Start the process in the sandbox with our stdout/stderr handlers
|
|
self.process: CommandHandle = self.sandbox.commands.run(
|
|
full_command,
|
|
envs=self.user_env,
|
|
timeout=1000 * 60 * 10, # 10 minutes timeout
|
|
background=True,
|
|
on_stdout=self._handle_stdout,
|
|
on_stderr=self._handle_stderr,
|
|
)
|
|
|
|
# Wait for the server to be ready
|
|
await self.wait_for_server_response(self.base_url, timeout=30)
|
|
logger.debug("Initializing connection manager...")
|
|
|
|
# Create the SSE connection URL
|
|
sse_url = f"{self.base_url}/sse"
|
|
|
|
# Create and start the connection manager
|
|
self._connection_manager = SseConnectionManager(sse_url, self.headers, self.timeout, self.sse_read_timeout)
|
|
read_stream, write_stream = await self._connection_manager.start()
|
|
|
|
# Create the client session
|
|
raw_client_session = ClientSession(
|
|
read_stream,
|
|
write_stream,
|
|
sampling_callback=self.sampling_callback,
|
|
elicitation_callback=self.elicitation_callback,
|
|
list_roots_callback=self.list_roots_callback,
|
|
message_handler=self._internal_message_handler,
|
|
logging_callback=self.logging_callback,
|
|
client_info=self.client_info,
|
|
)
|
|
await raw_client_session.__aenter__()
|
|
|
|
# Wrap with middleware
|
|
self.client_session = CallbackClientSession(
|
|
raw_client_session, self.public_identifier, self.middleware_manager
|
|
)
|
|
|
|
# Mark as connected
|
|
self._connected = True
|
|
logger.debug(f"Successfully connected to MCP implementation via HTTP/SSE: {self.base_url}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to MCP implementation: {e}")
|
|
|
|
# Clean up any resources if connection failed
|
|
await self._cleanup_resources()
|
|
|
|
raise e
|
|
|
|
async def _cleanup_resources(self) -> None:
|
|
"""Clean up all resources associated with this connector, including the sandbox.
|
|
This method extends the base implementation to also terminate the sandbox instance
|
|
and clean up any processes running in the sandbox.
|
|
"""
|
|
logger.debug("Cleaning up sandbox resources")
|
|
|
|
# Terminate any running process
|
|
if self.process:
|
|
try:
|
|
logger.debug("Terminating sandbox process")
|
|
self.process.kill()
|
|
except Exception as e:
|
|
logger.warning(f"Error terminating sandbox process: {e}")
|
|
finally:
|
|
self.process = None
|
|
|
|
# Close the sandbox
|
|
if self.sandbox:
|
|
try:
|
|
logger.debug("Closing sandbox instance")
|
|
self.sandbox.kill()
|
|
logger.debug("Sandbox instance closed successfully")
|
|
except Exception as e:
|
|
logger.warning(f"Error closing sandbox: {e}")
|
|
finally:
|
|
self.sandbox = None
|
|
|
|
# Then call the parent method to clean up the rest
|
|
await super()._cleanup_resources()
|
|
|
|
# Clear any collected output
|
|
self.stdout_lines = []
|
|
self.stderr_lines = []
|
|
self.base_url = None
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Close the connection to the MCP implementation."""
|
|
if not self._connected:
|
|
logger.debug("Not connected to MCP implementation")
|
|
return
|
|
|
|
logger.debug("Disconnecting from MCP implementation")
|
|
await self._cleanup_resources()
|
|
self._connected = False
|
|
logger.debug("Disconnected from MCP implementation")
|
|
|
|
@property
|
|
def public_identifier(self) -> str:
|
|
"""Get the identifier for the connector."""
|
|
args_str = " ".join(self.user_args) if self.user_args else ""
|
|
return f"sandbox:{self.user_command} {args_str}".strip()
|