Files
freecad-robust-mcp-fc111/addon/FreecadRobustMCP/freecad_mcp_bridge/server.py
T
8c338f6da7 feat: MCP Bridge Workbench, just command cleanup, testing, etc. (#24)
* fix: lots of fixes and name refactoring

* feat: Add workbench preferences

* fix: MCP bridge status widget and just command fixes

* fix(tests): Use the correct mesa-glx package

* fix(ci): Add fontconfig to GUI test dependencies

FreeCAD GUI was failing to start with:
"Fontconfig error: Cannot load default config file: No such file"

Added fontconfig and fonts-dejavu-core packages to the GUI test job
dependencies to resolve the font configuration issue.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Extract path utilities into shared module

Create path_utils.py module that consolidates duplicated path-finding
logic from commands.py and InitGui.py:
- get_addon_path(): Find addon directory with caching and fallbacks
- get_icon_path(): Get full path to an icon file
- get_icons_dir(): Get path to icons directory
- get_workbench_icon(): Get path to workbench main icon

This removes ~100 lines of duplicated code while preserving the same
behavior including _addon_path_cache and all fallback methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(addon): Prevent stale plugin state on startup failure

The StartMCPBridgeCommand.Activated method could leave _mcp_plugin in
a partially initialized state if FreecadMCPPlugin.start() failed after
the plugin was instantiated.

Changes:
- Create plugin in a local variable first
- Only assign to _mcp_plugin after start() succeeds
- Explicitly clear _mcp_plugin and _running_config in exception
  handlers to ensure clean state for subsequent retry attempts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Lot of broad improvements

* fix(ci): Use blocking headless_server.py for GUI tests

The GUI test was using startup_bridge.py which is non-blocking
(designed for interactive use). For CI, even in GUI mode, we need
the blocking headless_server.py that calls run_forever() to keep
FreeCAD running. GUI features are still available since we use
the 'freecad' executable instead of 'freecadcmd'.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Rename headless_server.py to blocking_bridge.py

The old name was misleading because:
- It works with both GUI (freecad) and headless (freecadcmd) modes
- The key characteristic is that it BLOCKS with run_forever()

New naming convention clarifies the difference:
- blocking_bridge.py: Starts bridge and blocks (for CI, servers)
- startup_bridge.py: Starts bridge and returns (for interactive GUI)

Updated all references across:
- GitHub workflow (macro-test.yaml)
- Just commands (freecad.just)
- Unit tests (test_addon_structure.py)
- Documentation (5 files)
- CLAUDE.md

Also improved the script to detect GUI mode dynamically using
FreeCAD.GuiUp and display the appropriate status message.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(just): Remove erroneous rm of startup_bridge.py on error

The startup script is now a permanent source file in the repository,
not a generated temporary file. The rm -f would have deleted source
code if FreeCAD wasn't found.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: General improvements

* fix: Lots of general fixes and only stable to PyPi

* fix: small cleanup

* fix: Small fixes and hopefully fixes the GUI tests

* fix: Add proper library paths for FreeCAD GUI in CI

- Create wrapper scripts instead of symlinks for AppImage binaries
- Set LD_LIBRARY_PATH, QT_PLUGIN_PATH for GUI mode
- Add diagnostic output to identify startup failures

* fix: Use apprun for GUI tests in CI

* fix: Improving Xvfb tests

* fix: GUI tests worlk

* chore: remove invalid --no-splash comments

* fix: ARM64 architecture support and other fixes

* fix: cleanup

* test: just commands test suite

* test: improve just command tests

* fix: more general improvements

* fix: more cleanup

* fix: more updates

* fix: small tweaks

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:26:33 -08:00

930 lines
32 KiB
Python

"""FreeCAD MCP Bridge Plugin - Socket Server with Queue-based Thread Safety.
This module provides a socket server that runs inside FreeCAD to handle
MCP bridge requests. It must be executed within FreeCAD's Python environment.
Design inspired by neka-nat/freecad-mcp (MIT License):
- Queue-based GUI communication for thread safety
- XML-RPC compatibility mode (port 9875)
- Screenshot capture with view type detection
Attribution:
The queue-based thread safety pattern and XML-RPC protocol design were
inspired by neka-nat/freecad-mcp (https://github.com/neka-nat/freecad-mcp),
which is licensed under the MIT License. This implementation is a complete
rewrite with additional features (JSON-RPC 2.0, async socket server).
"""
from __future__ import annotations
import asyncio
import contextlib
import errno
import io
import json
import queue
import sys
import threading
import time
import traceback
import uuid
import xmlrpc.server
from contextlib import redirect_stderr, redirect_stdout
from typing import Any
# These imports only work inside FreeCAD
try:
import FreeCAD
import FreeCADGui
FREECAD_AVAILABLE = True
except ImportError:
FREECAD_AVAILABLE = False
# Default configuration
DEFAULT_SOCKET_PORT = 9876
DEFAULT_XMLRPC_PORT = 9875
QUEUE_POLL_INTERVAL_MS = 50
STATUS_UPDATE_INTERVAL_MS = 5000 # Update status bar every 5 seconds
HEADLESS_POLL_INTERVAL_S = 0.1 # Headless mode poll interval in seconds
def _get_qt_core() -> Any:
"""Get the QtCore module if GUI mode is available.
This helper checks if FreeCAD is available with GUI enabled and
attempts to import QtCore from PySide2 or PySide6.
Returns:
The QtCore module if available in GUI mode, None otherwise.
"""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return None
# Try PySide2 first, then PySide6
with contextlib.suppress(ImportError):
from PySide2 import QtCore
return QtCore
with contextlib.suppress(ImportError):
from PySide6 import QtCore
return QtCore
return None
class ExecutionRequest:
"""Represents a code execution request."""
def __init__(
self,
code: str,
timeout_ms: int = 30000,
request_id: str | None = None,
) -> None:
"""Initialize execution request.
Args:
code: Python code to execute.
timeout_ms: Execution timeout in milliseconds.
request_id: Optional request ID for tracking.
"""
self.code = code
self.timeout_ms = timeout_ms
self.request_id = request_id
self.result: dict[str, Any] | None = None
self.completed = threading.Event()
class FreecadMCPPlugin:
"""Plugin that runs inside FreeCAD to handle MCP bridge requests.
This class creates servers that accept connections from the MCP server
and executes commands in FreeCAD's context using a thread-safe queue
system for GUI operations.
Attributes:
socket_host: Hostname for socket server.
socket_port: Port for JSON-RPC socket server.
xmlrpc_port: Port for XML-RPC server.
"""
def __init__(
self,
host: str = "localhost",
port: int = DEFAULT_SOCKET_PORT,
xmlrpc_port: int = DEFAULT_XMLRPC_PORT,
enable_xmlrpc: bool = True,
) -> None:
"""Initialize the plugin.
Args:
host: Hostname to bind to.
port: Port for JSON-RPC socket server.
xmlrpc_port: Port for XML-RPC server.
enable_xmlrpc: Whether to enable XML-RPC server.
"""
# Generate unique instance ID for this server
self._instance_id = str(uuid.uuid4())
self._host = host
self._port = port
self._xmlrpc_port = xmlrpc_port
self._enable_xmlrpc = enable_xmlrpc
# Server instances
self._socket_server: asyncio.Server | None = None
self._xmlrpc_server: xmlrpc.server.SimpleXMLRPCServer | None = None
self._socket_loop: asyncio.AbstractEventLoop | None = None
# Threading
self._socket_thread: threading.Thread | None = None
self._xmlrpc_thread: threading.Thread | None = None
self._running = False
# Queue-based execution for thread safety (learned from neka-nat)
self._request_queue: queue.Queue[ExecutionRequest] = queue.Queue()
self._timer = None
self._queue_thread: threading.Thread | None = None
self._headless = False
# Status bar tracking
self._status_timer = None
self._request_count = 0
self._last_request_time: float | None = None
# =========================================================================
# Public API (for external access without using private attributes)
# =========================================================================
@property
def is_running(self) -> bool:
"""Check if the MCP bridge server is currently running.
Returns:
True if the server is running, False otherwise.
"""
return self._running
@property
def instance_id(self) -> str:
"""Get the unique instance ID for this server.
Returns:
UUID string identifying this server instance.
"""
return self._instance_id
@property
def socket_port(self) -> int:
"""Get the JSON-RPC socket server port.
Returns:
Port number for the socket server.
"""
return self._port
@property
def xmlrpc_port(self) -> int:
"""Get the XML-RPC server port.
Returns:
Port number for the XML-RPC server.
"""
return self._xmlrpc_port
@property
def request_count(self) -> int:
"""Get the total number of requests processed.
Returns:
Number of requests processed since server start.
"""
return self._request_count
def get_status(self) -> dict[str, Any]:
"""Get the current status of the MCP bridge server.
Returns:
Dictionary containing:
- running: Whether the server is running
- instance_id: Unique server instance ID
- socket_port: JSON-RPC socket port
- xmlrpc_port: XML-RPC port
- xmlrpc_enabled: Whether XML-RPC is enabled
- request_count: Total requests processed
- last_request_time: Timestamp of last request (or None)
- headless: Whether running in headless mode
"""
return {
"running": self._running,
"instance_id": self._instance_id,
"socket_port": self._port,
"xmlrpc_port": self._xmlrpc_port,
"xmlrpc_enabled": self._enable_xmlrpc,
"request_count": self._request_count,
"last_request_time": self._last_request_time,
"headless": self._headless,
}
def start(self) -> None:
"""Start all servers."""
if self._running:
return
self._running = True
# Print instance ID to stdout for test automation to capture
# This is printed before logging to ensure it's easily parseable
print(
f"FREECAD_MCP_BRIDGE_INSTANCE_ID={self._instance_id}",
file=sys.stdout,
flush=True,
)
# Start the queue processing timer on the main thread
self._start_queue_processor()
# Start socket server
self._socket_thread = threading.Thread(
target=self._run_socket_server,
daemon=True,
name="MCP-Socket",
)
self._socket_thread.start()
# Start XML-RPC server if enabled
if self._enable_xmlrpc:
self._xmlrpc_thread = threading.Thread(
target=self._run_xmlrpc_server,
daemon=True,
name="MCP-XMLRPC",
)
self._xmlrpc_thread.start()
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(
f"MCP Bridge started (Instance ID: {self._instance_id}):\n"
f" - JSON-RPC: {self._host}:{self._port}\n"
)
if self._enable_xmlrpc:
FreeCAD.Console.PrintMessage(
f" - XML-RPC: {self._host}:{self._xmlrpc_port}\n"
)
# Start status bar updates in GUI mode
self._start_status_updates()
def stop(self) -> None:
"""Stop all servers."""
self._running = False
# Stop status bar updates
self._stop_status_updates()
# Stop queue processor timer (GUI mode)
if self._timer:
with contextlib.suppress(Exception):
self._timer.stop()
self._timer = None
# Stop XML-RPC server by closing its socket directly
# This will cause handle_request() to raise an exception and exit
# Keep server reference until thread exits to avoid race condition
if self._xmlrpc_server:
with contextlib.suppress(Exception):
self._xmlrpc_server.socket.close()
# Stop socket server - close the server and stop the event loop
if self._socket_loop and self._socket_server:
self._socket_loop.call_soon_threadsafe(self._socket_server.close)
self._socket_loop.call_soon_threadsafe(self._socket_loop.stop)
# Wait briefly for threads - they're daemon threads so they'll
# be killed when the main thread exits anyway
if self._queue_thread and self._queue_thread.is_alive():
self._queue_thread.join(timeout=0.5)
self._queue_thread = None
if self._socket_thread and self._socket_thread.is_alive():
self._socket_thread.join(timeout=0.5)
self._socket_thread = None
# Wait for XML-RPC thread to exit before clearing server reference
if self._xmlrpc_thread and self._xmlrpc_thread.is_alive():
self._xmlrpc_thread.join(timeout=0.5)
self._xmlrpc_thread = None
# Now safe to clear the server reference
self._xmlrpc_server = None
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("MCP Bridge stopped\n")
def run_forever(self) -> None:
"""Run the server indefinitely.
This method blocks until interrupted (Ctrl+C) or stop() is called.
Works in both GUI and headless modes:
- GUI mode: Uses Qt event loop to allow timers to fire
- Headless mode: Uses short sleep intervals for responsive shutdown
"""
self.start()
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("Server running. Press Ctrl+C to stop.\n")
# Check if we're in GUI mode and have Qt available
QtCore = _get_qt_core()
try:
if QtCore is not None:
# GUI mode: use Qt's processEvents to keep the event loop running
# This allows QTimers to fire for queue processing
app = QtCore.QCoreApplication.instance()
if app is not None:
while self._running:
# Process Qt events (including our QTimer callbacks)
app.processEvents()
# Small sleep to prevent busy-waiting
time.sleep(0.01)
else:
# No QApplication - fall back to headless behavior
self._run_forever_headless()
else:
# Headless mode
self._run_forever_headless()
except KeyboardInterrupt:
pass # Normal exit via Ctrl+C
finally:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("\nShutting down...\n")
self.stop()
def _run_forever_headless(self) -> None:
"""Run forever in headless mode using short sleep intervals.
Uses a short sleep interval to allow responsive shutdown when
stop() sets _running to False.
"""
while self._running:
# Use short sleep to allow responsive shutdown
# This is more portable than signal.pause() and responds
# quickly when stop() sets _running = False
time.sleep(HEADLESS_POLL_INTERVAL_S)
# =========================================================================
# Status Bar Updates (GUI mode only)
# =========================================================================
def _start_status_updates(self) -> None:
"""Start periodic status bar updates in GUI mode."""
QtCore = _get_qt_core()
if QtCore is None:
return
# Create timer for status updates
timer = QtCore.QTimer()
timer.timeout.connect(self._update_status_bar)
timer.start(STATUS_UPDATE_INTERVAL_MS)
self._status_timer = timer
# Show initial status
self._update_status_bar()
def _stop_status_updates(self) -> None:
"""Stop status bar updates and clear the status."""
if self._status_timer:
with contextlib.suppress(Exception):
self._status_timer.stop()
self._status_timer = None
# Clear status bar message
if FREECAD_AVAILABLE and FreeCAD.GuiUp:
self._set_status_bar("")
def _update_status_bar(self) -> None:
"""Update the FreeCAD status bar with MCP bridge status."""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return
# Build status message
ports = f"XML-RPC:{self._xmlrpc_port}" if self._enable_xmlrpc else ""
if ports:
ports = f" ({ports})"
if self._request_count > 0:
# Show activity info
if self._last_request_time:
elapsed = time.time() - self._last_request_time
if elapsed < 60:
time_ago = f"{int(elapsed)}s ago"
else:
time_ago = f"{int(elapsed / 60)}m ago"
status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests | last: {time_ago}"
else:
status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests"
else:
status = f"🔌 MCP Bridge running{ports} | waiting for connections..."
self._set_status_bar(status)
def _set_status_bar(self, message: str) -> None:
"""Set the FreeCAD main window status bar message.
Args:
message: Message to display in status bar.
"""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return
try:
main_window = FreeCADGui.getMainWindow()
if main_window:
status_bar = main_window.statusBar()
if status_bar:
if message:
# Show message persistently (0 = no timeout)
status_bar.showMessage(message, 0)
else:
status_bar.clearMessage()
except Exception:
# Silently ignore status bar errors
pass
def _record_request(self) -> None:
"""Record that a request was processed (for status tracking)."""
self._request_count += 1
self._last_request_time = time.time()
# =========================================================================
# Queue-based Thread Safety (from neka-nat)
# =========================================================================
def _start_queue_processor(self) -> None:
"""Start the queue processor on the main GUI thread or as background thread."""
# Check if we're in GUI mode using FreeCAD.GuiUp
# Note: Qt (PySide) may be available even in headless mode, but without
# a running event loop, Qt timers won't fire. Use GuiUp to detect this.
gui_available = FREECAD_AVAILABLE and FreeCAD.GuiUp
if gui_available:
# GUI mode: use Qt timer for thread-safe GUI operations
try:
from PySide2 import QtCore
except ImportError:
try:
from PySide6 import QtCore
except ImportError:
QtCore = None # type: ignore[assignment]
if QtCore is not None:
timer = QtCore.QTimer()
timer.timeout.connect(self._process_queue)
timer.start(QUEUE_POLL_INTERVAL_MS)
self._timer = timer
return
# Headless mode: use a background thread for queue processing
# In headless mode, there's no GUI thread concern, so direct
# processing in a background thread is safe
self._headless = True
self._queue_thread = threading.Thread(
target=self._run_queue_processor_loop,
daemon=True,
name="MCP-QueueProcessor",
)
self._queue_thread.start()
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(
"Running in headless mode (queue processor thread started)\n"
)
def _run_queue_processor_loop(self) -> None:
"""Run queue processor in a loop for headless mode."""
while self._running:
self._process_queue()
time.sleep(QUEUE_POLL_INTERVAL_MS / 1000.0)
def _process_queue(self) -> None:
"""Process pending execution requests on the main thread.
This method is called periodically by a Qt timer to ensure
GUI operations happen on the main thread.
"""
while not self._request_queue.empty():
try:
request = self._request_queue.get_nowait()
result = self._execute_code_sync(request.code)
request.result = result
request.completed.set()
# Track request for status bar
self._record_request()
except queue.Empty:
break
except Exception as e:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(f"Queue processing error: {e}\n")
def _execute_via_queue(
self,
code: str,
timeout_ms: int = 30000,
) -> dict[str, Any]:
"""Execute code via the queue system for thread safety.
Args:
code: Python code to execute.
timeout_ms: Execution timeout in milliseconds.
Returns:
Execution result dictionary.
"""
request = ExecutionRequest(code, timeout_ms)
self._request_queue.put(request)
# Wait for completion
if request.completed.wait(timeout=timeout_ms / 1000):
return request.result or {
"success": False,
"error_type": "InternalError",
"error_message": "No result returned",
}
else:
return {
"success": False,
"error_type": "TimeoutError",
"error_message": f"Execution timed out after {timeout_ms}ms",
"execution_time_ms": timeout_ms,
}
def _execute_code_sync(self, code: str) -> dict[str, Any]:
"""Execute Python code synchronously (call on main thread only).
Args:
code: Python code to execute.
Returns:
Execution result dictionary.
"""
start = time.perf_counter()
stdout_capture = io.StringIO()
stderr_capture = io.StringIO()
exec_globals: dict[str, Any] = {
"__builtins__": __builtins__,
}
if FREECAD_AVAILABLE:
exec_globals["FreeCAD"] = FreeCAD
exec_globals["App"] = FreeCAD
exec_globals["FreeCADGui"] = FreeCADGui
exec_globals["Gui"] = FreeCADGui
try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
compiled = compile(code, "<mcp>", "exec")
exec(compiled, exec_globals) # noqa: S102
elapsed = (time.perf_counter() - start) * 1000
return {
"success": True,
"result": exec_globals.get("_result_"),
"stdout": stdout_capture.getvalue(),
"stderr": stderr_capture.getvalue(),
"execution_time_ms": elapsed,
}
except Exception as e:
elapsed = (time.perf_counter() - start) * 1000
return {
"success": False,
"result": None,
"stdout": stdout_capture.getvalue(),
"stderr": stderr_capture.getvalue(),
"execution_time_ms": elapsed,
"error_type": type(e).__name__,
"error_message": str(e),
"error_traceback": traceback.format_exc(),
}
# =========================================================================
# Socket Server (JSON-RPC 2.0)
# =========================================================================
def _run_socket_server(self) -> None:
"""Run the asyncio event loop in background thread."""
self._socket_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._socket_loop)
try:
self._socket_loop.run_until_complete(self._start_socket_server())
self._socket_loop.run_forever()
except OSError as e:
# Server failed to start - mark as not running
self._running = False
if e.errno == errno.EADDRINUSE:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintWarning(
f"MCP Bridge: JSON-RPC port {self._port} already in use. "
f"Another instance may be running.\n"
)
elif FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(
f"MCP Bridge: Failed to start JSON-RPC server: {e}\n"
)
finally:
self._socket_loop.close()
async def _start_socket_server(self) -> None:
"""Start the TCP server."""
self._socket_server = await asyncio.start_server(
self._handle_socket_client,
self._host,
self._port,
)
async def _handle_socket_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Handle a connected socket client.
Args:
reader: Stream reader for incoming data.
writer: Stream writer for outgoing data.
"""
try:
while self._running:
data = await reader.readline()
if not data:
break
try:
request = json.loads(data.decode("utf-8"))
response = await self._process_jsonrpc_request(request)
except json.JSONDecodeError as e:
response = {
"jsonrpc": "2.0",
"id": None,
"error": {
"code": -32700,
"message": "Parse error",
"data": str(e),
},
}
response_data = json.dumps(response).encode("utf-8") + b"\n"
writer.write(response_data)
await writer.drain()
except Exception as e:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(f"MCP socket error: {e}\n")
finally:
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
async def _process_jsonrpc_request(
self,
request: dict[str, Any],
) -> dict[str, Any]:
"""Process a JSON-RPC 2.0 request.
Args:
request: JSON-RPC request dictionary.
Returns:
JSON-RPC response dictionary.
"""
request_id = request.get("id")
method = request.get("method")
params = request.get("params", {})
# Handle ping specially (no queue needed)
if method == "ping":
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"pong": True,
"timestamp": time.time(),
"instance_id": self._instance_id,
},
}
# Handle get_instance_id specially (no queue needed)
if method == "get_instance_id":
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {"instance_id": self._instance_id},
}
# Handle execute via queue
if method == "execute":
code = params.get("code", "")
timeout_ms = params.get("timeout_ms", 30000)
# Execute via queue for thread safety
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: self._execute_via_queue(code, timeout_ms),
)
return {
"jsonrpc": "2.0",
"id": request_id,
"result": result,
}
# Unknown method
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": "Method not found",
"data": f"Unknown method: {method}",
},
}
# =========================================================================
# XML-RPC Server (neka-nat compatible)
# =========================================================================
def _run_xmlrpc_server(self) -> None:
"""Run the XML-RPC server."""
try:
self._xmlrpc_server = xmlrpc.server.SimpleXMLRPCServer(
(self._host, self._xmlrpc_port),
allow_none=True,
logRequests=False,
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintWarning(
f"MCP Bridge: XML-RPC port {self._xmlrpc_port} already in use. "
f"Another instance may be running.\n"
)
elif FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(
f"MCP Bridge: Failed to start XML-RPC server: {e}\n"
)
return
# Set a timeout so handle_request() doesn't block forever
# This allows the server to check self._running periodically
self._xmlrpc_server.timeout = 0.5
# Register methods (type: ignore needed - xmlrpc types are overly restrictive)
self._xmlrpc_server.register_function(self._xmlrpc_execute, "execute") # type: ignore[arg-type]
self._xmlrpc_server.register_function(self._xmlrpc_ping, "ping") # type: ignore[arg-type]
self._xmlrpc_server.register_function(
self._xmlrpc_get_instance_id, "get_instance_id"
) # type: ignore[arg-type]
self._xmlrpc_server.register_function(self._xmlrpc_get_view, "get_view") # type: ignore[arg-type]
self._xmlrpc_server.register_introspection_functions()
while self._running:
try:
self._xmlrpc_server.handle_request()
except OSError:
# Socket was closed during shutdown - this is expected
break
def _xmlrpc_ping(self) -> dict[str, Any]:
"""XML-RPC ping handler."""
return {
"pong": True,
"timestamp": time.time(),
"instance_id": self._instance_id,
}
def _xmlrpc_get_instance_id(self) -> dict[str, Any]:
"""XML-RPC get_instance_id handler.
Returns:
Dictionary containing the unique instance ID for this bridge.
"""
return {"instance_id": self._instance_id}
def _xmlrpc_execute(self, code: str) -> dict[str, Any]:
"""XML-RPC execute handler (neka-nat compatible).
Args:
code: Python code to execute.
Returns:
Execution result dictionary.
"""
return self._execute_via_queue(code, 30000)
# Valid view types for screenshot capture
_VALID_VIEW_TYPES = frozenset(
{"FitAll", "Isometric", "Front", "Back", "Top", "Bottom", "Left", "Right"}
)
def _xmlrpc_get_view(
self,
width: int = 800,
height: int = 600,
view_type: str = "Isometric",
) -> dict[str, Any]:
"""XML-RPC get_view handler for screenshots (neka-nat compatible).
Args:
width: Image width.
height: Image height.
view_type: View angle type.
Returns:
Dictionary with base64 image data or error.
"""
# Validate inputs to prevent code injection
# Type hints don't enforce at runtime, so explicit conversion is needed
try:
width = int(width)
height = int(height)
except (ValueError, TypeError) as e:
return {"success": False, "error": f"Invalid dimensions: {e}"}
if view_type not in self._VALID_VIEW_TYPES:
return {
"success": False,
"error": f"Invalid view_type: {view_type}. "
f"Must be one of: {', '.join(sorted(self._VALID_VIEW_TYPES))}",
}
code = f"""
import base64
import tempfile
import os
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available"}}
else:
doc = FreeCAD.ActiveDocument
if doc is None:
_result_ = {{"success": False, "error": "No active document"}}
else:
view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
_result_ = {{"success": False, "error": "No active view"}}
else:
# Check view type
view_class = view.__class__.__name__
if view_class not in ["View3DInventor", "View3DInventorPy"]:
_result_ = {{"success": False, "error": f"Cannot capture from {{view_class}}"}}
else:
# Set view angle
view_type = {view_type!r}
if view_type == "FitAll":
view.fitAll()
elif view_type == "Isometric":
view.viewIsometric()
elif view_type == "Front":
view.viewFront()
elif view_type == "Back":
view.viewRear()
elif view_type == "Top":
view.viewTop()
elif view_type == "Bottom":
view.viewBottom()
elif view_type == "Left":
view.viewLeft()
elif view_type == "Right":
view.viewRight()
# Capture screenshot
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
temp_path = f.name
view.saveImage(temp_path, {width}, {height}, "Current")
with open(temp_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
os.unlink(temp_path)
_result_ = {{
"success": True,
"data": image_data,
"format": "png",
"width": {width},
"height": {height},
}}
"""
result = self._execute_via_queue(code, 30000)
if result.get("success") and result.get("result"):
return result["result"]
return {"success": False, "error": result.get("error_message", "Unknown error")}
# Backwards compatibility
start = FreecadMCPPlugin