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 │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ FastMCP 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 using FastMCP from the official MCP Python SDK.
"""FreeCAD Robust MCP Server - Main entry point."""
from mcp.server.fastmcp import FastMCP
from freecad_mcp.config import ServerConfig
from freecad_mcp.bridge import create_bridge
from freecad_mcp.tools import register_all_tools
from freecad_mcp.resources import register_all_resources
from freecad_mcp.prompts import register_all_prompts
mcp = FastMCP(
name="freecad-mcp",
version="0.1.0",
description="MCP server for FreeCAD integration with AI assistants"
)
# Bridge instance (initialized on startup)
bridge = None
@mcp.on_startup
async def startup():
"""Initialize FreeCAD bridge on server startup."""
global bridge
config = ServerConfig.from_env()
bridge = await create_bridge(config)
@mcp.on_shutdown
async def shutdown():
"""Clean up FreeCAD bridge on server shutdown."""
if bridge:
await bridge.close()
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 = 30000
) -> 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 = 30000
) -> ExecutionResult:
"""Execute Python code in FreeCAD context."""
import time
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=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=timeout_ms,
error_type="TimeoutError",
error_traceback=f"Execution timed out after {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 = 30000
) -> ExecutionResult:
"""Execute Python code in FreeCAD context."""
response = await self._send_request("execute", {
"code": code,
"timeout_ms": 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 PySide2 import QtCore
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 = 30000,
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: Maximum execution time in milliseconds.
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_shape: bool = True,
) -> dict:
"""Get detailed information about an object.
Args:
object_name: Name of the object
doc_name: Document name
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 Plugin¶
- Install FreeCAD plugin:
- Configure MCP server:
Mode 4: Remote/Docker¶
FROM freecad/freecad:latest
# Install MCP plugin
COPY freecad_plugin /root/.FreeCAD/Mod/MCPBridge
# Expose socket port
EXPOSE 9876
CMD ["freecadcmd", "-c", "from freecad_mcp.plugin import start; start()"]
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.