* 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
1666 lines
55 KiB
Markdown
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.
|