Files
Luigi Pederzani b45d9663ec feat(python): introduce roots support in MCPClient and Server (#751)
* 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>
2026-01-12 11:16:09 +01:00

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()