Files
Sean P. KaneandGitHub 2bef958994 Release prep 0.6.2 (#39)
* docs: note macro repo move

* chore: bump workbench to 0.6.2

* docs: update README.md

* docs: improve release notes

* fix: better icons and minor tweaks

* fix: Add reverse test
2026-01-18 21:48:41 -08:00

540 lines
19 KiB
Python

"""Tests for the main server module."""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from freecad_mcp.config import FreecadMode
# Default argv for main() tests to avoid argparse errors
DEFAULT_ARGV: list[str] = ["freecad-mcp"]
class TestGetInstanceId:
"""Tests for get_instance_id function."""
def test_returns_string(self):
"""Instance ID should be a string."""
from freecad_mcp.server import get_instance_id
instance_id = get_instance_id()
assert isinstance(instance_id, str)
def test_returns_uuid_format(self):
"""Instance ID should be a valid UUID format."""
from freecad_mcp.server import get_instance_id
instance_id = get_instance_id()
# UUID format: 8-4-4-4-12 hex characters
parts = instance_id.split("-")
assert len(parts) == 5
assert len(parts[0]) == 8
assert len(parts[1]) == 4
assert len(parts[2]) == 4
assert len(parts[3]) == 4
assert len(parts[4]) == 12
def test_consistent_across_calls(self):
"""Instance ID should be consistent within a process."""
from freecad_mcp.server import get_instance_id
id1 = get_instance_id()
id2 = get_instance_id()
assert id1 == id2
class TestGetBridge:
"""Tests for get_bridge function."""
@pytest.mark.asyncio
async def test_raises_when_not_initialized(self):
"""Should raise RuntimeError when bridge is not initialized."""
import freecad_mcp.server as server_module
# Save original bridge
original_bridge = server_module._bridge
try:
# Set bridge to None
server_module._bridge = None
with pytest.raises(RuntimeError, match="not initialized"):
await server_module.get_bridge()
finally:
# Restore original bridge
server_module._bridge = original_bridge
@pytest.mark.asyncio
async def test_returns_bridge_when_initialized(self):
"""Should return bridge when it's initialized."""
import freecad_mcp.server as server_module
# Save original bridge
original_bridge = server_module._bridge
try:
# Set up mock bridge
mock_bridge = MagicMock()
server_module._bridge = mock_bridge
bridge = await server_module.get_bridge()
assert bridge is mock_bridge
finally:
# Restore original bridge
server_module._bridge = original_bridge
class TestLifespan:
"""Tests for the lifespan context manager."""
@pytest.mark.asyncio
async def test_embedded_mode_initialization(self):
"""Should initialize embedded bridge in embedded mode."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.EMBEDDED
mock_config.freecad_path = None
mock_embedded_bridge = AsyncMock()
mock_embedded_bridge.get_freecad_version = AsyncMock(
return_value={"version": "1.0.0", "gui_available": False}
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.embedded.EmbeddedBridge",
return_value=mock_embedded_bridge,
) as mock_embedded_class,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
# Bridge should be initialized
mock_embedded_class.assert_called_once_with(freecad_path=None)
mock_embedded_bridge.connect.assert_called_once()
# After exiting, disconnect should be called
mock_embedded_bridge.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_xmlrpc_mode_initialization(self):
"""Should initialize XML-RPC bridge in xmlrpc mode."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.XMLRPC
mock_config.socket_host = "localhost"
mock_config.xmlrpc_port = 9875
mock_xmlrpc_bridge = AsyncMock()
mock_xmlrpc_bridge.get_freecad_version = AsyncMock(
return_value={"version": "1.0.0", "gui_available": True}
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.xmlrpc.XmlRpcBridge",
return_value=mock_xmlrpc_bridge,
) as mock_xmlrpc_class,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
mock_xmlrpc_class.assert_called_once_with(host="localhost", port=9875)
mock_xmlrpc_bridge.connect.assert_called_once()
mock_xmlrpc_bridge.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_socket_mode_initialization(self):
"""Should initialize socket bridge in socket mode."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.SOCKET
mock_config.socket_host = "localhost"
mock_config.socket_port = 9876
mock_socket_bridge = AsyncMock()
mock_socket_bridge.get_freecad_version = AsyncMock(
return_value={"version": "1.0.0", "gui_available": True}
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.socket.SocketBridge",
return_value=mock_socket_bridge,
) as mock_socket_class,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
mock_socket_class.assert_called_once_with(host="localhost", port=9876)
mock_socket_bridge.connect.assert_called_once()
mock_socket_bridge.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_version_fetch_failure_logs_warning(self):
"""Should log warning if version fetch fails."""
import freecad_mcp.server as server_module
mock_config = MagicMock()
mock_config.mode = FreecadMode.EMBEDDED
mock_config.freecad_path = None
mock_bridge = AsyncMock()
mock_bridge.get_freecad_version = AsyncMock(
side_effect=Exception("Connection failed")
)
with (
patch.object(server_module, "get_config", return_value=mock_config),
patch(
"freecad_mcp.bridge.embedded.EmbeddedBridge",
return_value=mock_bridge,
),
patch.object(server_module.logger, "warning") as mock_warning,
):
mock_server = MagicMock()
async with server_module.lifespan(mock_server):
# Warning should be logged
mock_warning.assert_called_once()
assert "Could not get FreeCAD version" in str(mock_warning.call_args)
class TestRegisterAllComponents:
"""Tests for register_all_components function."""
def test_registers_tools(self):
"""Should register all tool categories."""
from freecad_mcp.server import mcp
# The function is called at module load, but we can verify
# that the mcp instance exists and has tools registered
assert mcp is not None
assert mcp.name == "freecad-mcp"
class TestMain:
"""Tests for main function."""
def test_main_prints_instance_id(self):
"""Main should print instance ID on startup when FREECAD_MCP_TESTING is set."""
import freecad_mcp.server as server_module
from freecad_mcp.config import TransportType
mock_config = MagicMock()
mock_config.log_level = "INFO"
mock_config.mode = FreecadMode.EMBEDDED
mock_config.transport = TransportType.STDIO
with (
patch.object(sys, "argv", DEFAULT_ARGV),
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print") as mock_print,
patch.dict(os.environ, {"FREECAD_MCP_TESTING": "1"}),
):
# Mock run to exit immediately
mock_run.return_value = None
server_module.main()
# Check that instance ID was printed to stderr (not stdout, to avoid
# corrupting JSON-RPC in stdio mode)
print_calls = [str(call) for call in mock_print.call_args_list]
assert any("FREECAD_MCP_INSTANCE_ID=" in call for call in print_calls)
# Verify it was printed to stderr
instance_id_call = next(
call
for call in mock_print.call_args_list
if "FREECAD_MCP_INSTANCE_ID=" in str(call)
)
assert instance_id_call.kwargs.get("file") == sys.stderr
def test_main_no_instance_id_without_testing_env(self):
"""Main should NOT print instance ID when FREECAD_MCP_TESTING is unset."""
import freecad_mcp.server as server_module
from freecad_mcp.config import TransportType
mock_config = MagicMock()
mock_config.log_level = "INFO"
mock_config.mode = FreecadMode.EMBEDDED
mock_config.transport = TransportType.STDIO
# Ensure FREECAD_MCP_TESTING is not set
env_without_testing = {
k: v for k, v in os.environ.items() if k != "FREECAD_MCP_TESTING"
}
with (
patch.object(sys, "argv", DEFAULT_ARGV),
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print") as mock_print,
patch.dict(os.environ, env_without_testing, clear=True),
):
# Mock run to exit immediately
mock_run.return_value = None
server_module.main()
# Check that instance ID was NOT printed
print_calls = [str(call) for call in mock_print.call_args_list]
assert not any("FREECAD_MCP_INSTANCE_ID=" in call for call in print_calls)
def test_main_http_transport(self):
"""Main should start HTTP transport when configured."""
import freecad_mcp.server as server_module
from freecad_mcp.config import TransportType
mock_config = MagicMock()
mock_config.log_level = "INFO"
mock_config.mode = FreecadMode.EMBEDDED
mock_config.transport = TransportType.HTTP
mock_config.http_port = 8080
with (
patch.object(sys, "argv", DEFAULT_ARGV),
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print"),
):
server_module.main()
# Should call run with HTTP transport settings
mock_run.assert_called_once()
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs.get("transport") == "streamable-http"
assert call_kwargs.get("port") == 8080
def test_main_stdio_transport(self):
"""Main should start stdio transport by default."""
import freecad_mcp.server as server_module
from freecad_mcp.config import TransportType
mock_config = MagicMock()
mock_config.log_level = "INFO"
mock_config.mode = FreecadMode.EMBEDDED
mock_config.transport = TransportType.STDIO
with (
patch.object(sys, "argv", DEFAULT_ARGV),
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print"),
):
server_module.main()
# Should call run without transport arguments (stdio is default)
mock_run.assert_called_once_with()
class TestStdioProtocolCleanliness:
"""Tests to ensure stdio mode produces clean JSON-RPC output.
These tests verify that stdout contains ONLY valid JSON-RPC messages,
with no debug output, print statements, or other text that would corrupt
the MCP protocol. This is critical for compatibility with MCP clients
like Claude Desktop.
The bug this catches: Any print() to stdout (instead of stderr) will
cause MCP clients to fail with JSON parse errors like:
"Unexpected token 'F', "FREECAD_MC"... is not valid JSON"
"""
def test_no_stdout_before_jsonrpc(self):
"""Verify no stray output appears on stdout before JSON-RPC messages.
This test spawns the MCP server as a subprocess and validates that
ALL stdout output is valid JSON-RPC. Any non-JSON output on stdout
will corrupt the MCP protocol.
"""
import json
import os
import subprocess
import time
# Start the MCP server process
# Use a non-existent FreeCAD host so it won't actually connect
proc = subprocess.Popen( # noqa: S603
[
sys.executable,
"-m",
"freecad_mcp.server",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
**dict(os.environ),
"FREECAD_MODE": "xmlrpc",
"FREECAD_XMLRPC_PORT": "59999", # Non-existent port
"FREECAD_SOCKET_HOST": "localhost",
},
)
try:
# Ensure pipes are available
assert proc.stdin is not None
assert proc.stdout is not None
# Send a minimal MCP initialize request
init_request = {
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0.0"},
},
}
request_bytes = json.dumps(init_request).encode() + b"\n"
proc.stdin.write(request_bytes)
proc.stdin.flush()
# Give the server a moment to respond
time.sleep(0.5)
# Set stdout to non-blocking mode
os.set_blocking(proc.stdout.fileno(), False)
# Read any available stdout
stdout_data = b""
try:
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
stdout_data += chunk
except (BlockingIOError, TypeError):
pass # No more data available
# Validate that ALL stdout is valid JSON-RPC
# Each line should be a valid JSON object
stdout_text = stdout_data.decode("utf-8", errors="replace")
lines = [line.strip() for line in stdout_text.split("\n") if line.strip()]
for line in lines:
try:
parsed = json.loads(line)
# Should be a JSON-RPC message (has jsonrpc field)
assert "jsonrpc" in parsed, (
f"stdout contains JSON but not JSON-RPC: {line[:100]}"
)
except json.JSONDecodeError as e:
pytest.fail(
f"stdout contains non-JSON output which corrupts MCP protocol!\n"
f"Invalid line: {line[:200]!r}\n"
f"JSON error: {e}\n\n"
f"All stdout lines:\n{stdout_text[:1000]}"
)
finally:
# Clean up the process
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
def test_instance_id_on_stderr_not_stdout(self) -> None:
"""Verify FREECAD_MCP_INSTANCE_ID is printed to stderr, not stdout.
The instance ID must go to stderr because stdout is reserved for
JSON-RPC messages in stdio mode.
"""
import os
import subprocess
import time
proc = subprocess.Popen( # noqa: S603
[
sys.executable,
"-m",
"freecad_mcp.server",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
**dict(os.environ),
"FREECAD_MODE": "xmlrpc",
"FREECAD_XMLRPC_PORT": "59999",
"FREECAD_SOCKET_HOST": "localhost",
"FREECAD_MCP_TESTING": "1", # Enable stderr instance ID output
},
)
try:
# Ensure pipes are available
assert proc.stdout is not None
assert proc.stderr is not None
# Set pipes to non-blocking mode
os.set_blocking(proc.stdout.fileno(), False)
os.set_blocking(proc.stderr.fileno(), False)
# Poll for stderr content with timeout (CI systems can be slower)
stderr_data = b""
max_wait = 5.0 # 5 second timeout
poll_interval = 0.1
elapsed = 0.0
while elapsed < max_wait:
try:
chunk = proc.stderr.read(4096)
if chunk:
stderr_data += chunk
# Check if we got the instance ID
if b"FREECAD_MCP_INSTANCE_ID=" in stderr_data:
break
except (BlockingIOError, TypeError):
pass # No data available yet
time.sleep(poll_interval)
elapsed += poll_interval
stderr_text = stderr_data.decode("utf-8", errors="replace")
# Instance ID should be in stderr
assert "FREECAD_MCP_INSTANCE_ID=" in stderr_text, (
f"Instance ID not found in stderr after {max_wait}s.\n"
f"stderr: {stderr_text[:500]}"
)
# Read stdout (should NOT contain instance ID)
stdout_data = b""
try:
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
stdout_data += chunk
except (BlockingIOError, TypeError):
pass # No more data available
stdout_text = stdout_data.decode("utf-8", errors="replace")
# Instance ID should NOT be in stdout
assert "FREECAD_MCP_INSTANCE_ID=" not in stdout_text, (
f"Instance ID incorrectly appears in stdout, corrupting MCP protocol!\n"
f"stdout: {stdout_text[:500]}"
)
finally:
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()