Files
Sean P. KaneandGitHub f14ab8177d Fix: rename workbench and prepare for release (#27)
* chore: rename workbench to FreecadRobustMCPBridge

* fix: stdio cleanup and bug fix for JSON RPC

* fix: update FreeCAD MCP bridge and tests

* test: Fix GUI Integration tests and other issues

* fix: unit tests in CI and a few other things

* docs: generate GitHub pages site and link to it.

* fix: General code improvements and DRY refactoring

* chore: General cleanup

* chore: small fixes

* docs: cleanup

* docs: fixes

* docs: small corrections

* docs: fix

* test: release check

* bump: workbench v0.6.0

* chore: bump macros to 0.6.0

* docs: Release improvements

* refactor: improve DRYness of code
2026-01-12 09:30:05 -08:00

1666 lines
55 KiB
Markdown

# 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
<!--TOC-->
- [FreeCAD Robust MCP Server Architecture](#freecad-robust-mcp-server-architecture)
- [Executive Summary](#executive-summary)
- [Table of Contents](#table-of-contents)
- [Competitive Analysis](#competitive-analysis)
- [Existing FreeCAD Robust MCP Servers](#existing-freecad-robust-mcp-servers)
- [Our Differentiators](#our-differentiators)
- [Key Learnings Applied](#key-learnings-applied)
- [System Overview](#system-overview)
- [What is MCP?](#what-is-mcp)
- [What is FreeCAD?](#what-is-freecad)
- [Integration Vision](#integration-vision)
- [Architecture Goals](#architecture-goals)
- [Primary Goals](#primary-goals)
- [Secondary Goals](#secondary-goals)
- [High-Level Architecture](#high-level-architecture)
- [Architecture Pattern: Bridge with Adapter](#architecture-pattern-bridge-with-adapter)
- [Module Structure](#module-structure)
- [Component Design](#component-design)
- [1. MCP Server (`server.py`)](#1-mcp-server-serverpy)
- [2. Bridge Interface (`bridge/base.py`)](#2-bridge-interface-bridgebasepy)
- [3. Embedded Bridge (`bridge/embedded.py`)](#3-embedded-bridge-bridgeembeddedpy)
- [4. Socket Bridge (`bridge/socket.py`)](#4-socket-bridge-bridgesocketpy)
- [5. FreeCAD Plugin (`freecad_plugin/server.py`)](#5-freecad-plugin-freecad_pluginserverpy)
- [MCP Tools Specification](#mcp-tools-specification)
- [Core Execution Tools](#core-execution-tools)
- [`execute_python`](#execute_python)
- [`execute_macro`](#execute_macro)
- [Document Management Tools](#document-management-tools)
- [`create_document`](#create_document)
- [`open_document`](#open_document)
- [`save_document`](#save_document)
- [Object Creation Tools](#object-creation-tools)
- [`create_primitive`](#create_primitive)
- [`create_sketch`](#create_sketch)
- [Geometry Tools](#geometry-tools)
- [`add_sketch_geometry`](#add_sketch_geometry)
- [`boolean_operation`](#boolean_operation)
- [Export/Import Tools](#exportimport-tools)
- [`export_mesh`](#export_mesh)
- [`export_step`](#export_step)
- [Debugging Tools](#debugging-tools)
- [`inspect_object`](#inspect_object)
- [`validate_model`](#validate_model)
- [Workbench Tools](#workbench-tools)
- [`activate_workbench`](#activate_workbench)
- [`list_workbench_commands`](#list_workbench_commands)
- [MCP Resources Specification](#mcp-resources-specification)
- [Document Resources](#document-resources)
- [Console Resources](#console-resources)
- [Environment Resources](#environment-resources)
- [Communication Patterns](#communication-patterns)
- [Pattern 1: Stdio Transport (Default for MCP Clients)](#pattern-1-stdio-transport-default-for-mcp-clients)
- [Pattern 2: HTTP Transport (Remote/Shared)](#pattern-2-http-transport-remoteshared)
- [Pattern 3: GUI Integration](#pattern-3-gui-integration)
- [Security Considerations](#security-considerations)
- [Code Execution Sandboxing](#code-execution-sandboxing)
- [Resource Limits](#resource-limits)
- [Authentication (for HTTP transport)](#authentication-for-http-transport)
- [Deployment Modes](#deployment-modes)
- [Mode 1: XML-RPC Mode (Recommended)](#mode-1-xml-rpc-mode-recommended)
- [Mode 2: Local Embedded (Linux Only)](#mode-2-local-embedded-linux-only)
- [Mode 3: Local GUI with Plugin](#mode-3-local-gui-with-plugin)
- [Mode 4: Remote/Docker](#mode-4-remotedocker)
- [Error Handling](#error-handling)
- [Error Categories](#error-categories)
- [Error Response Format](#error-response-format)
- [Future Extensibility](#future-extensibility)
- [Planned Features](#planned-features)
- [Extension Points](#extension-points)
- [Summary](#summary)
<!--TOC-->
---
## Competitive Analysis
See [COMPARISON.md](../COMPARISON.md) for detailed analysis of existing implementations.
### Existing FreeCAD Robust MCP Servers
| Project | Stars | Approach | Strengths |
| ----------------------------------------------------------------------------------- | ----- | -------------- | ------------------------------------------------- |
| [neka-nat/freecad-mcp](https://github.com/neka-nat/freecad-mcp) | 380+ | XML-RPC | Thread-safe GUI ops, screenshots, parts library |
| [jango-blockchained/mcp-freecad](https://github.com/jango-blockchained/mcp-freecad) | - | Multi-mode | 6 connection modes, multi-AI, connection recovery |
| [contextform/freecad-mcp](https://github.com/contextform/freecad-mcp) | - | Node.js bridge | 31+ CAD operations, installer, demos |
| [ATOI-Ming/FreeCAD-MCP](https://github.com/ATOI-Ming/FreeCAD-MCP) | - | Macro-centric | GUI panel, macro templates, validation |
| [bonninr/freecad_mcp](https://github.com/bonninr/freecad_mcp) | - | Simple socket | Minimal, lightweight |
### Our Differentiators
1. **True Headless Mode**: Embedded bridge runs FreeCAD in-process (unique)
1. **Dual Protocol**: XML-RPC (compatibility) + JSON-RPC (modern)
1. **MCP Resources**: Document/object introspection (no other impl has this)
1. **MCP Prompts**: Guided workflow templates (unique)
1. **Developer Focus**: Debugging tools, console access, macro development
1. **Thread-Safe Design**: Queue-based GUI operations (learned from neka-nat)
1. **Connection Resilience**: Auto-reconnect with health monitoring
### Key Learnings Applied
1. **From neka-nat**: Queue-based GUI thread safety, smart screenshot handling
1. **From jango**: Multiple connection modes, connection recovery
1. **From contextform**: Comprehensive PartDesign/Part operations
1. **From ATOI-Ming**: Macro templates, validation, automatic imports
---
## System Overview
### What is MCP?
The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 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](https://www.freecadweb.org/) 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
```text
┌─────────────────────────────────────────────────────────────────────────┐
│ 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
1. **Full Python Console Access**: Execute arbitrary Python code in FreeCAD's context
1. **Bidirectional Communication**: Send commands and receive results/errors
1. **Dual Mode Support**: Work with both GUI and headless FreeCAD instances
1. **Real-time Feedback**: Stream console output and execution results
1. **Document Introspection**: Query and understand FreeCAD document structure
1. **Safe Execution**: Sandboxed execution with timeout and resource limits
### Secondary Goals
1. **Macro Development**: Assist in writing, testing, and debugging macros
1. **Workbench Integration**: Access workbench-specific functionality
1. **Model Validation**: Check model integrity and constraints
1. **Export Capabilities**: Generate various output formats (STEP, STL, etc.)
1. **Version Compatibility**: Support FreeCAD 0.21+ and 1.0+
---
## High-Level Architecture
### Architecture Pattern: Bridge with Adapter
```text
┌─────────────────────────────────────────────────────────────────────────┐
│ 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
```text
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 (82 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.
```python
"""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.
```python
"""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).
```python
"""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).
```python
"""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.
```python
"""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.
```python
@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.
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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`
```python
@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
```python
@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
```python
@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
```python
@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)
```text
MCP Client <--stdio--> MCP Server <--embedded/socket--> FreeCAD
```
Configuration in Claude Code settings (similar for other MCP clients):
```json
{
"mcpServers": {
"freecad": {
"command": "uv",
"args": ["run", "freecad-mcp"],
"env": {
"FREECAD_MODE": "xmlrpc"
}
}
}
}
```
### Pattern 2: HTTP Transport (Remote/Shared)
```text
MCP Client <--HTTP--> MCP Server <--socket--> FreeCAD
```
For shared or remote setups:
```json
{
"mcpServers": {
"freecad": {
"url": "http://localhost:8000/mcp",
"transport": "streamable-http"
}
}
}
```
### Pattern 3: GUI Integration
When FreeCAD GUI is running:
1. User installs FreeCAD plugin via Addon Manager
1. Plugin starts socket server on FreeCAD startup
1. MCP server connects via socket bridge
1. Full GUI access including view manipulation
```text
┌─────────────────────────────────────────────────────────────┐
│ 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
```python
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
```python
@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)
```python
# 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).
```yaml
# .mise.toml
[tools]
python = "3.11" # Required for FreeCAD ABI compatibility
uv = "latest"
[env]
FREECAD_MODE = "xmlrpc"
FREECAD_XMLRPC_PORT = "9875"
```
**Setup:**
1. Install the Robust MCP Bridge workbench via FreeCAD Addon Manager, or run `just freecad::run-gui` from source
1. Start the bridge using the workbench toolbar button or menu
1. 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.
```yaml
# .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
1. Install FreeCAD plugin:
```bash
just install-freecad-plugin
```
1. Configure MCP server:
```yaml
FREECAD_MODE = "socket"
FREECAD_SOCKET_HOST = "localhost"
FREECAD_SOCKET_PORT = "9876"
```
### Mode 4: Remote/Docker
```dockerfile
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
```python
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
```json
{
"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
1. **Streaming Execution Output**: Real-time stdout/stderr streaming for long operations
1. **View Manipulation**: Camera control, section views, rendering in GUI mode
1. **Parametric Updates**: Modify parameters and observe model updates
1. **Assembly Support**: Integration with Assembly3/4 workbenches
1. **FEM Integration**: Finite Element Analysis setup and results
1. **Path/CAM Support**: CAM toolpath generation and simulation
1. **Multi-Document**: Work across multiple documents simultaneously
1. **Undo/Redo**: Transaction management with rollback capability
### Extension Points
```python
# 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:
1. **Full Python Console Access**: Execute any FreeCAD Python code
1. **Dual Mode Support**: Works with GUI and headless FreeCAD
1. **Comprehensive Tooling**: Document, object, geometry, export tools
1. **Rich Resources**: Query documents, objects, console, workbenches
1. **Secure Execution**: Sandboxing, timeouts, resource limits
1. **Flexible Deployment**: Embedded, socket, remote modes
1. **Extensible Design**: Plugin system for custom functionality
The MCP server acts as a powerful bridge enabling AI assistants to become intelligent FreeCAD development partners.