mirror of
https://github.com/hanzoai/mcp-remote-macos-use.git
synced 2026-10-30 18:28:08 +00:00
- Refactored package structure from mcp_remote_macos_use to mcp_remote_macos - Updated Docker configuration to use uv package manager per Hanzo standards - Fixed CI/CD workflows for multi-platform Docker image publishing - Added compose.yml for easier deployment - Added .env.example for configuration reference - All tests passing (32/32) - Ready for Docker Hub publishing with hanzoai/mcp-remote-macos:latest
342 lines
13 KiB
Python
342 lines
13 KiB
Python
import os
|
|
import sys
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock, AsyncMock
|
|
|
|
# Add src to path
|
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
# Import AFTER setting path
|
|
import src.mcp_remote_macos.server as server_module
|
|
from src.mcp_remote_macos.server import main
|
|
|
|
@pytest.fixture
|
|
def mock_env_vars():
|
|
"""Set up environment variables for testing."""
|
|
with patch.dict('os.environ', {
|
|
'MACOS_HOST': 'test-host',
|
|
'MACOS_PORT': '5900',
|
|
'MACOS_USERNAME': 'test-user',
|
|
'MACOS_PASSWORD': 'test-password',
|
|
'VNC_ENCRYPTION': 'prefer_on'
|
|
}):
|
|
yield
|
|
|
|
@pytest.fixture
|
|
def mock_mcp_server():
|
|
"""Create a mock MCP Server."""
|
|
with patch('src.mcp_remote_macos.server.Server') as mock_server_class:
|
|
mock_server = MagicMock()
|
|
mock_server_class.return_value = mock_server
|
|
|
|
# Configure list_resources to work as a decorator - it should accept a function and return it
|
|
mock_server.list_resources.return_value = lambda func: func
|
|
|
|
# Same for other decorator methods
|
|
mock_server.read_resource.return_value = lambda func: func
|
|
mock_server.list_tools.return_value = lambda func: func
|
|
mock_server.call_tool.return_value = lambda func: func
|
|
|
|
# Set up run method as async mock
|
|
mock_server.run = AsyncMock()
|
|
mock_server.get_capabilities.return_value = {"capabilities": "test"}
|
|
|
|
yield mock_server
|
|
|
|
@pytest.fixture
|
|
def mock_stdio_server():
|
|
"""Mock stdio_server context manager."""
|
|
with patch('src.mcp_remote_macos.server.mcp.server.stdio.stdio_server') as mock_stdio:
|
|
read_stream = MagicMock()
|
|
write_stream = MagicMock()
|
|
|
|
# Set up as async context manager
|
|
async_cm = AsyncMock()
|
|
async_cm.__aenter__.return_value = (read_stream, write_stream)
|
|
mock_stdio.return_value = async_cm
|
|
|
|
yield mock_stdio, read_stream, write_stream
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_server_initialization(mock_env_vars, mock_mcp_server, mock_stdio_server):
|
|
"""Test that the server initializes correctly."""
|
|
# Get a reference to the Server class mock, not the instance
|
|
server_class_mock = sys.modules['src.mcp_remote_macos.server'].Server
|
|
|
|
# Act
|
|
await main()
|
|
|
|
# Assert
|
|
server_class_mock.assert_called_once_with("remote-macos-client")
|
|
mock_mcp_server.run.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tools_returns_expected_tools(mock_env_vars, mock_mcp_server):
|
|
"""Test that list_tools returns the expected number of tools."""
|
|
# Instead of running main() which might get stuck, directly test the module's tools
|
|
# by importing the expected tools from the action_handlers module
|
|
|
|
# Create a mock Server instance
|
|
server = MagicMock()
|
|
|
|
# Get the handler function definition from the source code
|
|
handler = None
|
|
|
|
# Define a decorator replacement that captures the handler
|
|
def decorator_replacement(func):
|
|
nonlocal handler
|
|
handler = func
|
|
return func
|
|
|
|
# Apply our decorator to the handler function
|
|
with patch.object(server, 'list_tools', return_value=decorator_replacement):
|
|
# Manually create the handler as it would be in main()
|
|
@server.list_tools()
|
|
async def handle_list_tools() -> list:
|
|
"""Simulate the actual handler in main()"""
|
|
from src.action_handlers import (
|
|
handle_remote_macos_get_screen,
|
|
handle_remote_macos_mouse_scroll,
|
|
handle_remote_macos_mouse_click,
|
|
handle_remote_macos_mouse_double_click,
|
|
handle_remote_macos_mouse_move,
|
|
handle_remote_macos_send_keys,
|
|
)
|
|
|
|
# This is a simplified version of the actual handler
|
|
import mcp.types as types
|
|
return [
|
|
types.Tool(name="remote_macos_get_screen", description="Get screen", inputSchema={}),
|
|
types.Tool(name="remote_macos_mouse_scroll", description="Mouse scroll", inputSchema={}),
|
|
types.Tool(name="remote_macos_mouse_click", description="Mouse click", inputSchema={}),
|
|
types.Tool(name="remote_macos_mouse_double_click", description="Mouse double-click", inputSchema={}),
|
|
types.Tool(name="remote_macos_mouse_move", description="Mouse move", inputSchema={}),
|
|
types.Tool(name="remote_macos_send_keys", description="Send keys", inputSchema={}),
|
|
]
|
|
|
|
# Now we can call the handler directly
|
|
tools = await handler()
|
|
|
|
# Assert
|
|
assert len(tools) >= 6 # We have at least 6 tools defined
|
|
assert all(tool.name.startswith("remote_macos_") for tool in tools)
|
|
assert any(tool.name == "remote_macos_get_screen" for tool in tools)
|
|
assert any(tool.name == "remote_macos_mouse_click" for tool in tools)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('src.mcp_remote_macos.server.handle_remote_macos_get_screen')
|
|
async def test_call_tool_routes_to_correct_handler(mock_get_screen, mock_env_vars):
|
|
"""Test that call_tool routes to the correct handler function."""
|
|
# Create a mock Server instance
|
|
server = MagicMock()
|
|
|
|
# Get the handler function definition from the source code
|
|
handler = None
|
|
|
|
# Define a decorator replacement that captures the handler
|
|
def decorator_replacement(func):
|
|
nonlocal handler
|
|
handler = func
|
|
return func
|
|
|
|
# Apply our decorator to the handler function
|
|
with patch.object(server, 'call_tool', return_value=decorator_replacement):
|
|
# Manually create the handler as it would be in main()
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: dict = None) -> list:
|
|
"""Simulate the actual handler in main()"""
|
|
try:
|
|
if arguments is None:
|
|
arguments = {}
|
|
|
|
if name == "remote_macos_get_screen":
|
|
from src.mcp_remote_macos.server import handle_remote_macos_get_screen
|
|
return await handle_remote_macos_get_screen(arguments)
|
|
else:
|
|
raise ValueError(f"Unknown tool: {name}")
|
|
except Exception as e:
|
|
import mcp.types as types
|
|
return [types.TextContent(type="text", text=f"Error: {str(e)}")]
|
|
|
|
# Now we can call the handler directly
|
|
mock_get_screen.return_value = [MagicMock()]
|
|
await handler("remote_macos_get_screen", {})
|
|
|
|
# Assert
|
|
mock_get_screen.assert_called_once_with({})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_tool_handles_unknown_tool(mock_env_vars):
|
|
"""Test that call_tool handles unknown tools."""
|
|
# Create a mock Server instance
|
|
server = MagicMock()
|
|
|
|
# Get the handler function definition from the source code
|
|
handler = None
|
|
|
|
# Define a decorator replacement that captures the handler
|
|
def decorator_replacement(func):
|
|
nonlocal handler
|
|
handler = func
|
|
return func
|
|
|
|
# Apply our decorator to the handler function
|
|
with patch.object(server, 'call_tool', return_value=decorator_replacement):
|
|
# Manually create the handler as it would be in main()
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: dict = None) -> list:
|
|
"""Simulate the actual handler in main()"""
|
|
if name == "unknown_tool":
|
|
raise ValueError(f"Unknown tool: {name}")
|
|
return []
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError, match="Unknown tool: unknown_tool"):
|
|
await handler("unknown_tool", {})
|
|
|
|
@pytest.mark.asyncio
|
|
@patch('src.mcp_remote_macos.server.handle_remote_macos_get_screen')
|
|
async def test_call_tool_handles_exceptions(mock_get_screen, mock_env_vars):
|
|
"""Test that call_tool handles exceptions from handlers."""
|
|
# Create a mock Server instance
|
|
server = MagicMock()
|
|
|
|
# Get the handler function definition from the source code
|
|
handler = None
|
|
|
|
# Define a decorator replacement that captures the handler
|
|
def decorator_replacement(func):
|
|
nonlocal handler
|
|
handler = func
|
|
return func
|
|
|
|
# Apply our decorator to the handler function
|
|
with patch.object(server, 'call_tool', return_value=decorator_replacement):
|
|
# Manually create the handler as it would be in main()
|
|
@server.call_tool()
|
|
async def handle_call_tool(name: str, arguments: dict = None) -> list:
|
|
"""Simulate the actual handler in main()"""
|
|
try:
|
|
if arguments is None:
|
|
arguments = {}
|
|
|
|
if name == "remote_macos_get_screen":
|
|
from src.mcp_remote_macos.server import handle_remote_macos_get_screen
|
|
return await handle_remote_macos_get_screen(arguments)
|
|
else:
|
|
raise ValueError(f"Unknown tool: {name}")
|
|
except Exception as e:
|
|
import mcp.types as types
|
|
return [types.TextContent(type="text", text=f"Error: {str(e)}")]
|
|
|
|
# Arrange
|
|
error_msg = "Test error"
|
|
mock_get_screen.side_effect = Exception(error_msg)
|
|
|
|
# Act
|
|
result = await handler("remote_macos_get_screen", {})
|
|
|
|
# Assert
|
|
assert len(result) == 1
|
|
assert "Error: Test error" in result[0].text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_resources_returns_empty_list(mock_env_vars):
|
|
"""Test that list_resources returns an empty list."""
|
|
# Create a mock Server instance
|
|
server = MagicMock()
|
|
|
|
# Get the handler function definition from the source code
|
|
handler = None
|
|
|
|
# Define a decorator replacement that captures the handler
|
|
def decorator_replacement(func):
|
|
nonlocal handler
|
|
handler = func
|
|
return func
|
|
|
|
# Apply our decorator to the handler function
|
|
with patch.object(server, 'list_resources', return_value=decorator_replacement):
|
|
# Manually create the handler as it would be in main()
|
|
@server.list_resources()
|
|
async def handle_list_resources() -> list:
|
|
"""Simulate the actual handler in main()"""
|
|
return []
|
|
|
|
# Act
|
|
resources = await handler()
|
|
|
|
# Assert
|
|
assert isinstance(resources, list)
|
|
assert len(resources) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_resource_returns_empty_string(mock_env_vars):
|
|
"""Test that read_resource returns an empty string."""
|
|
# Create a mock Server instance
|
|
server = MagicMock()
|
|
|
|
# Get the handler function definition from the source code
|
|
handler = None
|
|
|
|
# Define a decorator replacement that captures the handler
|
|
def decorator_replacement(func):
|
|
nonlocal handler
|
|
handler = func
|
|
return func
|
|
|
|
# Apply our decorator to the handler function
|
|
with patch.object(server, 'read_resource', return_value=decorator_replacement):
|
|
# Manually create the handler as it would be in main()
|
|
@server.read_resource()
|
|
async def handle_read_resource(uri) -> str:
|
|
"""Simulate the actual handler in main()"""
|
|
return ""
|
|
|
|
# Act
|
|
content = await handler("any_uri")
|
|
|
|
# Assert
|
|
assert content == ""
|
|
|
|
def test_environment_variables_validation(mock_env_vars):
|
|
"""Test validation of environment variables."""
|
|
# This test implicitly tests that the module loads successfully with mock env vars
|
|
# If validation failed, an exception would be raised during module import
|
|
|
|
# Assert that environment variables were loaded
|
|
assert server_module.MACOS_HOST == 'test-host'
|
|
assert server_module.MACOS_PORT == 5900
|
|
assert server_module.MACOS_USERNAME == 'test-user'
|
|
assert server_module.MACOS_PASSWORD == 'test-password'
|
|
assert server_module.VNC_ENCRYPTION == 'prefer_on'
|
|
|
|
def test_missing_host_env_var():
|
|
"""Test that missing MACOS_HOST raises an error."""
|
|
# Arrange
|
|
with patch.dict('os.environ', {
|
|
'MACOS_HOST': '',
|
|
'MACOS_PASSWORD': 'test-password'
|
|
}):
|
|
# Act & Assert
|
|
with pytest.raises(ValueError, match="MACOS_HOST environment variable is required but not set"):
|
|
# Reimport to trigger validation
|
|
with patch.dict('sys.modules'):
|
|
if 'src.mcp_remote_macos.server' in sys.modules:
|
|
del sys.modules['src.mcp_remote_macos.server']
|
|
import src.mcp_remote_macos.server
|
|
|
|
def test_missing_password_env_var():
|
|
"""Test that missing MACOS_PASSWORD raises an error."""
|
|
# Arrange
|
|
with patch.dict('os.environ', {
|
|
'MACOS_HOST': 'test-host',
|
|
'MACOS_PASSWORD': ''
|
|
}):
|
|
# Act & Assert
|
|
with pytest.raises(ValueError, match="MACOS_PASSWORD environment variable is required but not set"):
|
|
# Reimport to trigger validation
|
|
with patch.dict('sys.modules'):
|
|
if 'src.mcp_remote_macos.server' in sys.modules:
|
|
del sys.modules['src.mcp_remote_macos.server']
|
|
import src.mcp_remote_macos.server |