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

390 lines
15 KiB
Python

"""
Unit tests for the StdioConnector class.
"""
import sys
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
import pytest
from mcp.types import CallToolResult, Tool
from pydantic import AnyUrl
from mcp_use.client.connectors.stdio import StdioConnector
from mcp_use.client.middleware.middleware import CallbackClientSession
from mcp_use.client.task_managers.stdio import StdioConnectionManager
@pytest.fixture(autouse=True)
def mock_logger():
"""Mock the logger to prevent errors during tests."""
with patch("mcp_use.client.connectors.base.logger") as mock_logger:
yield mock_logger
class TestStdioConnectorInitialization:
"""Tests for StdioConnector initialization."""
def test_init_default(self):
"""Test initialization with default parameters."""
connector = StdioConnector()
assert connector.command == "npx"
assert connector.args == []
assert connector.env is None
assert connector.errlog == sys.stderr
assert connector.client_session is None
assert connector._connection_manager is None
assert connector._tools is None
assert connector._connected is False
def test_init_with_params(self):
"""Test initialization with custom parameters."""
command = "custom-command"
args = ["--arg1", "--arg2"]
env = {"ENV_VAR": "value"}
errlog = Mock()
connector = StdioConnector(command, args, env, errlog)
assert connector.command == command
assert connector.args == args
assert connector.env == env
assert connector.errlog == errlog
assert connector.client_session is None
assert connector._connection_manager is None
assert connector._tools is None
assert connector._connected is False
class TestStdioConnectorConnection:
"""Tests for StdioConnector connection methods."""
@pytest.mark.asyncio
@patch("mcp_use.client.connectors.stdio.StdioConnectionManager")
@patch("mcp_use.client.connectors.stdio.ClientSession")
@patch("mcp_use.client.connectors.stdio.logger")
async def test_connect(self, mock_stdio_logger, mock_client_session, mock_connection_manager):
"""Test connecting to the MCP implementation."""
# Setup mocks
mock_manager_instance = Mock(spec=StdioConnectionManager)
mock_manager_instance.start = AsyncMock(return_value=("read_stream", "write_stream"))
mock_connection_manager.return_value = mock_manager_instance
mock_client_instance = Mock()
mock_client_instance.__aenter__ = AsyncMock()
mock_client_session.return_value = mock_client_instance
# Create connector and connect
connector = StdioConnector(command="test-command", args=["--test"])
await connector.connect()
# Verify connection manager creation
mock_connection_manager.assert_called_once()
mock_manager_instance.start.assert_called_once()
# Verify client session creation
mock_client_session.assert_called_once_with(
"read_stream",
"write_stream",
sampling_callback=None,
elicitation_callback=None,
list_roots_callback=ANY,
message_handler=ANY,
logging_callback=None,
client_info=ANY,
)
mock_client_instance.__aenter__.assert_called_once()
# Verify state
assert connector._connected is True
assert isinstance(connector.client_session, CallbackClientSession)
assert connector._connection_manager == mock_manager_instance
@pytest.mark.asyncio
@patch("mcp_use.client.task_managers.stdio.logger")
async def test_connect_already_connected(self, mock_stdio_logger):
"""Test connecting when already connected."""
connector = StdioConnector()
connector._connected = True
await connector.connect()
# Verify no connection established since already connected
assert connector._connection_manager is None
assert connector.client_session is None
@pytest.mark.asyncio
@patch("mcp_use.client.connectors.stdio.StdioConnectionManager")
@patch("mcp_use.client.connectors.stdio.ClientSession")
@patch("mcp_use.client.connectors.stdio.logger")
@patch("mcp_use.client.connectors.base.logger")
async def test_connect_error(
self,
mock_base_logger,
mock_stdio_logger,
mock_client_session,
mock_connection_manager,
):
"""Test connection error handling."""
# Setup mocks to raise an exception
mock_manager_instance = Mock(spec=StdioConnectionManager)
mock_manager_instance.start = AsyncMock(side_effect=Exception("Connection error"))
mock_connection_manager.return_value = mock_manager_instance
mock_manager_instance.stop = AsyncMock()
# Create connector and attempt to connect
connector = StdioConnector()
# Expect the exception to be re-raised
with pytest.raises(Exception, match="Connection error"):
await connector.connect()
# Verify resources were cleaned up
assert connector._connected is False
assert connector.client_session is None
# Mock should be called to clean up resources
mock_manager_instance.stop.assert_called_once()
@pytest.mark.asyncio
async def test_disconnect_not_connected(self):
"""Test disconnecting when not connected."""
connector = StdioConnector()
connector._connected = False
await connector.disconnect()
# Should do nothing since not connected
assert connector._connected is False
@pytest.mark.asyncio
async def test_disconnect(self):
"""Test disconnecting from MCP implementation."""
connector = StdioConnector()
connector._connected = True
# Mock the _cleanup_resources method to replace the actual method
connector._cleanup_resources = AsyncMock()
# Disconnect
await connector.disconnect()
# Verify _cleanup_resources was called
connector._cleanup_resources.assert_called_once()
# Verify state
assert connector._connected is False
class TestStdioConnectorOperations:
"""Tests for StdioConnector operations."""
@pytest.mark.asyncio
async def test_initialize(self):
"""Test initializing the MCP session."""
connector = StdioConnector()
# Setup mocks
mock_client = MagicMock()
# Mock client.initialize() to return capabilities
mock_init_result = MagicMock()
mock_init_result.status = "success" # Or whatever structure the Stdio connector expects to return
mock_init_result.capabilities = MagicMock(tools=True, resources=True, prompts=True)
mock_client.initialize = AsyncMock(return_value=mock_init_result)
# Mocks for list_tools, list_resources, list_prompts (already well-structured)
mock_tools_response = MagicMock(tools=[MagicMock(spec=Tool)])
mock_client.list_tools = AsyncMock(return_value=mock_tools_response)
mock_list_resources_response = MagicMock()
mock_list_resources_response.resources = []
mock_client.list_resources = AsyncMock(return_value=mock_list_resources_response)
# Mock list_prompts (called by base initialize)
mock_list_prompts_response = MagicMock()
mock_list_prompts_response.prompts = [] # Assumes a .prompts attribute
mock_client.list_prompts = AsyncMock(return_value=mock_list_prompts_response)
connector.client_session = mock_client
# IMPORTANT: Mark as connected to prevent _ensure_connected from trying to reconnect
connector._connected = True
# Initialize
result_session_info = await connector.initialize()
# Verify calls
mock_client.initialize.assert_called_once()
mock_client.list_tools.assert_called_once()
mock_client.list_resources.assert_called_once()
mock_client.list_prompts.assert_called_once()
# Verify connector state and return value
assert result_session_info == mock_init_result
assert connector._tools is not None
assert len(connector._tools) == 1
assert connector._resources is not None
assert len(connector._resources) == 0 # Based on current mock for list_resources
assert connector._prompts is not None
assert len(connector._prompts) == 0 # Based on current mock for list_prompts
@pytest.mark.asyncio
async def test_initialize_no_client(self):
"""Test initializing without a client."""
connector = StdioConnector()
connector.client_session = None
# Expect RuntimeError
with pytest.raises(RuntimeError, match="MCP client is not connected"):
await connector.initialize()
def test_tools_property(self):
"""Test the tools property."""
connector = StdioConnector()
mock_tools = [Mock(spec=Tool)]
connector._tools = mock_tools
# Get tools
tools = connector.tools
assert tools == mock_tools
def test_tools_property_not_initialized(self):
"""Test the tools property when not initialized."""
connector = StdioConnector()
connector._tools = None
# Expect RuntimeError
with pytest.raises(RuntimeError, match="MCP client is not initialized"):
_ = connector.tools
@pytest.mark.asyncio
async def test_call_tool(self):
"""Test calling an MCP tool."""
connector = StdioConnector()
mock_client = Mock()
mock_result = Mock(spec=CallToolResult)
mock_client.call_tool = AsyncMock(return_value=mock_result)
connector.client_session = mock_client
# Mock the connection state to simulate being properly connected
connector._connected = True
# Mock a connection manager to simulate active connection
mock_connection_manager = Mock()
mock_task = Mock()
mock_task.done.return_value = False # Task is still running (connection active)
mock_connection_manager._task = mock_task
mock_connection_manager.get_streams.return_value = ("read_stream", "write_stream")
connector._connection_manager = mock_connection_manager
# Call tool
tool_name = "test_tool"
arguments = {"param": "value"}
result = await connector.call_tool(tool_name, arguments)
# Verify
mock_client.call_tool.assert_called_once_with(tool_name, arguments, None)
assert result == mock_result
@pytest.mark.asyncio
async def test_call_tool_no_client(self):
"""Test calling a tool without a client."""
connector = StdioConnector()
connector.client_session = None
# Expect RuntimeError
with pytest.raises(RuntimeError, match="MCP client is not connected"):
await connector.call_tool("test_tool", {})
@pytest.mark.asyncio
async def test_list_resources(self):
"""Test listing resources."""
connector = StdioConnector()
mock_client = Mock()
mock_result = MagicMock()
mock_result.resources = [MagicMock()]
mock_client.list_resources = AsyncMock(return_value=mock_result)
connector.client_session = mock_client
# Mark as connected to prevent _ensure_connected from trying to reconnect
connector._connected = True
# List resources
result = await connector.list_resources()
# Verify
mock_client.list_resources.assert_called_once()
assert result == mock_result.resources
@pytest.mark.asyncio
async def test_list_resources_no_client(self):
"""Test listing resources without a client."""
connector = StdioConnector()
connector.client_session = None
# Expect RuntimeError
with pytest.raises(RuntimeError, match="MCP client is not connected"):
await connector.list_resources()
@pytest.mark.asyncio
async def test_read_resource(self):
"""Test reading a resource."""
# Mocked return for connector.client.read_resource().
# Needs the structure StdioConnector.read_resource expects.
mock_client_return_value = MagicMock() # spec=ReadResourceResult optional with MagicMock if defining manually
# Define the nested structure
content_item_mock = MagicMock()
content_item_mock.content = b"test content"
# Note camelCase.
# Adjust if StdioConnector expects a different attribute name for mimetype.
content_item_mock.mimeType = "text/plain"
result_attribute_mock = MagicMock() # This is for the .result attribute
result_attribute_mock.contents = [content_item_mock] # .contents is a list of these items
mock_client_return_value.result = result_attribute_mock
# Setup the connector and mock client
connector = StdioConnector()
# Mock the Stdio client and its methods.
mock_stdio_client = MagicMock()
mock_stdio_client.read_resource = AsyncMock(return_value=mock_client_return_value)
# If other client methods are called by StdioConnector.read_resource,
# ensure they are AsyncMocks.
connector.client_session = mock_stdio_client
# Mark as connected to prevent _ensure_connected from trying to reconnect
connector._connected = True
# Mock a connection manager to simulate active connection
mock_connection_manager = Mock()
mock_task = Mock()
mock_task.done.return_value = False # Task is still running (connection active)
mock_connection_manager._task = mock_task
mock_connection_manager.get_streams.return_value = ("read_stream", "write_stream")
connector._connection_manager = mock_connection_manager
# Act: Call the connector's method
# connector.read_resource returns a ReadResourceResult object.
read_resource_result = await connector.read_resource(uri=AnyUrl("file:///test/resource"))
# Assert: Verify the outcome and client interaction
# Assert attributes of ReadResourceResult against mock_client_return_value.
assert read_resource_result.result is not None
assert read_resource_result.result.contents is not None
assert len(read_resource_result.result.contents) == 1
assert read_resource_result.result.contents[0].content == b"test content"
assert read_resource_result.result.contents[0].mimeType == "text/plain"
mock_stdio_client.read_resource.assert_called_once_with(AnyUrl("file:///test/resource"))
@pytest.mark.asyncio
async def test_read_resource_no_client(self):
"""Test reading a resource without a client."""
connector = StdioConnector()
connector.client_session = None
# Expect RuntimeError
with pytest.raises(RuntimeError, match="MCP client is not connected"):
await connector.read_resource("test_uri")