Files
freecad-robust-mcp-fc111/ARCHITECTURE-MCP.md
T

55 KiB

FreeCAD 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


Competitive Analysis

See docs/COMPARISON.md for detailed analysis of existing implementations.

Existing FreeCAD 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

  1. True Headless Mode: Embedded bridge runs FreeCAD in-process (unique)
  2. Dual Protocol: XML-RPC (compatibility) + JSON-RPC (modern)
  3. MCP Resources: Document/object introspection (no other impl has this)
  4. MCP Prompts: Guided workflow templates (unique)
  5. Developer Focus: Debugging tools, console access, macro development
  6. Thread-Safe Design: Queue-based GUI operations (learned from neka-nat)
  7. Connection Resilience: Auto-reconnect with health monitoring

Key Learnings Applied

  1. From neka-nat: Queue-based GUI thread safety, smart screenshot handling
  2. From jango: Multiple connection modes, connection recovery
  3. From contextform: Comprehensive PartDesign/Part operations
  4. 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 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
  2. Bidirectional Communication: Send commands and receive results/errors
  3. Dual Mode Support: Work with both GUI and headless FreeCAD instances
  4. Real-time Feedback: Stream console output and execution results
  5. Document Introspection: Query and understand FreeCAD document structure
  6. Safe Execution: Sandboxed execution with timeout and resource limits

Secondary Goals

  1. Macro Development: Assist in writing, testing, and debugging macros
  2. Workbench Integration: Access workbench-specific functionality
  3. Model Validation: Check model integrity and constraints
  4. Export Capabilities: Generate various output formats (STEP, STL, etc.)
  5. 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 (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
│   └── headless_server.py   # Headless mode launcher
│
└── 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 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)

MCP Client <--stdio--> MCP Server <--embedded/socket--> FreeCAD

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)

MCP Client <--HTTP--> MCP Server <--socket--> FreeCAD

For shared or remote setups:

{
  "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
  2. Plugin starts socket server on FreeCAD startup
  3. MCP server connects via socket bridge
  4. 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

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:

  1. Run just install-bridge-macro to install the StartMCPBridge macro
  2. Start FreeCAD and execute the macro: Macro → Macros → StartMCPBridge → Execute
  3. The macro 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

  1. Install FreeCAD plugin:

    just install-freecad-plugin
    
  2. Configure MCP server:

    FREECAD_MODE = "socket"
    FREECAD_SOCKET_HOST = "localhost"
    FREECAD_SOCKET_PORT = "9876"
    

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

  1. Streaming Execution Output: Real-time stdout/stderr streaming for long operations
  2. View Manipulation: Camera control, section views, rendering in GUI mode
  3. Parametric Updates: Modify parameters and observe model updates
  4. Assembly Support: Integration with Assembly3/4 workbenches
  5. FEM Integration: Finite Element Analysis setup and results
  6. Path/CAM Support: CAM toolpath generation and simulation
  7. Multi-Document: Work across multiple documents simultaneously
  8. 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:

  1. Full Python Console Access: Execute any FreeCAD Python code
  2. Dual Mode Support: Works with GUI and headless FreeCAD
  3. Comprehensive Tooling: Document, object, geometry, export tools
  4. Rich Resources: Query documents, objects, console, workbenches
  5. Secure Execution: Sandboxing, timeouts, resource limits
  6. Flexible Deployment: Embedded, socket, remote modes
  7. Extensible Design: Plugin system for custom functionality

The MCP server acts as a powerful bridge enabling AI assistants to become intelligent FreeCAD development partners.