FreeCAD Robust MCP Server Architecture¶
Executive Summary¶
This document describes the architecture for a Model Context Protocol (MCP) server that enables tight integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and FreeCAD. The server provides AI assistants with full access to FreeCAD's Python console capabilities in both GUI and headless modes, enabling AI-assisted development and debugging of models, macros, and workbenches.
Table of Contents¶
- FreeCAD Robust MCP Server Architecture
- Executive Summary
- Table of Contents
- Competitive Analysis
- System Overview
- Architecture Goals
- High-Level Architecture
- Component Design
- MCP Tools Specification
- Core Execution Tools
execute_pythonexecute_macro- Document Management Tools
create_documentopen_documentsave_document- Object Creation Tools
create_primitivecreate_sketch- Geometry Tools
add_sketch_geometryboolean_operation- Export/Import Tools
export_meshexport_step- Debugging Tools
inspect_objectvalidate_model- Workbench Tools
activate_workbenchlist_workbench_commands
- MCP Resources Specification
- Communication Patterns
- Security Considerations
- Deployment Modes
- Error Handling
- Future Extensibility
- Summary
Competitive Analysis¶
See COMPARISON.md for detailed analysis of existing implementations.
Existing FreeCAD Robust MCP Servers¶
| Project | Stars | Approach | Strengths |
|---|---|---|---|
| neka-nat/freecad-mcp | 380+ | XML-RPC | Thread-safe GUI ops, screenshots, parts library |
| jango-blockchained/mcp-freecad | - | Multi-mode | 6 connection modes, multi-AI, connection recovery |
| contextform/freecad-mcp | - | Node.js bridge | 31+ CAD operations, installer, demos |
| ATOI-Ming/FreeCAD-MCP | - | Macro-centric | GUI panel, macro templates, validation |
| bonninr/freecad_mcp | - | Simple socket | Minimal, lightweight |
Our Differentiators¶
- True Headless Mode: Embedded bridge runs FreeCAD in-process (unique)
- Dual Protocol: XML-RPC (compatibility) + JSON-RPC (modern)
- MCP Resources: Document/object introspection (no other impl has this)
- MCP Prompts: Guided workflow templates (unique)
- Developer Focus: Debugging tools, console access, macro development
- Thread-Safe Design: Queue-based GUI operations (learned from neka-nat)
- Connection Resilience: Auto-reconnect with health monitoring
Key Learnings Applied¶
- From neka-nat: Queue-based GUI thread safety, smart screenshot handling
- From jango: Multiple connection modes, connection recovery
- From contextform: Comprehensive PartDesign/Part operations
- From ATOI-Ming: Macro templates, validation, automatic imports
System Overview¶
What is MCP?¶
The Model Context Protocol (MCP) is a standardized protocol that allows LLM applications (like Claude Code, GPT-based tools, and other MCP clients) to interact with external systems through:
- Tools: Executable functions that perform actions (like POST endpoints)
- Resources: Data sources that provide context (like GET endpoints)
- Prompts: Reusable templates for LLM interactions
What is FreeCAD?¶
FreeCAD is an open-source parametric 3D CAD modeler with:
- A comprehensive Python API for scripting
- GUI mode with interactive Python console
- Headless mode (
freecadcmd) for batch processing - Workbench architecture for domain-specific tools
- Full access to geometry, constraints, and document structure via Python
Integration Vision¶
┌─────────────────────────────────────────────────────────────────────────┐
│ Claude Code │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ User Request: "Create a parametric gear with 20 teeth" │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ MCP Client (Claude Code, GPT, etc.) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
MCP Protocol (stdio/HTTP)
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ FreeCAD Robust MCP Server │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Tools │ │ Resources │ │ Prompts │ │
│ │ - execute_py │ │ - documents │ │ - modeling │ │
│ │ - create_obj │ │ - objects │ │ - debugging │ │
│ │ - export │ │ - console │ │ - macros │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ FreeCAD Bridge │ │ FreeCAD Bridge │ │
│ │ (GUI Mode) │ │ (Headless Mode) │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ FreeCAD Instance │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Documents │ │ Objects │ │ Workbenches │ │ Macros │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Architecture Goals¶
Primary Goals¶
- Full Python Console Access: Execute arbitrary Python code in FreeCAD's context
- Bidirectional Communication: Send commands and receive results/errors
- Dual Mode Support: Work with both GUI and headless FreeCAD instances
- Real-time Feedback: Stream console output and execution results
- Document Introspection: Query and understand FreeCAD document structure
- Safe Execution: Sandboxed execution with timeout and resource limits
Secondary Goals¶
- Macro Development: Assist in writing, testing, and debugging macros
- Workbench Integration: Access workbench-specific functionality
- Model Validation: Check model integrity and constraints
- Export Capabilities: Generate various output formats (STEP, STL, etc.)
- Version Compatibility: Support FreeCAD 0.21+ and 1.0+
High-Level Architecture¶
Architecture Pattern: Bridge with Adapter¶
┌─────────────────────────────────────────────────────────────────────────┐
│ MCP Server Layer │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ MCPServer Application │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Tool │ │ Resource │ │ Prompt │ │ Lifecycle│ │ │
│ │ │ Registry │ │ Registry │ │ Registry │ │ Manager │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ FreeCAD Bridge Interface │ │
│ │ (Abstract Base Class) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ╱ ╲ │
│ ╱ ╲ │
│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │
│ │ EmbeddedBridge │ │ SocketBridge │ │
│ │ (In-process FreeCAD) │ │ (Remote FreeCAD) │ │
│ │ │ │ │ │
│ │ - Direct Python API access │ │ - TCP/Unix socket comm │ │
│ │ - Headless mode only │ │ - GUI or headless mode │ │
│ │ - Fastest execution │ │ - Process isolation │ │
│ └─────────────────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Module Structure¶
freecad_mcp/
├── __init__.py
├── server.py # Main MCP server entry point
├── config.py # Configuration management
│
├── bridge/ # FreeCAD communication layer
│ ├── __init__.py
│ ├── base.py # Abstract bridge interface
│ ├── embedded.py # In-process FreeCAD bridge (Linux only)
│ ├── socket.py # JSON-RPC socket bridge
│ ├── xmlrpc.py # XML-RPC bridge (recommended)
│ └── protocol.py # Wire protocol for socket communication
│
├── tools/ # MCP tool implementations (150+ tools)
│ ├── __init__.py
│ ├── execution.py # Python execution & debugging (4 tools)
│ ├── documents.py # Document management (7 tools)
│ ├── objects.py # Object creation/manipulation (12 tools)
│ ├── partdesign.py # PartDesign parametric modeling (19 tools)
│ ├── view.py # View, camera, display (11 tools)
│ ├── export.py # Export/import operations (7 tools)
│ └── macros.py # Macro management (6 tools)
│
├── resources/ # MCP resource implementations
│ ├── __init__.py
│ └── freecad.py # Document, console, capabilities resources
│
├── prompts/ # MCP prompt templates
│ ├── __init__.py
│ └── freecad.py # Modeling and debugging prompts
│
├── freecad_plugin/ # Plugin to install IN FreeCAD
│ ├── __init__.py
│ ├── server.py # XML-RPC/JSON-RPC server
│ ├── handlers.py # Request handlers
│ ├── blocking_bridge.py # Blocking server (keeps FreeCAD running)
│ └── startup_bridge.py # Non-blocking startup (for interactive GUI)
│
└── utils/ # Utility modules
└── __init__.py
Component Design¶
1. MCP Server (server.py)¶
The main entry point uses MCPServer from the official MCP Python SDK 2.x.
"""FreeCAD Robust MCP Server - Main entry point."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from mcp.server.mcpserver import MCPServer
bridge = None
@asynccontextmanager
async def lifespan(_server: MCPServer[Any]) -> AsyncIterator[None]:
"""Connect the configured FreeCAD bridge for the server lifetime."""
global bridge
bridge = build_bridge_from_config()
await bridge.connect()
try:
yield
finally:
await bridge.disconnect()
mcp = MCPServer(
name="freecad-mcp",
title="FreeCAD Robust MCP Server",
description="Connect AI assistants to FreeCAD through a robust bridge.",
version=get_package_version(),
lifespan=lifespan,
)
2. Bridge Interface (bridge/base.py)¶
Abstract interface for FreeCAD communication.
"""Abstract bridge interface for FreeCAD communication."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass
class ExecutionResult:
"""Result of Python code execution."""
success: bool
result: Any
stdout: str
stderr: str
execution_time_ms: float
error_type: str | None = None
error_traceback: str | None = None
@dataclass
class DocumentInfo:
"""Information about a FreeCAD document."""
name: str
path: str | None
objects: list[str]
is_modified: bool
@dataclass
class ObjectInfo:
"""Information about a FreeCAD object."""
name: str
label: str
type_id: str
properties: dict[str, Any]
shape_info: dict[str, Any] | None
children: list[str]
class FreecadBridge(ABC):
"""Abstract base class for FreeCAD bridges."""
@abstractmethod
async def connect(self) -> None:
"""Establish connection to FreeCAD."""
pass
@abstractmethod
async def disconnect(self) -> None:
"""Close connection to FreeCAD."""
pass
@abstractmethod
async def is_connected(self) -> bool:
"""Check if bridge is connected."""
pass
@abstractmethod
async def execute_python(
self,
code: str,
timeout_ms: int | None = None
) -> ExecutionResult:
"""Execute Python code in FreeCAD context."""
pass
@abstractmethod
async def get_documents(self) -> list[DocumentInfo]:
"""Get list of open documents."""
pass
@abstractmethod
async def get_active_document(self) -> DocumentInfo | None:
"""Get the active document."""
pass
@abstractmethod
async def get_object(
self,
doc_name: str,
obj_name: str
) -> ObjectInfo:
"""Get detailed object information."""
pass
@abstractmethod
async def get_console_output(
self,
lines: int = 100
) -> list[str]:
"""Get recent console output."""
pass
@abstractmethod
async def get_freecad_version(self) -> dict[str, Any]:
"""Get FreeCAD version information."""
pass
@abstractmethod
async def is_gui_available(self) -> bool:
"""Check if GUI is available."""
pass
3. Embedded Bridge (bridge/embedded.py)¶
For running FreeCAD in-process (headless mode).
"""Embedded bridge - runs FreeCAD in-process."""
import asyncio
import sys
import io
from contextlib import redirect_stdout, redirect_stderr
from typing import Any
from freecad_mcp.bridge.base import (
FreecadBridge,
ExecutionResult,
DocumentInfo,
ObjectInfo,
)
class EmbeddedBridge(FreecadBridge):
"""Bridge that runs FreeCAD embedded in the MCP server process."""
def __init__(self, freecad_path: str | None = None):
self._freecad_path = freecad_path
self._fc_module = None
self._executor = None
async def connect(self) -> None:
"""Import and initialize FreeCAD."""
if self._freecad_path:
sys.path.insert(0, self._freecad_path)
# Run import in thread pool to avoid blocking
loop = asyncio.get_event_loop()
self._fc_module = await loop.run_in_executor(
None, self._import_freecad
)
def _import_freecad(self):
"""Import FreeCAD module."""
import FreeCAD
return FreeCAD
async def execute_python(
self,
code: str,
timeout_ms: int | None = None
) -> ExecutionResult:
"""Execute Python code in FreeCAD context."""
import time
effective_timeout_ms = timeout_ms or self._execution_timeout_ms
start = time.perf_counter()
stdout_capture = io.StringIO()
stderr_capture = io.StringIO()
# Build execution context with FreeCAD modules
exec_globals = {
"FreeCAD": self._fc_module,
"App": self._fc_module,
"__builtins__": __builtins__,
}
# Add GUI module if available
if await self.is_gui_available():
import FreeCADGui
exec_globals["FreeCADGui"] = FreeCADGui
exec_globals["Gui"] = FreeCADGui
try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
# Execute with timeout
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.run_in_executor(
None,
lambda: exec(compile(code, "<mcp>", "exec"), exec_globals)
),
timeout=effective_timeout_ms / 1000
)
elapsed = (time.perf_counter() - start) * 1000
return ExecutionResult(
success=True,
result=exec_globals.get("_result_"), # Convention for return values
stdout=stdout_capture.getvalue(),
stderr=stderr_capture.getvalue(),
execution_time_ms=elapsed,
)
except asyncio.TimeoutError:
return ExecutionResult(
success=False,
result=None,
stdout=stdout_capture.getvalue(),
stderr=stderr_capture.getvalue(),
execution_time_ms=effective_timeout_ms,
error_type="TimeoutError",
error_traceback=(
f"Execution timed out after {effective_timeout_ms}ms"
),
)
except Exception as e:
import traceback
elapsed = (time.perf_counter() - start) * 1000
return ExecutionResult(
success=False,
result=None,
stdout=stdout_capture.getvalue(),
stderr=stderr_capture.getvalue(),
execution_time_ms=elapsed,
error_type=type(e).__name__,
error_traceback=traceback.format_exc(),
)
4. Socket Bridge (bridge/socket.py)¶
For communicating with a running FreeCAD instance (GUI or remote).
"""Socket bridge - communicates with FreeCAD over TCP/Unix socket."""
import asyncio
import json
from typing import Any
from freecad_mcp.bridge.base import (
FreecadBridge,
ExecutionResult,
DocumentInfo,
ObjectInfo,
)
from freecad_mcp.bridge.protocol import MCPWireProtocol
class SocketBridge(FreecadBridge):
"""Bridge that communicates with FreeCAD over sockets."""
def __init__(
self,
host: str = "localhost",
port: int = 9876,
unix_socket: str | None = None,
):
self._host = host
self._port = port
self._unix_socket = unix_socket
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._protocol = MCPWireProtocol()
self._request_id = 0
self._pending: dict[int, asyncio.Future] = {}
async def connect(self) -> None:
"""Connect to FreeCAD socket server."""
if self._unix_socket:
self._reader, self._writer = await asyncio.open_unix_connection(
self._unix_socket
)
else:
self._reader, self._writer = await asyncio.open_connection(
self._host, self._port
)
# Start response reader task
asyncio.create_task(self._read_responses())
async def _read_responses(self) -> None:
"""Background task to read responses from FreeCAD."""
while self._reader:
try:
data = await self._reader.readline()
if not data:
break
response = self._protocol.decode(data)
request_id = response.get("id")
if request_id in self._pending:
self._pending[request_id].set_result(response)
except Exception as e:
# Handle connection errors
break
async def _send_request(
self,
method: str,
params: dict[str, Any]
) -> dict[str, Any]:
"""Send a request and wait for response."""
self._request_id += 1
request_id = self._request_id
request = {
"id": request_id,
"method": method,
"params": params,
}
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[request_id] = future
try:
data = self._protocol.encode(request)
self._writer.write(data)
await self._writer.drain()
response = await asyncio.wait_for(future, timeout=60.0)
return response
finally:
del self._pending[request_id]
async def execute_python(
self,
code: str,
timeout_ms: int | None = None
) -> ExecutionResult:
"""Execute Python code in FreeCAD context."""
effective_timeout_ms = timeout_ms or self._execution_timeout_ms
response = await self._send_request("execute", {
"code": code,
"timeout_ms": effective_timeout_ms,
})
if "error" in response:
return ExecutionResult(
success=False,
result=None,
stdout="",
stderr=response["error"]["message"],
execution_time_ms=0,
error_type=response["error"].get("type", "Error"),
error_traceback=response["error"].get("traceback"),
)
result = response["result"]
return ExecutionResult(
success=True,
result=result.get("value"),
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
execution_time_ms=result.get("execution_time_ms", 0),
)
5. FreeCAD Plugin (freecad_plugin/server.py)¶
Plugin that runs inside FreeCAD to accept socket connections.
"""FreeCAD plugin - Socket server running inside FreeCAD."""
import asyncio
import json
import threading
import io
from contextlib import redirect_stdout, redirect_stderr
import FreeCAD
import FreeCADGui
class FreecadMCPPlugin:
"""Plugin that runs inside FreeCAD to handle MCP bridge requests."""
def __init__(self, host: str = "localhost", port: int = 9876):
self._host = host
self._port = port
self._server = None
self._loop = None
self._thread = None
def start(self) -> None:
"""Start the socket server in a background thread."""
self._thread = threading.Thread(target=self._run_server, daemon=True)
self._thread.start()
FreeCAD.Console.PrintMessage(
f"MCP Bridge server started on {self._host}:{self._port}\n"
)
def _run_server(self) -> None:
"""Run the asyncio event loop in background thread."""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._start_server())
self._loop.run_forever()
async def _start_server(self) -> None:
"""Start the TCP server."""
self._server = await asyncio.start_server(
self._handle_client,
self._host,
self._port,
)
async def _handle_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter
) -> None:
"""Handle a connected client."""
try:
while True:
data = await reader.readline()
if not data:
break
request = json.loads(data.decode())
response = await self._process_request(request)
writer.write(json.dumps(response).encode() + b"\n")
await writer.drain()
except Exception as e:
FreeCAD.Console.PrintError(f"MCP client error: {e}\n")
finally:
writer.close()
async def _process_request(self, request: dict) -> dict:
"""Process a single request."""
request_id = request.get("id")
method = request.get("method")
params = request.get("params", {})
try:
if method == "execute":
result = await self._execute_python(params)
elif method == "get_documents":
result = self._get_documents()
elif method == "get_object":
result = self._get_object(params)
elif method == "get_version":
result = self._get_version()
else:
return {
"id": request_id,
"error": {"message": f"Unknown method: {method}"}
}
return {"id": request_id, "result": result}
except Exception as e:
import traceback
return {
"id": request_id,
"error": {
"type": type(e).__name__,
"message": str(e),
"traceback": traceback.format_exc(),
}
}
async def _execute_python(self, params: dict) -> dict:
"""Execute Python code in FreeCAD's main thread."""
import time
code = params["code"]
timeout_ms = params.get("timeout_ms", 30000)
# Must execute in main thread for FreeCAD compatibility
result_holder = {}
def execute():
start = time.perf_counter()
stdout_capture = io.StringIO()
stderr_capture = io.StringIO()
exec_globals = {
"FreeCAD": FreeCAD,
"App": FreeCAD,
"FreeCADGui": FreeCADGui,
"Gui": FreeCADGui,
"__builtins__": __builtins__,
}
try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
exec(compile(code, "<mcp>", "exec"), exec_globals)
result_holder["value"] = exec_globals.get("_result_")
result_holder["stdout"] = stdout_capture.getvalue()
result_holder["stderr"] = stderr_capture.getvalue()
result_holder["execution_time_ms"] = (
time.perf_counter() - start
) * 1000
result_holder["success"] = True
except Exception as e:
import traceback
result_holder["success"] = False
result_holder["error_type"] = type(e).__name__
result_holder["error_message"] = str(e)
result_holder["error_traceback"] = traceback.format_exc()
result_holder["stdout"] = stdout_capture.getvalue()
result_holder["stderr"] = stderr_capture.getvalue()
result_holder["execution_time_ms"] = (
time.perf_counter() - start
) * 1000
# Schedule execution in FreeCAD's main thread
# Use FreeCAD's timer mechanism for thread safety
from freecad_mcp_bridge.qt_compat import load_qt_core
QtCore = load_qt_core()
event = threading.Event()
def main_thread_execute():
execute()
event.set()
QtCore.QTimer.singleShot(0, main_thread_execute)
# Wait for execution with timeout
if not event.wait(timeout=timeout_ms / 1000):
return {
"success": False,
"error_type": "TimeoutError",
"error_message": f"Execution timed out after {timeout_ms}ms",
}
return result_holder
MCP Tools Specification¶
Core Execution Tools¶
execute_python¶
Execute arbitrary Python code in FreeCAD's context.
@mcp.tool()
async def execute_python(
code: str,
timeout_ms: int | None = None,
capture_result: bool = True,
) -> dict:
"""Execute Python code in FreeCAD's Python console context.
Args:
code: Python code to execute. Use `_result_ = value` to return data.
timeout_ms: Optional per-call execution timeout in milliseconds. The
configured bridge default applies when omitted.
capture_result: Whether to capture and return the result value.
Returns:
Dictionary containing:
- success: Whether execution completed without errors
- result: The value assigned to `_result_` if capture_result is True
- stdout: Captured standard output
- stderr: Captured standard error
- execution_time_ms: Time taken in milliseconds
- error_type: Type of exception if failed
- error_traceback: Full traceback if failed
Example:
>>> execute_python('''
... import Part
... box = Part.makeBox(10, 10, 10)
... _result_ = {"volume": box.Volume, "area": box.Area}
... ''')
"""
result = await bridge.execute_python(code, timeout_ms)
return result.__dict__
execute_macro¶
Run a FreeCAD macro file.
@mcp.tool()
async def execute_macro(
macro_name: str,
args: dict | None = None,
) -> dict:
"""Execute a FreeCAD macro by name.
Args:
macro_name: Name of the macro (without .FCMacro extension)
args: Optional arguments to pass to the macro as variables
Returns:
Execution result with stdout/stderr and any errors
"""
# Build code that loads and runs the macro
code = f'''
import FreeCAD
macro_path = FreeCAD.getUserMacroDir(True) + "/{macro_name}.FCMacro"
with open(macro_path) as f:
macro_code = f.read()
exec(macro_code)
'''
return await bridge.execute_python(code)
Document Management Tools¶
create_document¶
@mcp.tool()
async def create_document(
name: str = "Unnamed",
label: str | None = None,
) -> dict:
"""Create a new FreeCAD document.
Args:
name: Internal document name (no spaces)
label: Display label (can contain spaces)
Returns:
Document information including name and path
"""
code = f'''
doc = FreeCAD.newDocument("{name}")
if {repr(label)}:
doc.Label = {repr(label)}
_result_ = {{"name": doc.Name, "label": doc.Label}}
'''
return await bridge.execute_python(code)
open_document¶
@mcp.tool()
async def open_document(path: str) -> dict:
"""Open an existing FreeCAD document.
Args:
path: Full path to the .FCStd file
Returns:
Document information
"""
code = f'''
doc = FreeCAD.openDocument({repr(path)})
_result_ = {{
"name": doc.Name,
"label": doc.Label,
"path": doc.FileName,
"objects": [obj.Name for obj in doc.Objects],
}}
'''
return await bridge.execute_python(code)
save_document¶
@mcp.tool()
async def save_document(
doc_name: str | None = None,
path: str | None = None,
) -> dict:
"""Save a FreeCAD document.
Args:
doc_name: Document name (uses active document if None)
path: Save path (uses existing path if None)
Returns:
Save status and path
"""
code = f'''
doc = FreeCAD.getDocument({repr(doc_name)}) if {repr(doc_name)} else FreeCAD.ActiveDocument
if {repr(path)}:
doc.saveAs({repr(path)})
else:
doc.save()
_result_ = {{"saved": True, "path": doc.FileName}}
'''
return await bridge.execute_python(code)
Object Creation Tools¶
create_primitive¶
@mcp.tool()
async def create_primitive(
primitive_type: str,
name: str | None = None,
parameters: dict | None = None,
doc_name: str | None = None,
) -> dict:
"""Create a Part primitive object.
Args:
primitive_type: One of 'Box', 'Cylinder', 'Sphere', 'Cone', 'Torus'
name: Object name (auto-generated if None)
parameters: Primitive-specific parameters (e.g., {"Length": 10, "Width": 5})
doc_name: Target document (active document if None)
Returns:
Created object information
"""
params = parameters or {}
code = f'''
import Part
doc = FreeCAD.getDocument({repr(doc_name)}) if {repr(doc_name)} else FreeCAD.ActiveDocument
obj = doc.addObject("Part::{primitive_type}", {repr(name) if name else repr(primitive_type)})
for key, value in {repr(params)}.items():
setattr(obj, key, value)
doc.recompute()
_result_ = {{
"name": obj.Name,
"label": obj.Label,
"type": obj.TypeId,
"properties": {{p: getattr(obj, p) for p in obj.PropertiesList[:20]}},
}}
'''
return await bridge.execute_python(code)
create_sketch¶
@mcp.tool()
async def create_sketch(
plane: str = "XY",
name: str | None = None,
doc_name: str | None = None,
) -> dict:
"""Create a new sketch on a standard plane.
Args:
plane: One of 'XY', 'XZ', 'YZ'
name: Sketch name
doc_name: Target document
Returns:
Sketch object information
"""
plane_vectors = {
"XY": "FreeCAD.Vector(0, 0, 1)",
"XZ": "FreeCAD.Vector(0, 1, 0)",
"YZ": "FreeCAD.Vector(1, 0, 0)",
}
code = f'''
doc = FreeCAD.getDocument({repr(doc_name)}) if {repr(doc_name)} else FreeCAD.ActiveDocument
sketch = doc.addObject("Sketcher::SketchObject", {repr(name) or '"Sketch"'})
sketch.MapMode = "Deactivated"
sketch.Placement = FreeCAD.Placement(
FreeCAD.Vector(0, 0, 0),
FreeCAD.Rotation({plane_vectors[plane]}, 0)
)
doc.recompute()
_result_ = {{"name": sketch.Name, "geometry_count": sketch.GeometryCount}}
'''
return await bridge.execute_python(code)
Geometry Tools¶
add_sketch_geometry¶
@mcp.tool()
async def add_sketch_geometry(
sketch_name: str,
geometry_type: str,
parameters: dict,
doc_name: str | None = None,
) -> dict:
"""Add geometry to a sketch.
Args:
sketch_name: Name of the sketch object
geometry_type: One of 'line', 'circle', 'arc', 'rectangle', 'point'
parameters: Geometry-specific parameters
doc_name: Document containing the sketch
Returns:
Geometry index and sketch state
Example parameters:
line: {"start": [0, 0], "end": [10, 10]}
circle: {"center": [0, 0], "radius": 5}
rectangle: {"corner1": [0, 0], "corner2": [10, 10]}
"""
pass # Implementation depends on geometry_type
boolean_operation¶
@mcp.tool()
async def boolean_operation(
operation: str,
base_object: str,
tool_objects: list[str],
doc_name: str | None = None,
) -> dict:
"""Perform boolean operation on Part objects.
Args:
operation: One of 'fuse' (union), 'cut' (subtract), 'common' (intersect)
base_object: Name of the base object
tool_objects: List of tool object names
doc_name: Document name
Returns:
Resulting object information
"""
pass
Export/Import Tools¶
export_mesh¶
@mcp.tool()
async def export_mesh(
objects: list[str],
path: str,
format: str = "stl",
options: dict | None = None,
doc_name: str | None = None,
) -> dict:
"""Export objects as mesh (STL, OBJ, etc.).
Args:
objects: List of object names to export
path: Output file path
format: Export format ('stl', 'obj', 'ply', 'off')
options: Format-specific options (e.g., mesh deflection)
doc_name: Document name
Returns:
Export status and file info
"""
pass
export_step¶
@mcp.tool()
async def export_step(
objects: list[str],
path: str,
doc_name: str | None = None,
) -> dict:
"""Export objects as STEP file.
Args:
objects: List of object names to export
path: Output file path (.step or .stp)
doc_name: Document name
Returns:
Export status
"""
pass
Debugging Tools¶
inspect_object¶
@mcp.tool()
async def inspect_object(
object_name: str,
doc_name: str | None = None,
include_properties: bool = True,
include_shape: bool = True,
) -> dict:
"""Get detailed information about an object.
Args:
object_name: Name of the object
doc_name: Document name
include_properties: Include object property values
include_shape: Include shape geometry details
Returns:
Comprehensive object information including:
- All properties and their values
- Shape information (if applicable)
- Parent/child relationships
- Placement/position data
"""
pass
validate_model¶
@mcp.tool()
async def validate_model(
doc_name: str | None = None,
check_geometry: bool = True,
check_constraints: bool = True,
) -> dict:
"""Validate model integrity and constraints.
Args:
doc_name: Document to validate
check_geometry: Check for geometry errors
check_constraints: Check sketch constraints
Returns:
Validation results with any issues found
"""
pass
Workbench Tools¶
activate_workbench¶
@mcp.tool()
async def activate_workbench(workbench_name: str) -> dict:
"""Activate a FreeCAD workbench.
Args:
workbench_name: Workbench identifier (e.g., 'PartDesignWorkbench')
Returns:
Activation status and available commands
"""
pass
list_workbench_commands¶
@mcp.tool()
async def list_workbench_commands(
workbench_name: str | None = None,
) -> dict:
"""List available commands in a workbench.
Args:
workbench_name: Workbench to query (active if None)
Returns:
List of commands with descriptions
"""
pass
MCP Resources Specification¶
Document Resources¶
@mcp.resource("freecad://documents")
async def list_documents() -> str:
"""List all open FreeCAD documents."""
docs = await bridge.get_documents()
return json.dumps([d.__dict__ for d in docs], indent=2)
@mcp.resource("freecad://documents/{doc_name}")
async def get_document(doc_name: str) -> str:
"""Get detailed document information."""
pass
@mcp.resource("freecad://documents/{doc_name}/objects")
async def list_objects(doc_name: str) -> str:
"""List all objects in a document with hierarchy."""
pass
@mcp.resource("freecad://documents/{doc_name}/objects/{obj_name}")
async def get_object_details(doc_name: str, obj_name: str) -> str:
"""Get full object details including properties and shape."""
pass
Console Resources¶
@mcp.resource("freecad://console/history")
async def get_console_history() -> str:
"""Get Python console command history."""
pass
@mcp.resource("freecad://console/output")
async def get_console_output() -> str:
"""Get recent console output (stdout/stderr)."""
pass
Environment Resources¶
@mcp.resource("freecad://version")
async def get_version() -> str:
"""Get FreeCAD version and build information."""
pass
@mcp.resource("freecad://workbenches")
async def list_workbenches() -> str:
"""List installed workbenches with status."""
pass
@mcp.resource("freecad://macros")
async def list_macros() -> str:
"""List installed macros."""
pass
@mcp.resource("freecad://preferences/{path}")
async def get_preference(path: str) -> str:
"""Get FreeCAD preference value."""
pass
Communication Patterns¶
Pattern 1: Stdio Transport (Default for MCP Clients)¶
Configuration in Claude Code settings (similar for other MCP clients):
{
"mcpServers": {
"freecad": {
"command": "uv",
"args": ["run", "freecad-mcp"],
"env": {
"FREECAD_MODE": "xmlrpc"
}
}
}
}
Pattern 2: HTTP Transport (Remote/Shared)¶
For shared or remote setups:
{
"mcpServers": {
"freecad": {
"url": "http://localhost:8000/mcp",
"transport": "streamable-http"
}
}
}
Pattern 3: GUI Integration¶
When FreeCAD GUI is running:
- User installs FreeCAD plugin via Addon Manager
- Plugin starts socket server on FreeCAD startup
- MCP server connects via socket bridge
- Full GUI access including view manipulation
┌─────────────────────────────────────────────────────────────┐
│ FreeCAD GUI │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ MCP Bridge Plugin │ │
│ │ (Socket Server on port 9876) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲
│ TCP Socket
▼
┌─────────────────────────────────────────────────────────────┐
│ MCP Server (SocketBridge mode) │
└─────────────────────────────────────────────────────────────┘
▲
│ stdio
▼
┌─────────────────────────────────────────────────────────────┐
│ MCP Client (Claude Code, etc.) │
└─────────────────────────────────────────────────────────────┘
Security Considerations¶
Code Execution Sandboxing¶
class CodeSandbox:
"""Sandbox for restricting Python code execution."""
FORBIDDEN_MODULES = {
"subprocess", "os.system", "pty", "socket", # System access
"pickle", "marshal", # Serialization attacks
"__import__", # Dynamic imports
}
FORBIDDEN_BUILTINS = {
"exec", "eval", "compile", # Meta-execution
"open", "__import__", # File/module access
}
def validate_code(self, code: str) -> list[str]:
"""Check code for dangerous patterns."""
import ast
issues = []
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name in self.FORBIDDEN_MODULES:
issues.append(f"Forbidden import: {alias.name}")
elif isinstance(node, ast.ImportFrom):
if node.module in self.FORBIDDEN_MODULES:
issues.append(f"Forbidden import: {node.module}")
return issues
Resource Limits¶
@dataclass
class ExecutionLimits:
"""Resource limits for code execution."""
max_execution_time_ms: int = 30000 # 30 seconds
max_memory_mb: int = 512
max_output_size_bytes: int = 1_000_000 # 1 MB
max_objects_created: int = 1000
Authentication (for HTTP transport)¶
# OAuth 2.1 resource server implementation
from mcp.server.auth import ResourceServer
auth = ResourceServer(
issuer="https://auth.example.com",
audience="freecad-mcp",
)
@mcp.middleware
async def auth_middleware(request, call_next):
"""Validate OAuth tokens for HTTP transport."""
if mcp.transport_type == "http":
await auth.validate_request(request)
return await call_next(request)
Deployment Modes¶
Mode 1: XML-RPC Mode (Recommended)¶
Connects to FreeCAD via XML-RPC protocol. Works on all platforms (macOS, Linux, Windows).
# .mise.toml
[tools]
python = "3.11" # Required for FreeCAD ABI compatibility
uv = "latest"
[env]
FREECAD_MODE = "xmlrpc"
FREECAD_XMLRPC_PORT = "9875"
Setup:
- Install the Robust MCP Bridge workbench via FreeCAD Addon Manager, or run
just freecad::run-guifrom source - Start the bridge using the workbench toolbar button or menu
- The bridge starts both XML-RPC (port 9875) and JSON-RPC (port 9876) servers
Mode 2: Local Embedded (Linux Only)¶
Runs FreeCAD in-process for fastest execution. Only works on Linux - crashes on macOS/Windows due to @rpath/libpython3.11.dylib conflicts.
# .mise.toml
[tools]
python = "3.11" # Must match FreeCAD's bundled Python version
uv = "latest"
[env]
FREECAD_MODE = "embedded"
FREECAD_PATH = "/usr/lib/freecad/lib" # Linux only
Mode 3: Local GUI with Workbench¶
Install the Robust MCP Bridge workbench in the profile used by FreeCAD, pair it with the server, and start it in the GUI. See the installation guide for profile discovery and the connection modes guide for settings.
Mode 4: Server in Docker¶
The container runs the MCP server; FreeCAD and its paired bridge run separately. See container configuration for the connection and credential requirements.
Error Handling¶
Error Categories¶
class MCPError(Exception):
"""Base MCP error."""
pass
class ConnectionError(MCPError):
"""Failed to connect to FreeCAD."""
pass
class ExecutionError(MCPError):
"""Python code execution failed."""
pass
class TimeoutError(MCPError):
"""Execution timed out."""
pass
class ValidationError(MCPError):
"""Input validation failed."""
pass
class ResourceNotFoundError(MCPError):
"""Requested resource does not exist."""
pass
Error Response Format¶
{
"success": false,
"error": {
"type": "ExecutionError",
"message": "NameError: name 'foo' is not defined",
"traceback": "Traceback (most recent call last):\n File \"<mcp>\", line 1, in <module>\nNameError: name 'foo' is not defined",
"context": {
"code_snippet": "foo.bar()",
"line_number": 1
}
}
}
Future Extensibility¶
Planned Features¶
- Streaming Execution Output: Real-time stdout/stderr streaming for long operations
- View Manipulation: Camera control, section views, rendering in GUI mode
- Parametric Updates: Modify parameters and observe model updates
- Assembly Support: Integration with Assembly3/4 workbenches
- FEM Integration: Finite Element Analysis setup and results
- Path/CAM Support: CAM toolpath generation and simulation
- Multi-Document: Work across multiple documents simultaneously
- Undo/Redo: Transaction management with rollback capability
Extension Points¶
# Plugin system for custom tools
class ToolPlugin(Protocol):
"""Protocol for tool plugins."""
@property
def name(self) -> str: ...
@property
def tools(self) -> list[Callable]: ...
async def initialize(self, bridge: FreecadBridge) -> None: ...
# Register custom plugins
mcp.register_plugin(MyCustomToolPlugin())
Summary¶
This architecture provides:
- Full Python Console Access: Execute any FreeCAD Python code
- Dual Mode Support: Works with GUI and headless FreeCAD
- Comprehensive Tooling: Document, object, geometry, export tools
- Rich Resources: Query documents, objects, console, workbenches
- Secure Execution: Sandboxing, timeouts, resource limits
- Flexible Deployment: Embedded, socket, remote modes
- Extensible Design: Plugin system for custom functionality
The MCP server acts as a powerful bridge enabling AI assistants to become intelligent FreeCAD development partners.