feat: Initial commit of FreeCAD MCP/tooling proj.

This commit is contained in:
Sean P. Kane
2026-01-03 16:48:59 -08:00
commit 8d1271995e
82 changed files with 26195 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
"""FreeCAD MCP Server - AI assistant integration for FreeCAD.
This package provides an MCP (Model Context Protocol) server that enables
integration between AI assistants (Claude, GPT, etc.) and FreeCAD, allowing
AI-assisted development and debugging of 3D models, macros, and workbenches.
Example:
Run the MCP server::
$ freecad-mcp
Or with Python::
>>> from freecad_mcp.server import main
>>> main()
"""
__version__ = "0.1.0"
__author__ = "Sean P. Kane"
__email__ = "spkane@gmail.com"
from freecad_mcp.server import mcp
__all__ = ["__version__", "mcp"]
+48
View File
@@ -0,0 +1,48 @@
"""FreeCAD bridge implementations.
This package provides bridge implementations for communicating with FreeCAD
in different modes (embedded, XML-RPC, and socket-based).
Bridge Modes:
- EmbeddedBridge: In-process FreeCAD for headless operation (fastest)
- XmlRpcBridge: XML-RPC protocol for GUI mode (neka-nat compatible)
- SocketBridge: JSON-RPC over TCP for modern, lightweight communication
"""
from freecad_mcp.bridge.base import (
ConnectionStatus,
DocumentInfo,
ExecutionResult,
FreecadBridge,
MacroInfo,
ObjectInfo,
ObjectType,
ScreenshotResult,
ShapeInfo,
ViewAngle,
WorkbenchInfo,
)
from freecad_mcp.bridge.embedded import EmbeddedBridge
from freecad_mcp.bridge.socket import JsonRpcError, SocketBridge
from freecad_mcp.bridge.xmlrpc import XmlRpcBridge
__all__ = [
# Base classes and types
"ConnectionStatus",
"DocumentInfo",
"ExecutionResult",
"FreecadBridge",
"MacroInfo",
"ObjectInfo",
"ObjectType",
"ScreenshotResult",
"ShapeInfo",
"ViewAngle",
"WorkbenchInfo",
# Bridge implementations
"EmbeddedBridge",
"XmlRpcBridge",
"SocketBridge",
# Exceptions
"JsonRpcError",
]
+611
View File
@@ -0,0 +1,611 @@
"""Abstract bridge interface for FreeCAD communication.
This module defines the abstract base class and data types for all FreeCAD
bridge implementations. Bridges provide the communication layer between
the MCP server and FreeCAD instances.
Based on learnings from existing implementations:
- neka-nat: Queue-based thread safety for GUI operations
- jango: Multiple connection modes with recovery
- contextform: Comprehensive CAD operations
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class ViewAngle(str, Enum):
"""Standard view angles for screenshots."""
ISOMETRIC = "Isometric"
FRONT = "Front"
BACK = "Back"
TOP = "Top"
BOTTOM = "Bottom"
LEFT = "Left"
RIGHT = "Right"
FIT_ALL = "FitAll"
class ObjectType(str, Enum):
"""FreeCAD object type categories."""
PART = "Part"
PART_DESIGN = "PartDesign"
DRAFT = "Draft"
SKETCHER = "Sketcher"
FEM = "Fem"
MESH = "Mesh"
SPREADSHEET = "Spreadsheet"
@dataclass
class ExecutionResult:
"""Result of Python code execution in FreeCAD.
Attributes:
success: Whether execution completed without errors.
result: The value assigned to `_result_` variable, or None.
stdout: Captured standard output.
stderr: Captured standard error.
execution_time_ms: Time taken in milliseconds.
error_type: Type of exception if failed, None otherwise.
error_traceback: Full traceback if failed, None otherwise.
"""
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.
Attributes:
name: Internal document name (identifier).
label: Display label (may differ from name).
path: File path if saved, None otherwise.
objects: List of object names in the document.
is_modified: Whether document has unsaved changes.
active_object: Name of the currently active object.
"""
name: str
label: str = ""
path: str | None = None
objects: list[str] = field(default_factory=list)
is_modified: bool = False
active_object: str | None = None
def __post_init__(self) -> None:
"""Set label to name if not provided."""
if not self.label:
self.label = self.name
@dataclass
class ObjectInfo:
"""Information about a FreeCAD object.
Attributes:
name: Object name (identifier).
label: Display label.
type_id: FreeCAD TypeId string (e.g., "Part::Box").
properties: Dictionary of property names to values.
shape_info: Shape geometry details if applicable.
children: List of child object names (OutList).
parents: List of parent object names (InList).
visibility: Whether object is visible in the view.
"""
name: str
label: str
type_id: str
properties: dict[str, Any] = field(default_factory=dict)
shape_info: dict[str, Any] | None = None
children: list[str] = field(default_factory=list)
parents: list[str] = field(default_factory=list)
visibility: bool = True
@dataclass
class ShapeInfo:
"""Detailed shape geometry information.
Attributes:
shape_type: Type of shape (Solid, Shell, Face, etc.).
volume: Volume of the shape (for solids).
area: Surface area of the shape.
center_of_mass: Center of mass coordinates.
bounding_box: Bounding box as (min, max) tuples.
is_valid: Whether the shape is geometrically valid.
is_closed: Whether the shape is closed.
vertex_count: Number of vertices.
edge_count: Number of edges.
face_count: Number of faces.
"""
shape_type: str
volume: float | None = None
area: float | None = None
center_of_mass: tuple[float, float, float] | None = None
bounding_box: (
tuple[tuple[float, float, float], tuple[float, float, float]] | None
) = None
is_valid: bool = True
is_closed: bool = False
vertex_count: int = 0
edge_count: int = 0
face_count: int = 0
@dataclass
class ScreenshotResult:
"""Result of a screenshot capture.
Attributes:
success: Whether screenshot was captured successfully.
data: Base64-encoded image data.
format: Image format (png, jpg).
width: Image width in pixels.
height: Image height in pixels.
view_angle: The view angle used.
error: Error message if failed.
"""
success: bool
data: str | None = None
format: str = "png"
width: int = 0
height: int = 0
view_angle: ViewAngle | None = None
error: str | None = None
@dataclass
class MacroInfo:
"""Information about a FreeCAD macro.
Attributes:
name: Macro name (without extension).
path: Full path to macro file.
description: Macro description from comments.
is_system: Whether it's a system macro.
"""
name: str
path: str
description: str = ""
is_system: bool = False
@dataclass
class WorkbenchInfo:
"""Information about a FreeCAD workbench.
Attributes:
name: Workbench internal name.
label: Display label.
icon: Icon resource path.
is_active: Whether workbench is currently active.
"""
name: str
label: str
icon: str = ""
is_active: bool = False
@dataclass
class ConnectionStatus:
"""Status of the FreeCAD connection.
Attributes:
connected: Whether connection is established.
mode: Connection mode (embedded, xmlrpc, socket).
freecad_version: FreeCAD version string.
gui_available: Whether GUI is available.
last_ping_ms: Last ping latency in milliseconds.
error: Connection error message if any.
"""
connected: bool
mode: str
freecad_version: str = ""
gui_available: bool = False
last_ping_ms: float = 0
error: str | None = None
class FreecadBridge(ABC):
"""Abstract base class for FreeCAD bridges.
A bridge provides communication between the MCP server and a FreeCAD
instance. Implementations may run FreeCAD in-process (embedded),
communicate via XML-RPC, or use JSON-RPC over sockets.
Thread Safety:
GUI operations must be executed on the main thread. Implementations
should use queue-based communication for thread safety (learned from
neka-nat implementation).
"""
@abstractmethod
async def connect(self) -> None:
"""Establish connection to FreeCAD.
Raises:
ConnectionError: If connection cannot be established.
"""
@abstractmethod
async def disconnect(self) -> None:
"""Close connection to FreeCAD.
Should be called during cleanup to release resources.
"""
@abstractmethod
async def is_connected(self) -> bool:
"""Check if bridge is connected to FreeCAD.
Returns:
True if connected, False otherwise.
"""
@abstractmethod
async def ping(self) -> float:
"""Ping FreeCAD to check connection and measure latency.
Returns:
Round-trip time in milliseconds.
Raises:
ConnectionError: If not connected.
"""
@abstractmethod
async def get_status(self) -> ConnectionStatus:
"""Get detailed connection status.
Returns:
ConnectionStatus with full status information.
"""
# =========================================================================
# Code Execution
# =========================================================================
@abstractmethod
async def execute_python(
self,
code: str,
timeout_ms: int = 30000,
) -> ExecutionResult:
"""Execute Python code in FreeCAD context.
The code runs with access to FreeCAD modules (FreeCAD, App, Gui).
To return a value, assign it to the `_result_` variable.
Args:
code: Python code to execute.
timeout_ms: Maximum execution time in milliseconds.
Returns:
ExecutionResult with success status, output, and any errors.
"""
# =========================================================================
# Document Management
# =========================================================================
@abstractmethod
async def get_documents(self) -> list[DocumentInfo]:
"""Get list of open documents.
Returns:
List of DocumentInfo for each open document.
"""
@abstractmethod
async def get_active_document(self) -> DocumentInfo | None:
"""Get the active document.
Returns:
DocumentInfo for active document, or None if no document is active.
"""
@abstractmethod
async def create_document(
self, name: str, label: str | None = None
) -> DocumentInfo:
"""Create a new document.
Args:
name: Internal document name (no spaces).
label: Display label (optional, defaults to name).
Returns:
DocumentInfo for the created document.
"""
@abstractmethod
async def open_document(self, path: str) -> DocumentInfo:
"""Open an existing document.
Args:
path: Path to the .FCStd file.
Returns:
DocumentInfo for the opened document.
Raises:
FileNotFoundError: If file doesn't exist.
ValueError: If file is not a valid FreeCAD document.
"""
@abstractmethod
async def save_document(
self,
doc_name: str | None = None,
path: str | None = None,
) -> str:
"""Save a document.
Args:
doc_name: Document name (uses active if None).
path: Save path (uses existing path if None).
Returns:
Path where document was saved.
Raises:
ValueError: If document not found or no path specified for new doc.
"""
@abstractmethod
async def close_document(self, doc_name: str | None = None) -> None:
"""Close a document.
Args:
doc_name: Document name (uses active if None).
"""
# =========================================================================
# Object Management
# =========================================================================
@abstractmethod
async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]:
"""Get all objects in a document.
Args:
doc_name: Document name (uses active if None).
Returns:
List of ObjectInfo for each object.
"""
@abstractmethod
async def get_object(
self,
obj_name: str,
doc_name: str | None = None,
) -> ObjectInfo:
"""Get detailed object information.
Args:
obj_name: Name of the object.
doc_name: Document name (uses active if None).
Returns:
ObjectInfo with full object details.
Raises:
ValueError: If object not found.
"""
@abstractmethod
async def create_object(
self,
type_id: str,
name: str | None = None,
properties: dict[str, Any] | None = None,
doc_name: str | None = None,
) -> ObjectInfo:
"""Create a new object.
Args:
type_id: FreeCAD type ID (e.g., "Part::Box", "Part::Cylinder").
name: Object name (auto-generated if None).
properties: Initial property values.
doc_name: Target document (uses active if None).
Returns:
ObjectInfo for the created object.
"""
@abstractmethod
async def edit_object(
self,
obj_name: str,
properties: dict[str, Any],
doc_name: str | None = None,
) -> ObjectInfo:
"""Edit object properties.
Args:
obj_name: Name of the object to edit.
properties: Property values to set.
doc_name: Document name (uses active if None).
Returns:
Updated ObjectInfo.
Raises:
ValueError: If object not found.
"""
@abstractmethod
async def delete_object(
self,
obj_name: str,
doc_name: str | None = None,
) -> None:
"""Delete an object.
Args:
obj_name: Name of the object to delete.
doc_name: Document name (uses active if None).
Raises:
ValueError: If object not found.
"""
# =========================================================================
# View and Screenshot
# =========================================================================
@abstractmethod
async def get_screenshot(
self,
view_angle: ViewAngle | None = None,
width: int = 800,
height: int = 600,
doc_name: str | None = None,
) -> ScreenshotResult:
"""Capture a screenshot of the 3D view.
Args:
view_angle: View angle to set before capture.
width: Image width in pixels.
height: Image height in pixels.
doc_name: Document name (uses active if None).
Returns:
ScreenshotResult with image data or error.
"""
@abstractmethod
async def set_view(
self,
view_angle: ViewAngle,
doc_name: str | None = None,
) -> None:
"""Set the 3D view angle.
Args:
view_angle: View angle to set.
doc_name: Document name (uses active if None).
"""
# =========================================================================
# Macros
# =========================================================================
@abstractmethod
async def get_macros(self) -> list[MacroInfo]:
"""Get list of available macros.
Returns:
List of MacroInfo for each macro.
"""
@abstractmethod
async def run_macro(
self,
macro_name: str,
args: dict[str, Any] | None = None,
) -> ExecutionResult:
"""Run a macro by name.
Args:
macro_name: Macro name (without .FCMacro extension).
args: Arguments to pass to the macro.
Returns:
ExecutionResult from macro execution.
"""
@abstractmethod
async def create_macro(
self,
name: str,
code: str,
description: str = "",
) -> MacroInfo:
"""Create a new macro.
Args:
name: Macro name (without extension).
code: Python code for the macro.
description: Macro description.
Returns:
MacroInfo for the created macro.
"""
# =========================================================================
# Workbenches
# =========================================================================
@abstractmethod
async def get_workbenches(self) -> list[WorkbenchInfo]:
"""Get list of available workbenches.
Returns:
List of WorkbenchInfo for each workbench.
"""
@abstractmethod
async def activate_workbench(self, workbench_name: str) -> None:
"""Activate a workbench.
Args:
workbench_name: Workbench internal name.
Raises:
ValueError: If workbench not found.
"""
# =========================================================================
# Version and Environment
# =========================================================================
@abstractmethod
async def get_freecad_version(self) -> dict[str, Any]:
"""Get FreeCAD version information.
Returns:
Dictionary with version, build_date, python_version, gui_available.
"""
@abstractmethod
async def is_gui_available(self) -> bool:
"""Check if FreeCAD GUI is available.
Returns:
True if GUI is available, False for headless mode.
"""
# =========================================================================
# Console
# =========================================================================
@abstractmethod
async def get_console_output(self, lines: int = 100) -> list[str]:
"""Get recent console output.
Args:
lines: Maximum number of lines to return.
Returns:
List of console output lines, most recent last.
"""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
"""Configuration management for FreeCAD MCP Server.
This module handles all configuration settings for the MCP server,
including FreeCAD connection settings, execution limits, and logging.
"""
from enum import Enum
from pathlib import Path
from typing import Annotated
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class FreecadMode(str, Enum):
"""FreeCAD connection mode."""
EMBEDDED = "embedded"
SOCKET = "socket"
XMLRPC = "xmlrpc"
class TransportType(str, Enum):
"""MCP transport type."""
STDIO = "stdio"
HTTP = "http"
class ServerConfig(BaseSettings):
"""Configuration for the FreeCAD MCP server.
Settings are loaded from environment variables with the FREECAD_ prefix.
For example, FREECAD_MODE sets the mode field.
Attributes:
mode: Connection mode - 'embedded', 'socket', or 'xmlrpc'.
freecad_path: Path to FreeCAD's lib directory (for embedded mode).
socket_host: Hostname for socket/xmlrpc connection.
socket_port: Port for JSON-RPC socket connection (default 9876).
xmlrpc_port: Port for XML-RPC connection (default 9875, neka-nat compatible).
timeout_ms: Default execution timeout in milliseconds.
max_output_size: Maximum output size in bytes.
transport: MCP transport type.
http_port: Port for HTTP transport.
log_level: Logging level.
"""
model_config = SettingsConfigDict(
env_prefix="FREECAD_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# FreeCAD connection settings
mode: FreecadMode = FreecadMode.EMBEDDED
freecad_path: Annotated[
Path | None,
Field(
description="Path to FreeCAD's lib directory",
alias="FREECAD_PATH",
),
] = None
# Socket settings (for socket mode)
socket_host: Annotated[
str,
Field(description="Socket/XML-RPC server hostname"),
] = "localhost"
socket_port: Annotated[
int,
Field(ge=1, le=65535, description="Socket server port (JSON-RPC)"),
] = 9876
xmlrpc_port: Annotated[
int,
Field(ge=1, le=65535, description="XML-RPC server port (neka-nat compatible)"),
] = 9875
# Execution limits
timeout_ms: Annotated[
int,
Field(ge=1000, le=600000, description="Execution timeout in ms"),
] = 30000
max_output_size: Annotated[
int,
Field(ge=1000, description="Maximum output size in bytes"),
] = 1_000_000
# MCP transport settings
transport: TransportType = TransportType.STDIO
http_port: Annotated[
int,
Field(ge=1, le=65535, description="HTTP server port"),
] = 8000
# Logging
log_level: str = "INFO"
# Security settings
enable_sandbox: bool = True
allow_file_access: bool = True
allow_network_access: bool = False
def get_config() -> ServerConfig:
"""Get the server configuration.
Returns:
ServerConfig instance populated from environment variables.
"""
return ServerConfig()
@@ -0,0 +1,58 @@
"""FreeCAD MCP Bridge Plugin.
This package is installed into FreeCAD's Mod directory to provide
socket and XML-RPC servers for MCP communication.
Based on learnings from competitive analysis:
- Queue-based GUI communication for thread safety (from neka-nat)
- JSON-RPC 2.0 protocol for modern integration (port 9876)
- XML-RPC compatibility mode for neka-nat addons (port 9875)
To install, copy this directory to:
- macOS: ~/Library/Application Support/FreeCAD/Mod/MCPBridge/
- Linux: ~/.local/share/FreeCAD/Mod/MCPBridge/
- Windows: %APPDATA%/FreeCAD/Mod/MCPBridge/
Or use the justfile command:
just install-freecad-plugin
"""
from freecad_mcp.freecad_plugin.server import (
DEFAULT_SOCKET_PORT,
DEFAULT_XMLRPC_PORT,
FreecadMCPPlugin,
)
__all__ = [
"DEFAULT_SOCKET_PORT",
"DEFAULT_XMLRPC_PORT",
"FreecadMCPPlugin",
"start",
]
def start(
host: str = "localhost",
port: int = DEFAULT_SOCKET_PORT,
xmlrpc_port: int = DEFAULT_XMLRPC_PORT,
enable_xmlrpc: bool = True,
) -> FreecadMCPPlugin:
"""Start the MCP bridge servers.
Args:
host: Hostname to bind to.
port: Port for JSON-RPC socket server.
xmlrpc_port: Port for XML-RPC server.
enable_xmlrpc: Whether to enable XML-RPC server.
Returns:
The running plugin instance.
"""
plugin = FreecadMCPPlugin(
host=host,
port=port,
xmlrpc_port=xmlrpc_port,
enable_xmlrpc=enable_xmlrpc,
)
plugin.start()
return plugin
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""FreeCAD MCP Bridge Auto-Start Script for GUI mode.
This script is run automatically when FreeCAD GUI starts via `just run-gui`.
It starts the MCP bridge servers to allow AI assistants to communicate with FreeCAD.
Usage:
This script is passed to FreeCAD as a startup script:
/Applications/FreeCAD.app/Contents/Resources/bin/freecad gui_startup.py
Note: This script imports FreecadMCPPlugin directly from server.py to avoid
triggering the MCP SDK import in freecad_mcp/__init__.py (which isn't available
in FreeCAD's embedded Python environment).
"""
from __future__ import annotations
import sys
from pathlib import Path
# Check if we're running inside FreeCAD
try:
import FreeCAD
except ImportError:
print("ERROR: This script must be run inside FreeCAD.")
sys.exit(1)
# Import the plugin server directly from the module file
# We avoid importing through the package hierarchy (freecad_mcp.freecad_plugin.server)
# because freecad_mcp/__init__.py imports the MCP SDK which isn't available
# in FreeCAD's embedded Python environment
script_dir = str(Path(__file__).resolve().parent)
sys.path.insert(0, script_dir)
# Import and start the plugin
try:
from server import FreecadMCPPlugin # Direct import from same directory
# Create and start the plugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port (neka-nat compatible)
enable_xmlrpc=True,
)
plugin.start()
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 60 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n")
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage(
"You can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
FreeCAD.Console.PrintMessage("=" * 60 + "\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
import traceback
traceback.print_exc()
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Headless FreeCAD MCP Bridge Server.
This script starts the MCP bridge server in FreeCAD's headless mode.
It should be run with FreeCADCmd (the headless FreeCAD executable).
Usage:
FreeCADCmd headless_server.py
# or
freecadcmd headless_server.py
Note: In headless mode, GUI features like screenshots are not available.
For full functionality, use the StartMCPBridge macro in FreeCAD's GUI.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Check if we're running inside FreeCAD
try:
import FreeCAD
print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}")
except ImportError:
print("ERROR: This script must be run with FreeCADCmd or inside FreeCAD.")
print("")
print("Usage:")
print(" just run-headless")
print(" # or")
print(" FreeCADCmd headless_server.py")
print(" freecadcmd headless_server.py")
print("")
print("On macOS:")
print(
" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd headless_server.py"
)
print("")
print("On Linux:")
print(" freecadcmd headless_server.py")
sys.exit(1)
# Import the plugin server directly from the module file
# We avoid importing through the package hierarchy (freecad_mcp.freecad_plugin.server)
# because freecad_mcp/__init__.py imports the MCP SDK which isn't available
# in FreeCAD's embedded Python environment
script_dir = str(Path(__file__).resolve().parent)
# Import directly from server.py in the same directory
sys.path.insert(0, script_dir)
from server import FreecadMCPPlugin # noqa: E402
# Create and run the plugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port (neka-nat compatible)
enable_xmlrpc=True,
)
# Start the plugin
plugin.start()
# Print status messages with flush to ensure they appear immediately
# (FreeCAD's Python may have buffered stdout)
print("", flush=True)
print("=" * 60, flush=True)
print("MCP Bridge started in headless mode!", flush=True)
print(" - XML-RPC: localhost:9875", flush=True)
print(" - Socket: localhost:9876", flush=True)
print("", flush=True)
print(
"Note: Screenshot and view features are not available in headless mode.", flush=True
)
print("Press Ctrl+C to stop.", flush=True)
print("=" * 60, flush=True)
print("", flush=True)
# Run forever (blocks until Ctrl+C)
plugin.run_forever()
+708
View File
@@ -0,0 +1,708 @@
"""FreeCAD MCP Bridge Plugin - Socket Server with Queue-based Thread Safety.
This module provides a socket server that runs inside FreeCAD to handle
MCP bridge requests. It must be executed within FreeCAD's Python environment.
Design inspired by neka-nat/freecad-mcp (MIT License):
- Queue-based GUI communication for thread safety
- XML-RPC compatibility mode (port 9875)
- Screenshot capture with view type detection
Attribution:
The queue-based thread safety pattern and XML-RPC protocol design were
inspired by neka-nat/freecad-mcp (https://github.com/neka-nat/freecad-mcp),
which is licensed under the MIT License. This implementation is a complete
rewrite with additional features (JSON-RPC 2.0, async socket server).
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import json
import queue
import threading
import time
import traceback
import xmlrpc.server
from contextlib import redirect_stderr, redirect_stdout
from typing import Any
# These imports only work inside FreeCAD
try:
import FreeCAD
import FreeCADGui
FREECAD_AVAILABLE = True
except ImportError:
FREECAD_AVAILABLE = False
# Default configuration
DEFAULT_SOCKET_PORT = 9876
DEFAULT_XMLRPC_PORT = 9875
QUEUE_POLL_INTERVAL_MS = 50
STATUS_UPDATE_INTERVAL_MS = 5000 # Update status bar every 5 seconds
class ExecutionRequest:
"""Represents a code execution request."""
def __init__(
self,
code: str,
timeout_ms: int = 30000,
request_id: str | None = None,
) -> None:
"""Initialize execution request.
Args:
code: Python code to execute.
timeout_ms: Execution timeout in milliseconds.
request_id: Optional request ID for tracking.
"""
self.code = code
self.timeout_ms = timeout_ms
self.request_id = request_id
self.result: dict[str, Any] | None = None
self.completed = threading.Event()
class FreecadMCPPlugin:
"""Plugin that runs inside FreeCAD to handle MCP bridge requests.
This class creates servers that accept connections from the MCP server
and executes commands in FreeCAD's context using a thread-safe queue
system for GUI operations.
Attributes:
socket_host: Hostname for socket server.
socket_port: Port for JSON-RPC socket server.
xmlrpc_port: Port for XML-RPC server.
"""
def __init__(
self,
host: str = "localhost",
port: int = DEFAULT_SOCKET_PORT,
xmlrpc_port: int = DEFAULT_XMLRPC_PORT,
enable_xmlrpc: bool = True,
) -> None:
"""Initialize the plugin.
Args:
host: Hostname to bind to.
port: Port for JSON-RPC socket server.
xmlrpc_port: Port for XML-RPC server.
enable_xmlrpc: Whether to enable XML-RPC server.
"""
self._host = host
self._port = port
self._xmlrpc_port = xmlrpc_port
self._enable_xmlrpc = enable_xmlrpc
# Server instances
self._socket_server: asyncio.Server | None = None
self._xmlrpc_server: xmlrpc.server.SimpleXMLRPCServer | None = None
self._socket_loop: asyncio.AbstractEventLoop | None = None
# Threading
self._socket_thread: threading.Thread | None = None
self._xmlrpc_thread: threading.Thread | None = None
self._running = False
# Queue-based execution for thread safety (learned from neka-nat)
self._request_queue: queue.Queue[ExecutionRequest] = queue.Queue()
self._timer = None
self._queue_thread: threading.Thread | None = None
self._headless = False
# Status bar tracking
self._status_timer = None
self._request_count = 0
self._last_request_time: float | None = None
def start(self) -> None:
"""Start all servers."""
if self._running:
return
self._running = True
# Start the queue processing timer on the main thread
self._start_queue_processor()
# Start socket server
self._socket_thread = threading.Thread(
target=self._run_socket_server,
daemon=True,
name="MCP-Socket",
)
self._socket_thread.start()
# Start XML-RPC server if enabled
if self._enable_xmlrpc:
self._xmlrpc_thread = threading.Thread(
target=self._run_xmlrpc_server,
daemon=True,
name="MCP-XMLRPC",
)
self._xmlrpc_thread.start()
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(
f"MCP Bridge started:\n - JSON-RPC: {self._host}:{self._port}\n"
)
if self._enable_xmlrpc:
FreeCAD.Console.PrintMessage(
f" - XML-RPC: {self._host}:{self._xmlrpc_port}\n"
)
# Start status bar updates in GUI mode
self._start_status_updates()
def stop(self) -> None:
"""Stop all servers."""
self._running = False
# Stop status bar updates
self._stop_status_updates()
# Stop queue processor timer (GUI mode)
if self._timer:
with contextlib.suppress(Exception):
self._timer.stop()
self._timer = None
# Stop queue processor thread (headless mode)
if self._queue_thread:
self._queue_thread.join(timeout=2.0)
self._queue_thread = None
# Stop socket server
if self._socket_loop and self._socket_server:
self._socket_loop.call_soon_threadsafe(self._socket_server.close)
# Stop XML-RPC server
if self._xmlrpc_server:
self._xmlrpc_server.shutdown()
# Wait for threads
if self._socket_thread:
self._socket_thread.join(timeout=5.0)
self._socket_thread = None
if self._xmlrpc_thread:
self._xmlrpc_thread.join(timeout=5.0)
self._xmlrpc_thread = None
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("MCP Bridge stopped\n")
def run_forever(self) -> None:
"""Run the server indefinitely (for headless mode).
This method blocks until interrupted (Ctrl+C) or stop() is called.
Use this when running FreeCAD in headless/console mode.
"""
self.start()
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("Server running. Press Ctrl+C to stop.\n")
try:
while self._running:
time.sleep(0.5)
except KeyboardInterrupt:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("\nShutting down...\n")
finally:
self.stop()
# =========================================================================
# Status Bar Updates (GUI mode only)
# =========================================================================
def _start_status_updates(self) -> None:
"""Start periodic status bar updates in GUI mode."""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return
# Try to import Qt - need to check both PySide2 and PySide6
QtCore = None
with contextlib.suppress(ImportError):
from PySide2 import QtCore # type: ignore[no-redef]
if QtCore is None:
with contextlib.suppress(ImportError):
from PySide6 import QtCore # type: ignore[no-redef]
if QtCore is None:
return
# Create timer for status updates
timer = QtCore.QTimer()
timer.timeout.connect(self._update_status_bar)
timer.start(STATUS_UPDATE_INTERVAL_MS)
self._status_timer = timer
# Show initial status
self._update_status_bar()
def _stop_status_updates(self) -> None:
"""Stop status bar updates and clear the status."""
if self._status_timer:
with contextlib.suppress(Exception):
self._status_timer.stop()
self._status_timer = None
# Clear status bar message
if FREECAD_AVAILABLE and FreeCAD.GuiUp:
self._set_status_bar("")
def _update_status_bar(self) -> None:
"""Update the FreeCAD status bar with MCP bridge status."""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return
# Build status message
ports = f"XML-RPC:{self._xmlrpc_port}" if self._enable_xmlrpc else ""
if ports:
ports = f" ({ports})"
if self._request_count > 0:
# Show activity info
if self._last_request_time:
elapsed = time.time() - self._last_request_time
if elapsed < 60:
time_ago = f"{int(elapsed)}s ago"
else:
time_ago = f"{int(elapsed / 60)}m ago"
status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests | last: {time_ago}"
else:
status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests"
else:
status = f"🔌 MCP Bridge running{ports} | waiting for connections..."
self._set_status_bar(status)
def _set_status_bar(self, message: str) -> None:
"""Set the FreeCAD main window status bar message.
Args:
message: Message to display in status bar.
"""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return
try:
main_window = FreeCADGui.getMainWindow()
if main_window:
status_bar = main_window.statusBar()
if status_bar:
if message:
# Show message persistently (0 = no timeout)
status_bar.showMessage(message, 0)
else:
status_bar.clearMessage()
except Exception:
# Silently ignore status bar errors
pass
def _record_request(self) -> None:
"""Record that a request was processed (for status tracking)."""
self._request_count += 1
self._last_request_time = time.time()
# =========================================================================
# Queue-based Thread Safety (from neka-nat)
# =========================================================================
def _start_queue_processor(self) -> None:
"""Start the queue processor on the main GUI thread or as background thread."""
# Check if we're in GUI mode using FreeCAD.GuiUp
# Note: Qt (PySide) may be available even in headless mode, but without
# a running event loop, Qt timers won't fire. Use GuiUp to detect this.
gui_available = FREECAD_AVAILABLE and FreeCAD.GuiUp
if gui_available:
# GUI mode: use Qt timer for thread-safe GUI operations
try:
from PySide2 import QtCore
except ImportError:
try:
from PySide6 import QtCore
except ImportError:
QtCore = None # type: ignore[assignment]
if QtCore is not None:
timer = QtCore.QTimer()
timer.timeout.connect(self._process_queue)
timer.start(QUEUE_POLL_INTERVAL_MS)
self._timer = timer
return
# Headless mode: use a background thread for queue processing
# In headless mode, there's no GUI thread concern, so direct
# processing in a background thread is safe
self._headless = True
self._queue_thread = threading.Thread(
target=self._run_queue_processor_loop,
daemon=True,
name="MCP-QueueProcessor",
)
self._queue_thread.start()
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(
"Running in headless mode (queue processor thread started)\n"
)
def _run_queue_processor_loop(self) -> None:
"""Run queue processor in a loop for headless mode."""
while self._running:
self._process_queue()
time.sleep(QUEUE_POLL_INTERVAL_MS / 1000.0)
def _process_queue(self) -> None:
"""Process pending execution requests on the main thread.
This method is called periodically by a Qt timer to ensure
GUI operations happen on the main thread.
"""
while not self._request_queue.empty():
try:
request = self._request_queue.get_nowait()
result = self._execute_code_sync(request.code)
request.result = result
request.completed.set()
# Track request for status bar
self._record_request()
except queue.Empty:
break
except Exception as e:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(f"Queue processing error: {e}\n")
def _execute_via_queue(
self,
code: str,
timeout_ms: int = 30000,
) -> dict[str, Any]:
"""Execute code via the queue system for thread safety.
Args:
code: Python code to execute.
timeout_ms: Execution timeout in milliseconds.
Returns:
Execution result dictionary.
"""
request = ExecutionRequest(code, timeout_ms)
self._request_queue.put(request)
# Wait for completion
if request.completed.wait(timeout=timeout_ms / 1000):
return request.result or {
"success": False,
"error_type": "InternalError",
"error_message": "No result returned",
}
else:
return {
"success": False,
"error_type": "TimeoutError",
"error_message": f"Execution timed out after {timeout_ms}ms",
"execution_time_ms": timeout_ms,
}
def _execute_code_sync(self, code: str) -> dict[str, Any]:
"""Execute Python code synchronously (call on main thread only).
Args:
code: Python code to execute.
Returns:
Execution result dictionary.
"""
start = time.perf_counter()
stdout_capture = io.StringIO()
stderr_capture = io.StringIO()
exec_globals: dict[str, Any] = {
"__builtins__": __builtins__,
}
if FREECAD_AVAILABLE:
exec_globals["FreeCAD"] = FreeCAD
exec_globals["App"] = FreeCAD
exec_globals["FreeCADGui"] = FreeCADGui
exec_globals["Gui"] = FreeCADGui
try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
compiled = compile(code, "<mcp>", "exec")
exec(compiled, exec_globals) # noqa: S102
elapsed = (time.perf_counter() - start) * 1000
return {
"success": True,
"result": exec_globals.get("_result_"),
"stdout": stdout_capture.getvalue(),
"stderr": stderr_capture.getvalue(),
"execution_time_ms": elapsed,
}
except Exception as e:
elapsed = (time.perf_counter() - start) * 1000
return {
"success": False,
"result": None,
"stdout": stdout_capture.getvalue(),
"stderr": stderr_capture.getvalue(),
"execution_time_ms": elapsed,
"error_type": type(e).__name__,
"error_message": str(e),
"error_traceback": traceback.format_exc(),
}
# =========================================================================
# Socket Server (JSON-RPC 2.0)
# =========================================================================
def _run_socket_server(self) -> None:
"""Run the asyncio event loop in background thread."""
self._socket_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._socket_loop)
try:
self._socket_loop.run_until_complete(self._start_socket_server())
self._socket_loop.run_forever()
finally:
self._socket_loop.close()
async def _start_socket_server(self) -> None:
"""Start the TCP server."""
self._socket_server = await asyncio.start_server(
self._handle_socket_client,
self._host,
self._port,
)
async def _handle_socket_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Handle a connected socket client.
Args:
reader: Stream reader for incoming data.
writer: Stream writer for outgoing data.
"""
peer = writer.get_extra_info("peername")
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(f"MCP client connected (socket): {peer}\n")
try:
while self._running:
data = await reader.readline()
if not data:
break
try:
request = json.loads(data.decode("utf-8"))
response = await self._process_jsonrpc_request(request)
except json.JSONDecodeError as e:
response = {
"jsonrpc": "2.0",
"id": None,
"error": {
"code": -32700,
"message": "Parse error",
"data": str(e),
},
}
response_data = json.dumps(response).encode("utf-8") + b"\n"
writer.write(response_data)
await writer.drain()
except Exception as e:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(f"MCP socket error: {e}\n")
finally:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(f"MCP client disconnected: {peer}\n")
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
async def _process_jsonrpc_request(
self,
request: dict[str, Any],
) -> dict[str, Any]:
"""Process a JSON-RPC 2.0 request.
Args:
request: JSON-RPC request dictionary.
Returns:
JSON-RPC response dictionary.
"""
request_id = request.get("id")
method = request.get("method")
params = request.get("params", {})
# Handle ping specially (no queue needed)
if method == "ping":
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {"pong": True, "timestamp": time.time()},
}
# Handle execute via queue
if method == "execute":
code = params.get("code", "")
timeout_ms = params.get("timeout_ms", 30000)
# Execute via queue for thread safety
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: self._execute_via_queue(code, timeout_ms),
)
return {
"jsonrpc": "2.0",
"id": request_id,
"result": result,
}
# Unknown method
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": "Method not found",
"data": f"Unknown method: {method}",
},
}
# =========================================================================
# XML-RPC Server (neka-nat compatible)
# =========================================================================
def _run_xmlrpc_server(self) -> None:
"""Run the XML-RPC server."""
self._xmlrpc_server = xmlrpc.server.SimpleXMLRPCServer(
(self._host, self._xmlrpc_port),
allow_none=True,
logRequests=False,
)
# Register methods (type: ignore needed - xmlrpc types are overly restrictive)
self._xmlrpc_server.register_function(self._xmlrpc_execute, "execute") # type: ignore[arg-type]
self._xmlrpc_server.register_function(self._xmlrpc_ping, "ping") # type: ignore[arg-type]
self._xmlrpc_server.register_function(self._xmlrpc_get_view, "get_view") # type: ignore[arg-type]
self._xmlrpc_server.register_introspection_functions()
while self._running:
self._xmlrpc_server.handle_request()
def _xmlrpc_ping(self) -> dict[str, Any]:
"""XML-RPC ping handler."""
return {"pong": True, "timestamp": time.time()}
def _xmlrpc_execute(self, code: str) -> dict[str, Any]:
"""XML-RPC execute handler (neka-nat compatible).
Args:
code: Python code to execute.
Returns:
Execution result dictionary.
"""
return self._execute_via_queue(code, 30000)
def _xmlrpc_get_view(
self,
width: int = 800,
height: int = 600,
view_type: str = "Isometric",
) -> dict[str, Any]:
"""XML-RPC get_view handler for screenshots (neka-nat compatible).
Args:
width: Image width.
height: Image height.
view_type: View angle type.
Returns:
Dictionary with base64 image data or error.
"""
code = f"""
import base64
import tempfile
import os
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available"}}
else:
doc = FreeCAD.ActiveDocument
if doc is None:
_result_ = {{"success": False, "error": "No active document"}}
else:
view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
_result_ = {{"success": False, "error": "No active view"}}
else:
# Check view type
view_class = view.__class__.__name__
if view_class not in ["View3DInventor", "View3DInventorPy"]:
_result_ = {{"success": False, "error": f"Cannot capture from {{view_class}}"}}
else:
# Set view angle
view_type = {view_type!r}
if view_type == "FitAll":
view.fitAll()
elif view_type == "Isometric":
view.viewIsometric()
elif view_type == "Front":
view.viewFront()
elif view_type == "Back":
view.viewRear()
elif view_type == "Top":
view.viewTop()
elif view_type == "Bottom":
view.viewBottom()
elif view_type == "Left":
view.viewLeft()
elif view_type == "Right":
view.viewRight()
# Capture screenshot
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
temp_path = f.name
view.saveImage(temp_path, {width}, {height}, "Current")
with open(temp_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
os.unlink(temp_path)
_result_ = {{
"success": True,
"data": image_data,
"format": "png",
"width": {width},
"height": {height},
}}
"""
result = self._execute_via_queue(code, 30000)
if result.get("success") and result.get("result"):
return result["result"]
return {"success": False, "error": result.get("error_message", "Unknown error")}
# Backwards compatibility
start = FreecadMCPPlugin
+30
View File
@@ -0,0 +1,30 @@
"""MCP prompt templates for FreeCAD.
This package contains reusable prompt templates for common FreeCAD tasks.
Prompts guide users through complex workflows and provide best practices.
Available prompts:
Design Workflows:
- design_part: Guided parametric part design
- create_sketch_guide: 2D sketch creation
- boolean_operations_guide: Shape combination
Export/Import:
- export_guide: Export to various formats
- import_guide: Import from various formats
Analysis:
- analyze_shape: Shape geometry analysis
- debug_model: Model troubleshooting
Macro Development:
- macro_development: Macro creation guide
- python_api_reference: API quick reference
Troubleshooting:
- troubleshooting: General issue resolution
"""
from freecad_mcp.prompts.freecad import register_prompts
__all__ = ["register_prompts"]
+739
View File
@@ -0,0 +1,739 @@
"""FreeCAD MCP prompts for common CAD tasks.
This module provides reusable prompt templates that help Claude
understand FreeCAD concepts and guide users through complex tasks.
Prompt Categories:
- Design Workflows: Part design, sketching, modeling
- Export/Import: File format handling
- Analysis: Shape inspection, validation
- Macro Development: Scripting guidance
- Troubleshooting: Common issues and solutions
"""
from collections.abc import Callable, Coroutine
from typing import Any
from mcp.server.fastmcp import FastMCP
def register_prompts(
mcp: FastMCP,
get_bridge: Callable[[], Coroutine[Any, Any, Any]], # noqa: ARG001
) -> None:
"""Register FreeCAD prompts with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge (unused but kept
for interface consistency with other register functions).
"""
# =========================================================================
# Design Workflow Prompts
# =========================================================================
@mcp.prompt()
async def design_part(
description: str,
units: str = "mm",
) -> str:
"""Generate a guided workflow for designing a parametric part.
Use this prompt when a user wants to create a new part from scratch.
It provides step-by-step guidance for the PartDesign workflow.
Args:
description: Natural language description of the desired part.
units: Unit system to use (mm, cm, m, in).
Returns:
Structured prompt guiding through part design.
"""
return f"""# FreeCAD Part Design Workflow
## Part Description
{description}
## Recommended Approach
### 1. Create a New Document
First, create a new document for this part:
- Use `create_document` with a descriptive name
### 2. Set Up PartDesign Body
Create a PartDesign body to contain the parametric features:
- Use `create_partdesign_body` to create the body container
- This enables the parametric workflow with features
### 3. Create Base Sketch
Design the base profile:
- Use `create_sketch` on the XY plane (or appropriate plane)
- Add geometry with `add_sketch_rectangle`, `add_sketch_circle`, etc.
- Close the sketch when complete
### 4. Extrude the Base
Create the base 3D shape:
- Use `pad_sketch` to extrude the sketch
- Specify length in {units}
### 5. Add Features
Add additional features as needed:
- `pocket_sketch` for cuts/holes
- `fillet_edges` for rounded edges
- `chamfer_edges` for beveled edges
### 6. Verify and Export
When complete:
- Use `inspect_object` to verify dimensions
- Use `get_screenshot` to visualize the result
- Export with `export_step` or `export_stl` as needed
## Units
All dimensions should be specified in **{units}**.
"""
@mcp.prompt()
async def create_sketch_guide(
shape_type: str = "rectangle",
plane: str = "XY",
) -> str:
"""Guide for creating 2D sketches for part design.
Args:
shape_type: Type of shape (rectangle, circle, polygon).
plane: Sketch plane (XY, XZ, YZ).
Returns:
Sketch creation guidance.
"""
return f"""# FreeCAD Sketch Creation Guide
## Target Shape: {shape_type}
## Sketch Plane: {plane}
### Step 1: Create Sketch
Use `create_sketch` with plane="{plane}" to start a new sketch.
### Step 2: Add Geometry
{"#### Rectangle" if shape_type == "rectangle" else ""}
{"Use `add_sketch_rectangle` with:" if shape_type == "rectangle" else ""}
{"- x, y: Starting corner position" if shape_type == "rectangle" else ""}
{"- width, height: Rectangle dimensions" if shape_type == "rectangle" else ""}
{"#### Circle" if shape_type == "circle" else ""}
{"Use `add_sketch_circle` with:" if shape_type == "circle" else ""}
{"- x, y: Center position" if shape_type == "circle" else ""}
{"- radius: Circle radius" if shape_type == "circle" else ""}
{"#### Custom Polygon" if shape_type == "polygon" else ""}
{"Use `execute_python` with Part.makePolygon() for custom shapes." if shape_type == "polygon" else ""}
### Step 3: Constrain the Sketch
For a fully constrained sketch:
- All geometry should have defined positions
- No free degrees of freedom
### Step 4: Close and Use
The sketch can then be:
- Padded (extruded) with `pad_sketch`
- Pocketed (cut) with `pocket_sketch`
- Revolved with `execute_python` using PartDesign Revolution
"""
@mcp.prompt()
async def boolean_operations_guide() -> str:
"""Guide for performing boolean operations on shapes.
Returns:
Boolean operations guidance.
"""
return """# FreeCAD Boolean Operations Guide
Boolean operations combine two or more shapes into a new shape.
## Available Operations
### 1. Fuse (Union)
Combines two shapes into one:
```
boolean_operation(
object1="Box",
object2="Cylinder",
operation="fuse",
result_name="FusedShape"
)
```
### 2. Cut (Difference)
Removes the second shape from the first:
```
boolean_operation(
object1="Box",
object2="Cylinder",
operation="cut",
result_name="CutShape"
)
```
### 3. Common (Intersection)
Keeps only the overlapping region:
```
boolean_operation(
object1="Box",
object2="Cylinder",
operation="common",
result_name="CommonShape"
)
```
## Tips
- Shapes must overlap for meaningful results
- The original objects remain in the document
- Use `set_object_visibility` to hide originals after operation
- Recompute the document after boolean operations
"""
# =========================================================================
# Export/Import Prompts
# =========================================================================
@mcp.prompt()
async def export_guide(target_format: str = "STEP") -> str:
"""Guide for exporting FreeCAD models to various formats.
Args:
target_format: Target export format (STEP, STL, OBJ, IGES).
Returns:
Export guidance for the specified format.
"""
format_info = {
"STEP": {
"tool": "export_step",
"extension": ".step",
"description": "Standard for exchanging 3D CAD data between systems",
"best_for": "CAD interchange, preserves geometry precisely",
"params": "file_path, object_names (optional)",
},
"STL": {
"tool": "export_stl",
"extension": ".stl",
"description": "Triangulated mesh format",
"best_for": "3D printing, mesh-based workflows",
"params": "file_path, object_names (optional), mesh_tolerance (default 0.1)",
},
"OBJ": {
"tool": "export_obj",
"extension": ".obj",
"description": "Wavefront OBJ mesh format",
"best_for": "3D graphics, rendering, game engines",
"params": "file_path, object_names (optional)",
},
"IGES": {
"tool": "export_iges",
"extension": ".iges",
"description": "Initial Graphics Exchange Specification",
"best_for": "Legacy CAD systems, surface data",
"params": "file_path, object_names (optional)",
},
}
info = format_info.get(target_format.upper(), format_info["STEP"])
return f"""# FreeCAD Export Guide: {target_format.upper()}
## Format: {target_format.upper()} ({info["extension"]})
{info["description"]}
**Best for:** {info["best_for"]}
## Export Command
Use the `{info["tool"]}` tool with parameters:
- {info["params"]}
## Example
```python
{info["tool"]}(
file_path="/path/to/output{info["extension"]}",
object_names=["Part1", "Part2"] # Optional: exports all if not specified
)
```
## Pre-Export Checklist
1. Verify all objects are visible with `list_objects`
2. Check object validity with `inspect_object`
3. Recompute document if needed: `recompute_document`
4. Consider using `fit_all` and `get_screenshot` to verify visually
## Post-Export
- Verify the exported file exists
- Check file size is reasonable
- Test import in target application if possible
"""
@mcp.prompt()
async def import_guide(source_format: str = "STEP") -> str:
"""Guide for importing models into FreeCAD.
Args:
source_format: Source file format (STEP, STL).
Returns:
Import guidance for the specified format.
"""
format_info = {
"STEP": {
"tool": "import_step",
"description": "Imports precise CAD geometry",
"notes": "Preserves feature boundaries, faces, and edges",
},
"STL": {
"tool": "import_stl",
"description": "Imports triangulated mesh",
"notes": "Results in Mesh object, may need conversion for CAD operations",
},
}
info = format_info.get(source_format.upper(), format_info["STEP"])
return f"""# FreeCAD Import Guide: {source_format.upper()}
## Format: {source_format.upper()}
{info["description"]}
**Notes:** {info["notes"]}
## Import Command
Use the `{info["tool"]}` tool:
```python
{info["tool"]}(
file_path="/path/to/file.{source_format.lower()}",
doc_name="TargetDocument" # Optional
)
```
## Post-Import Steps
1. List imported objects: `list_objects`
2. Inspect geometry: `inspect_object` on each object
3. Adjust view: `fit_all` to see all imported geometry
4. Take screenshot: `get_screenshot` to verify import
## Common Issues
- Large files may take time to process
- Complex geometry may create many objects
- STL meshes need conversion for boolean operations
"""
# =========================================================================
# Analysis Prompts
# =========================================================================
@mcp.prompt()
async def analyze_shape() -> str:
"""Guide for analyzing shape geometry and properties.
Returns:
Shape analysis guidance.
"""
return """# FreeCAD Shape Analysis Guide
## Quick Analysis
Use `inspect_object` with `include_shape=True` to get:
- Volume
- Surface area
- Bounding box
- Vertex/edge/face counts
- Validity status
## Detailed Analysis with Python
### Bounding Box
```python
execute_python('''
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
bb = obj.Shape.BoundBox
_result_ = {
"min": [bb.XMin, bb.YMin, bb.ZMin],
"max": [bb.XMax, bb.YMax, bb.ZMax],
"size": [bb.XLength, bb.YLength, bb.ZLength],
"center": [bb.Center.x, bb.Center.y, bb.Center.z]
}
''')
```
### Center of Mass
```python
execute_python('''
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
com = obj.Shape.CenterOfMass
_result_ = {"x": com.x, "y": com.y, "z": com.z}
''')
```
### Moments of Inertia
```python
execute_python('''
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
moi = obj.Shape.MatrixOfInertia
_result_ = {
"Ixx": moi.A11, "Iyy": moi.A22, "Izz": moi.A33,
"Ixy": moi.A12, "Ixz": moi.A13, "Iyz": moi.A23
}
''')
```
## Validation
Check for geometry issues:
```python
execute_python('''
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
shape = obj.Shape
_result_ = {
"is_valid": shape.isValid(),
"is_closed": shape.isClosed() if hasattr(shape, 'isClosed') else None,
"has_shape": shape.ShapeType != "Compound" or len(shape.Solids) > 0
}
''')
```
"""
@mcp.prompt()
async def debug_model() -> str:
"""Guide for debugging FreeCAD model issues.
Returns:
Model debugging guidance.
"""
return """# FreeCAD Model Debugging Guide
## Common Issues and Solutions
### 1. Recompute Errors
**Symptom:** Objects show error state, model doesn't update
**Solution:**
```python
recompute_document() # Force full recompute
```
### 2. Invalid Shape
**Symptom:** Boolean operations fail, export errors
**Diagnosis:**
```python
execute_python('''
obj = FreeCAD.ActiveDocument.getObject("ObjectName")
_result_ = {
"valid": obj.Shape.isValid(),
"type": obj.Shape.ShapeType,
"check": obj.Shape.check() if hasattr(obj.Shape, 'check') else "N/A"
}
''')
```
### 3. Sketch Not Fully Constrained
**Symptom:** Sketch geometry moves unexpectedly
**Check constraints:**
```python
execute_python('''
sketch = FreeCAD.ActiveDocument.getObject("SketchName")
_result_ = {
"dof": sketch.solve(), # Degrees of freedom
"constraint_count": sketch.ConstraintCount,
"geometry_count": sketch.GeometryCount
}
''')
```
### 4. Object Dependencies
**Symptom:** Can't delete object, unexpected behavior
**Check dependencies:**
```python
inspect_object("ObjectName") # Check children and parents
```
### 5. View Not Updating
**Symptom:** Display doesn't match model
**Solution:**
```python
fit_all() # Reset view
get_screenshot() # Force view update
```
## Diagnostic Workflow
1. `list_objects` - See all objects and their states
2. `inspect_object` on problematic objects
3. `get_console_output` - Check for error messages
4. `recompute_document` - Force update
5. `get_screenshot` - Visual verification
"""
# =========================================================================
# Macro Development Prompts
# =========================================================================
@mcp.prompt()
async def macro_development() -> str:
"""Guide for developing FreeCAD macros.
Returns:
Macro development guidance.
"""
return """# FreeCAD Macro Development Guide
## Macro Structure
A FreeCAD macro is a Python script that automates tasks.
### Basic Template
```python
# -*- coding: utf-8 -*-
# Macro: MacroName
# Description: What the macro does
import FreeCAD
import FreeCADGui
def main():
# Get active document
doc = FreeCAD.ActiveDocument
if doc is None:
FreeCAD.Console.PrintError("No active document\\n")
return
# Your code here
doc.recompute()
FreeCAD.Console.PrintMessage("Macro completed\\n")
if __name__ == "__main__":
main()
```
## Creating a Macro
Use `create_macro` to save a macro:
```python
create_macro(
name="MyMacro",
code="... macro code ...",
description="What it does"
)
```
Or use a template:
```python
create_macro_from_template(
template_name="part", # basic, part, sketch, gui, selection
macro_name="MyPartMacro"
)
```
## Available Templates
- **basic**: Minimal template
- **part**: Part creation with primitives
- **sketch**: 2D sketch operations
- **gui**: GUI interaction with message boxes
- **selection**: Working with selected objects
## Running Macros
```python
run_macro("MacroName")
```
## Best Practices
1. Always check for active document
2. Use FreeCAD.Console for output
3. Call doc.recompute() after changes
4. Handle exceptions gracefully
5. Add descriptive comments
"""
@mcp.prompt()
async def python_api_reference() -> str:
"""Quick reference for common FreeCAD Python API operations.
Returns:
Python API reference.
"""
return """# FreeCAD Python API Quick Reference
## Document Operations
```python
# Create/get documents
doc = FreeCAD.newDocument("Name")
doc = FreeCAD.ActiveDocument
doc = FreeCAD.getDocument("Name")
# Document methods
doc.recompute()
doc.save()
doc.saveAs("/path/to/file.FCStd")
```
## Object Operations
```python
# Create objects
box = doc.addObject("Part::Box", "MyBox")
cyl = doc.addObject("Part::Cylinder", "MyCyl")
# Get objects
obj = doc.getObject("ObjectName")
all_objs = doc.Objects
# Modify properties
obj.Length = 100
obj.Placement = FreeCAD.Placement(
FreeCAD.Vector(x, y, z),
FreeCAD.Rotation(axis, angle)
)
# Delete
doc.removeObject("ObjectName")
```
## Part Module
```python
import Part
# Primitives
box = Part.makeBox(l, w, h)
cyl = Part.makeCylinder(r, h)
sphere = Part.makeSphere(r)
# Boolean operations
fused = shape1.fuse(shape2)
cut = shape1.cut(shape2)
common = shape1.common(shape2)
# Create from shape
Part.show(shape, "Name")
```
## Sketcher Module
```python
import Sketcher
# Create sketch
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
sketch.MapMode = "FlatFace"
# Add geometry
sketch.addGeometry(Part.LineSegment(p1, p2))
sketch.addGeometry(Part.Circle(center, normal, radius))
# Add constraints
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, 1, 2))
sketch.addConstraint(Sketcher.Constraint("Horizontal", 0))
```
## GUI Operations
```python
import FreeCADGui as Gui
# View control
view = Gui.ActiveDocument.ActiveView
view.viewIsometric()
view.fitAll()
view.saveImage("/path/to/image.png", 800, 600)
# Object visibility
obj.ViewObject.Visibility = True/False
obj.ViewObject.ShapeColor = (r, g, b) # 0.0-1.0
```
## Vectors and Placement
```python
# Vector operations
v = FreeCAD.Vector(x, y, z)
v.Length
v.normalize()
v1.cross(v2)
v1.dot(v2)
# Placement
p = FreeCAD.Placement()
p.Base = FreeCAD.Vector(x, y, z)
p.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
```
"""
# =========================================================================
# Troubleshooting Prompts
# =========================================================================
@mcp.prompt()
async def troubleshooting() -> str:
"""General troubleshooting guide for FreeCAD MCP.
Returns:
Troubleshooting guidance.
"""
return """# FreeCAD MCP Troubleshooting Guide
## Connection Issues
### Cannot Connect to FreeCAD
1. Verify FreeCAD is running (for socket/xmlrpc modes)
2. Check the MCP plugin is started in FreeCAD
3. Verify port numbers match (default: 9876 socket, 9875 xmlrpc)
**Check status:**
```python
get_connection_status()
```
### Connection Drops
- FreeCAD may be busy with long operations
- Try increasing timeout values
- Check FreeCAD console for errors
## Execution Issues
### Code Execution Timeout
- Increase timeout_ms parameter
- Break complex operations into smaller steps
- Check for infinite loops in code
### No Result Returned
- Ensure you set `_result_ = value` in your code
- Check for exceptions in stderr
**Debug execution:**
```python
execute_python('''
try:
# Your code
_result_ = {"success": True, "data": result}
except Exception as e:
_result_ = {"success": False, "error": str(e)}
''')
```
## GUI Issues
### Screenshots Fail
- Ensure GUI mode is available: `get_freecad_version()`
- Check for active document and view
- Verify view type supports screenshots
### View Not Updating
```python
recompute_document()
fit_all()
```
## Model Issues
### Boolean Operation Fails
- Check shapes are valid
- Ensure shapes overlap
- Try with simpler geometry first
### Export Fails
- Verify objects have valid shapes
- Check file path is writable
- Ensure correct format for geometry type
## Getting Help
1. Check console output: `get_console_output()`
2. Inspect problematic objects: `inspect_object()`
3. Verify document state: `list_documents()`, `list_objects()`
"""
+2
View File
@@ -0,0 +1,2 @@
# PEP 561 marker file
# This file indicates that this package supports type checking
+23
View File
@@ -0,0 +1,23 @@
"""MCP resource implementations for FreeCAD.
This package contains all MCP resource definitions for querying FreeCAD state.
Resources provide read-only access to FreeCAD's current state via URI-addressable
endpoints.
Available resources:
- freecad://version - FreeCAD version information
- freecad://status - Connection and runtime status
- freecad://documents - List of open documents
- freecad://documents/{name} - Single document details
- freecad://documents/{name}/objects - Objects in a document
- freecad://objects/{doc_name}/{obj_name} - Object details
- freecad://workbenches - Available workbenches
- freecad://workbenches/active - Currently active workbench
- freecad://macros - Available macros
- freecad://console - Recent console output
- freecad://active-document - Currently active document
"""
from freecad_mcp.resources.freecad import register_resources
__all__ = ["register_resources"]
+898
View File
@@ -0,0 +1,898 @@
"""FreeCAD MCP resources for exposing FreeCAD state.
This module provides MCP resources that expose FreeCAD's current state
as read-only data. Resources are URI-addressable data that Claude can
access to understand the current FreeCAD environment.
Resource URIs:
- freecad://capabilities - Complete list of all available tools/resources
- freecad://version - FreeCAD version information
- freecad://status - Connection and runtime status
- freecad://documents - List of open documents
- freecad://documents/{name} - Single document details
- freecad://documents/{name}/objects - Objects in a document
- freecad://objects/{doc_name}/{obj_name} - Object details
- freecad://active-document - Currently active document
- freecad://workbenches - Available workbenches
- freecad://workbenches/active - Currently active workbench
- freecad://macros - Available macros
- freecad://console - Recent console output
"""
import json
from typing import Any
def register_resources(mcp, get_bridge) -> None:
"""Register FreeCAD resources with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
"""
@mcp.resource("freecad://version")
async def resource_version() -> str:
"""Get FreeCAD version and build information.
Returns:
JSON string containing:
- version: FreeCAD version string
- build_date: Build timestamp
- python_version: Python interpreter version
- gui_available: Whether GUI mode is active
"""
bridge = await get_bridge()
version_info = await bridge.get_freecad_version()
return json.dumps(version_info, indent=2)
@mcp.resource("freecad://status")
async def resource_status() -> str:
"""Get current FreeCAD connection and runtime status.
Returns:
JSON string containing:
- connected: Connection state
- mode: Bridge mode (embedded, xmlrpc, socket)
- freecad_version: Version string
- gui_available: GUI availability
- last_ping_ms: Connection latency
- error: Any error message
"""
bridge = await get_bridge()
status = await bridge.get_status()
return json.dumps(
{
"connected": status.connected,
"mode": status.mode,
"freecad_version": status.freecad_version,
"gui_available": status.gui_available,
"last_ping_ms": status.last_ping_ms,
"error": status.error,
},
indent=2,
)
@mcp.resource("freecad://documents")
async def resource_documents() -> str:
"""Get list of all open FreeCAD documents.
Returns:
JSON string containing list of documents, each with:
- name: Internal document name
- label: Display label
- path: File path (null if unsaved)
- object_count: Number of objects
- is_modified: Has unsaved changes
- active_object: Currently selected object
"""
bridge = await get_bridge()
docs = await bridge.get_documents()
doc_list = [
{
"name": doc.name,
"label": doc.label,
"path": doc.path,
"object_count": len(doc.objects),
"is_modified": doc.is_modified,
"active_object": doc.active_object,
}
for doc in docs
]
return json.dumps(doc_list, indent=2)
@mcp.resource("freecad://documents/{name}")
async def resource_document(name: str) -> str:
"""Get detailed information about a specific document.
Args:
name: Document name to query.
Returns:
JSON string containing:
- name: Internal document name
- label: Display label
- path: File path (null if unsaved)
- objects: List of object names
- is_modified: Has unsaved changes
- active_object: Currently selected object
"""
bridge = await get_bridge()
docs = await bridge.get_documents()
for doc in docs:
if doc.name == name:
return json.dumps(
{
"name": doc.name,
"label": doc.label,
"path": doc.path,
"objects": doc.objects,
"is_modified": doc.is_modified,
"active_object": doc.active_object,
},
indent=2,
)
return json.dumps({"error": f"Document '{name}' not found"}, indent=2)
@mcp.resource("freecad://documents/{name}/objects")
async def resource_document_objects(name: str) -> str:
"""Get list of objects in a specific document.
Args:
name: Document name to query.
Returns:
JSON string containing list of objects, each with:
- name: Object name
- label: Display label
- type_id: FreeCAD type identifier
- visibility: Whether object is visible
"""
bridge = await get_bridge()
objects = await bridge.get_objects(doc_name=name)
obj_list = [
{
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
"visibility": obj.visibility,
}
for obj in objects
]
return json.dumps(obj_list, indent=2)
@mcp.resource("freecad://objects/{doc_name}/{obj_name}")
async def resource_object(doc_name: str, obj_name: str) -> str:
"""Get detailed information about a specific object.
Args:
doc_name: Document containing the object.
obj_name: Object name to query.
Returns:
JSON string containing:
- name: Object name
- label: Display label
- type_id: FreeCAD type identifier
- properties: Dictionary of property values
- shape_info: Shape geometry (if applicable)
- children: Dependent object names
- parents: Parent object names
- visibility: Display visibility
"""
bridge = await get_bridge()
obj = await bridge.get_object(obj_name, doc_name=doc_name)
# Filter properties to only include serializable values
safe_properties = _make_json_safe(obj.properties)
return json.dumps(
{
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
"properties": safe_properties,
"shape_info": obj.shape_info,
"children": obj.children,
"parents": obj.parents,
"visibility": obj.visibility,
},
indent=2,
)
@mcp.resource("freecad://workbenches")
async def resource_workbenches() -> str:
"""Get list of available FreeCAD workbenches.
Returns:
JSON string containing list of workbenches, each with:
- name: Workbench internal name
- label: Display label
- is_active: Whether currently active
"""
bridge = await get_bridge()
workbenches = await bridge.get_workbenches()
wb_list = [
{
"name": wb.name,
"label": wb.label,
"is_active": wb.is_active,
}
for wb in workbenches
]
return json.dumps(wb_list, indent=2)
@mcp.resource("freecad://workbenches/active")
async def resource_active_workbench() -> str:
"""Get the currently active workbench.
Returns:
JSON string containing active workbench info or null.
"""
bridge = await get_bridge()
workbenches = await bridge.get_workbenches()
for wb in workbenches:
if wb.is_active:
return json.dumps(
{
"name": wb.name,
"label": wb.label,
},
indent=2,
)
return json.dumps(None)
@mcp.resource("freecad://macros")
async def resource_macros() -> str:
"""Get list of available FreeCAD macros.
Returns:
JSON string containing list of macros, each with:
- name: Macro name (without extension)
- path: Full file path
- description: Macro description
- is_system: Whether it's a system macro
"""
bridge = await get_bridge()
macros = await bridge.get_macros()
macro_list = [
{
"name": macro.name,
"path": macro.path,
"description": macro.description,
"is_system": macro.is_system,
}
for macro in macros
]
return json.dumps(macro_list, indent=2)
@mcp.resource("freecad://console")
async def resource_console() -> str:
"""Get recent FreeCAD console output.
Returns:
JSON string containing:
- lines: List of console output lines
- count: Number of lines
"""
bridge = await get_bridge()
lines = await bridge.get_console_output(lines=100)
return json.dumps(
{
"lines": lines,
"count": len(lines),
},
indent=2,
)
@mcp.resource("freecad://active-document")
async def resource_active_document() -> str:
"""Get the currently active document.
Returns:
JSON string containing active document info or null.
"""
bridge = await get_bridge()
doc = await bridge.get_active_document()
if doc is None:
return json.dumps(None)
return json.dumps(
{
"name": doc.name,
"label": doc.label,
"path": doc.path,
"objects": doc.objects,
"is_modified": doc.is_modified,
"active_object": doc.active_object,
},
indent=2,
)
@mcp.resource("freecad://capabilities")
async def resource_capabilities() -> str:
"""Get comprehensive list of all MCP capabilities.
This resource provides a complete catalog of all available tools,
resources, and prompts. Use this to discover what functionality
is available when working with the FreeCAD MCP server.
Returns:
JSON string containing:
- tools: Dict of tool categories with tool definitions
- resources: List of available resource URIs
- prompts: List of available prompt names
- examples: Common usage patterns
"""
capabilities = {
"description": "FreeCAD MCP Server - Control FreeCAD via Model Context Protocol",
"tools": {
"execution": {
"description": "Execute Python code and access console",
"tools": [
{
"name": "execute_python",
"description": "Execute arbitrary Python code in FreeCAD's context. Use _result_ = value to return data.",
"key_params": ["code", "timeout_ms"],
},
{
"name": "get_console_output",
"description": "Get recent FreeCAD console output for debugging",
"key_params": ["lines"],
},
{
"name": "get_console_log",
"description": "Alternative console log access",
"key_params": ["lines"],
},
{
"name": "get_freecad_version",
"description": "Get FreeCAD version, build date, Python version",
"key_params": [],
},
{
"name": "get_connection_status",
"description": "Check MCP bridge connection status and latency",
"key_params": [],
},
{
"name": "get_mcp_server_environment",
"description": "Get MCP server environment info (OS, hostname, Docker detection)",
"key_params": [],
},
],
},
"documents": {
"description": "Document management",
"tools": [
{
"name": "list_documents",
"description": "List all open FreeCAD documents",
"key_params": [],
},
{
"name": "get_active_document",
"description": "Get info about currently active document",
"key_params": [],
},
{
"name": "create_document",
"description": "Create a new FreeCAD document",
"key_params": ["name"],
},
{
"name": "open_document",
"description": "Open an existing .FCStd file",
"key_params": ["path"],
},
{
"name": "save_document",
"description": "Save document to disk",
"key_params": ["doc_name", "path"],
},
{
"name": "close_document",
"description": "Close a document",
"key_params": ["doc_name"],
},
{
"name": "recompute_document",
"description": "Force recomputation of document features",
"key_params": ["doc_name"],
},
],
},
"objects": {
"description": "Object creation and manipulation",
"tools": [
{
"name": "list_objects",
"description": "List all objects in a document",
"key_params": ["doc_name"],
},
{
"name": "inspect_object",
"description": "Get detailed info about an object",
"key_params": ["object_name", "doc_name"],
},
{
"name": "create_box",
"description": "Create Part::Box primitive",
"key_params": ["length", "width", "height"],
},
{
"name": "create_cylinder",
"description": "Create Part::Cylinder primitive",
"key_params": ["radius", "height"],
},
{
"name": "create_sphere",
"description": "Create Part::Sphere primitive",
"key_params": ["radius"],
},
{
"name": "create_cone",
"description": "Create Part::Cone primitive",
"key_params": ["radius1", "radius2", "height"],
},
{
"name": "create_torus",
"description": "Create Part::Torus primitive",
"key_params": ["radius1", "radius2"],
},
{
"name": "boolean_operation",
"description": "Union, cut, or intersection operations",
"key_params": ["operation", "object1", "object2"],
},
{
"name": "edit_object",
"description": "Modify object properties",
"key_params": ["object_name", "properties"],
},
{
"name": "delete_object",
"description": "Delete an object",
"key_params": ["object_name"],
},
{
"name": "set_placement",
"description": "Set object position and rotation",
"key_params": ["object_name", "x", "y", "z"],
},
{
"name": "copy_object",
"description": "Create a copy of an object",
"key_params": ["object_name"],
},
{
"name": "mirror_object",
"description": "Mirror object across a plane",
"key_params": ["object_name", "plane"],
},
],
},
"selection": {
"description": "Selection management",
"tools": [
{
"name": "get_selection",
"description": "Get currently selected objects",
"key_params": ["doc_name"],
},
{
"name": "set_selection",
"description": "Select specific objects",
"key_params": ["object_names"],
},
{
"name": "clear_selection",
"description": "Clear current selection",
"key_params": [],
},
],
},
"partdesign": {
"description": "Parametric modeling with PartDesign workbench",
"tools": [
{
"name": "create_partdesign_body",
"description": "Create a PartDesign::Body container",
"key_params": ["name"],
},
{
"name": "create_sketch",
"description": "Create sketch on plane or face",
"key_params": ["body_name", "plane"],
},
{
"name": "add_sketch_rectangle",
"description": "Add rectangle to sketch",
"key_params": ["sketch_name", "x", "y", "width", "height"],
},
{
"name": "add_sketch_circle",
"description": "Add circle to sketch",
"key_params": ["sketch_name", "x", "y", "radius"],
},
{
"name": "add_sketch_line",
"description": "Add line to sketch",
"key_params": ["sketch_name", "x1", "y1", "x2", "y2"],
},
{
"name": "add_sketch_arc",
"description": "Add arc to sketch",
"key_params": [
"sketch_name",
"center_x",
"center_y",
"radius",
],
},
{
"name": "add_sketch_point",
"description": "Add point to sketch (for holes)",
"key_params": ["sketch_name", "x", "y"],
},
{
"name": "pad_sketch",
"description": "Extrude sketch (additive)",
"key_params": ["body_name", "sketch_name", "length"],
},
{
"name": "pocket_sketch",
"description": "Cut using sketch (subtractive)",
"key_params": ["body_name", "sketch_name", "length"],
},
{
"name": "revolution_sketch",
"description": "Revolve sketch around axis",
"key_params": ["body_name", "sketch_name", "axis", "angle"],
},
{
"name": "create_hole",
"description": "Create parametric hole feature",
"key_params": [
"body_name",
"sketch_name",
"diameter",
"depth",
],
},
{
"name": "fillet_edges",
"description": "Add fillets to edges",
"key_params": ["body_name", "edges", "radius"],
},
{
"name": "chamfer_edges",
"description": "Add chamfers to edges",
"key_params": ["body_name", "edges", "size"],
},
{
"name": "loft_sketches",
"description": "Create loft between sketches",
"key_params": ["body_name", "sketch_names"],
},
{
"name": "sweep_sketch",
"description": "Sweep sketch along path",
"key_params": [
"body_name",
"profile_sketch",
"path_sketch",
],
},
],
},
"patterns": {
"description": "Pattern and transform features",
"tools": [
{
"name": "linear_pattern",
"description": "Create linear pattern",
"key_params": [
"body_name",
"feature_name",
"direction",
"count",
],
},
{
"name": "polar_pattern",
"description": "Create circular/polar pattern",
"key_params": [
"body_name",
"feature_name",
"axis",
"count",
],
},
{
"name": "mirrored_feature",
"description": "Mirror feature across plane",
"key_params": ["body_name", "feature_name", "plane"],
},
],
},
"view": {
"description": "View and GUI control (some require GUI mode)",
"tools": [
{
"name": "get_screenshot",
"description": "Capture 3D view screenshot (GUI only)",
"key_params": ["file_path", "width", "height"],
},
{
"name": "set_view_angle",
"description": "Set camera to standard views",
"key_params": ["angle"],
},
{
"name": "fit_all",
"description": "Zoom to fit all objects",
"key_params": [],
},
{
"name": "zoom_in",
"description": "Zoom in",
"key_params": ["factor"],
},
{
"name": "zoom_out",
"description": "Zoom out",
"key_params": ["factor"],
},
{
"name": "set_object_visibility",
"description": "Show/hide objects (GUI only)",
"key_params": ["object_name", "visible"],
},
{
"name": "set_display_mode",
"description": "Set display mode (wireframe, shaded)",
"key_params": ["object_name", "mode"],
},
{
"name": "set_object_color",
"description": "Change object color (GUI only)",
"key_params": ["object_name", "r", "g", "b"],
},
{
"name": "list_workbenches",
"description": "List available workbenches",
"key_params": [],
},
{
"name": "activate_workbench",
"description": "Switch workbench",
"key_params": ["workbench_name"],
},
{
"name": "recompute",
"description": "Recompute document",
"key_params": ["doc_name"],
},
],
},
"undo_redo": {
"description": "Undo/redo operations",
"tools": [
{
"name": "undo",
"description": "Undo last operation",
"key_params": ["doc_name"],
},
{
"name": "redo",
"description": "Redo undone operation",
"key_params": ["doc_name"],
},
{
"name": "get_undo_redo_status",
"description": "Get available undo/redo operations",
"key_params": ["doc_name"],
},
],
},
"export_import": {
"description": "File export and import",
"tools": [
{
"name": "export_step",
"description": "Export to STEP format",
"key_params": ["object_names", "file_path"],
},
{
"name": "export_stl",
"description": "Export to STL format (3D printing)",
"key_params": ["object_names", "file_path"],
},
{
"name": "export_3mf",
"description": "Export to 3MF format",
"key_params": ["object_names", "file_path"],
},
{
"name": "export_obj",
"description": "Export to OBJ format",
"key_params": ["object_names", "file_path"],
},
{
"name": "export_iges",
"description": "Export to IGES format",
"key_params": ["object_names", "file_path"],
},
{
"name": "import_step",
"description": "Import STEP file",
"key_params": ["file_path"],
},
{
"name": "import_stl",
"description": "Import STL file",
"key_params": ["file_path"],
},
],
},
"macros": {
"description": "Macro management",
"tools": [
{
"name": "list_macros",
"description": "List available macros",
"key_params": [],
},
{
"name": "run_macro",
"description": "Execute a macro",
"key_params": ["macro_name"],
},
{
"name": "create_macro",
"description": "Create new macro",
"key_params": ["macro_name", "code"],
},
{
"name": "read_macro",
"description": "Read macro source code",
"key_params": ["macro_name"],
},
{
"name": "delete_macro",
"description": "Delete a macro",
"key_params": ["macro_name"],
},
],
},
"parts_library": {
"description": "Parts library access",
"tools": [
{
"name": "list_parts_library",
"description": "List parts in library",
"key_params": [],
},
{
"name": "insert_part_from_library",
"description": "Insert part from library",
"key_params": ["part_path"],
},
],
},
},
"resources": [
{
"uri": "freecad://capabilities",
"description": "This resource - lists all available capabilities",
},
{
"uri": "freecad://version",
"description": "FreeCAD version and build information",
},
{
"uri": "freecad://status",
"description": "Connection status, mode, GUI availability",
},
{
"uri": "freecad://documents",
"description": "List of all open documents",
},
{
"uri": "freecad://documents/{name}",
"description": "Details of a specific document",
},
{
"uri": "freecad://documents/{name}/objects",
"description": "Objects in a specific document",
},
{
"uri": "freecad://objects/{doc_name}/{obj_name}",
"description": "Detailed object info with properties",
},
{
"uri": "freecad://active-document",
"description": "Currently active document",
},
{
"uri": "freecad://workbenches",
"description": "Available FreeCAD workbenches",
},
{
"uri": "freecad://workbenches/active",
"description": "Currently active workbench",
},
{
"uri": "freecad://macros",
"description": "Available FreeCAD macros",
},
{
"uri": "freecad://console",
"description": "Recent console output (debugging)",
},
],
"prompts": [
{
"name": "freecad-help",
"description": "Get help on FreeCAD MCP capabilities",
},
{
"name": "create-parametric-part",
"description": "Guide for creating parametric parts",
},
{
"name": "debug-model",
"description": "Help debug model issues",
},
],
"examples": {
"debug_macro": {
"description": "Debug a macro by checking console output",
"steps": [
"Use get_console_output(lines=50) to see recent errors",
"Use execute_python to inspect document state",
],
},
"create_simple_part": {
"description": "Create a basic parametric part",
"steps": [
"create_document(name='MyPart')",
"create_partdesign_body(name='Body')",
"create_sketch(body_name='Body', plane='XY_Plane')",
"add_sketch_rectangle(...)",
"pad_sketch(...)",
],
},
"export_for_printing": {
"description": "Export model for 3D printing",
"steps": [
"export_stl(object_names=['Body'], file_path='...')",
"Or export_3mf for color/material support",
],
},
},
}
return json.dumps(capabilities, indent=2)
def _make_json_safe(obj: Any) -> Any:
"""Convert an object to be JSON serializable.
Args:
obj: Object to convert.
Returns:
JSON-safe representation of the object.
"""
if obj is None:
return None
if isinstance(obj, str | int | float | bool):
return obj
if isinstance(obj, list | tuple):
return [_make_json_safe(item) for item in obj]
if isinstance(obj, dict):
return {str(k): _make_json_safe(v) for k, v in obj.items()}
# Convert other types to string representation
return str(obj)
+190
View File
@@ -0,0 +1,190 @@
"""FreeCAD MCP Server - Main entry point.
This module provides the main MCP server implementation for FreeCAD
integration with AI assistants (Claude, GPT, and other MCP-compatible tools).
It exposes tools, resources, and prompts for interacting with FreeCAD.
Features:
- Full Python console access (GUI and headless modes)
- Document and object management
- PartDesign workflow (sketches, pads, pockets, fillets)
- Import/export (STEP, STL, OBJ, IGES)
- Macro management
- Screenshot capture
- Multiple connection modes (embedded, XML-RPC, socket)
Example:
Run as a module::
$ python -m freecad_mcp.server
Or use the installed command::
$ freecad-mcp
With environment variables::
$ FREECAD_MODE=socket FREECAD_HOST=localhost freecad-mcp
"""
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from mcp.server.fastmcp import FastMCP
from freecad_mcp.config import FreecadMode, TransportType, get_config
if TYPE_CHECKING:
from freecad_mcp.bridge.base import FreecadBridge
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Global bridge instance (initialized on startup via lifespan)
_bridge: Any = None
async def get_bridge() -> "FreecadBridge":
"""Get the active FreeCAD bridge.
Returns:
The active FreecadBridge instance.
Raises:
RuntimeError: If bridge is not initialized.
"""
if _bridge is None:
msg = "FreeCAD bridge not initialized"
raise RuntimeError(msg)
return _bridge
@asynccontextmanager
async def lifespan(_server: FastMCP) -> AsyncIterator[None]:
"""Manage FreeCAD bridge lifecycle.
This async context manager initializes the FreeCAD bridge on startup
and disconnects it on shutdown.
Args:
_server: The FastMCP server instance (unused).
Yields:
None - the bridge is stored in the global _bridge variable.
"""
global _bridge
config = get_config()
logger.info("Initializing FreeCAD bridge...")
if config.mode == FreecadMode.EMBEDDED:
from freecad_mcp.bridge.embedded import EmbeddedBridge
_bridge = EmbeddedBridge(
freecad_path=str(config.freecad_path) if config.freecad_path else None,
)
logger.info("Using embedded bridge (headless mode)")
elif config.mode == FreecadMode.XMLRPC:
from freecad_mcp.bridge.xmlrpc import XmlRpcBridge
_bridge = XmlRpcBridge(
host=config.socket_host,
port=config.xmlrpc_port,
)
logger.info(
"Using XML-RPC bridge: %s:%d", config.socket_host, config.xmlrpc_port
)
else: # SOCKET mode
from freecad_mcp.bridge.socket import SocketBridge
_bridge = SocketBridge(
host=config.socket_host,
port=config.socket_port,
)
logger.info(
"Using socket bridge: %s:%d", config.socket_host, config.socket_port
)
await _bridge.connect()
logger.info("FreeCAD bridge connected")
# Log FreeCAD version
try:
version = await _bridge.get_freecad_version()
logger.info(
"FreeCAD %s (GUI: %s)",
version.get("version", "unknown"),
"available" if version.get("gui_available") else "headless",
)
except Exception as e:
logger.warning("Could not get FreeCAD version: %s", e)
try:
yield
finally:
# Shutdown
if _bridge:
logger.info("Disconnecting FreeCAD bridge...")
await _bridge.disconnect()
_bridge = None
# Create the MCP server instance with lifespan
mcp = FastMCP(
name="freecad-mcp",
lifespan=lifespan,
)
def register_all_components() -> None:
"""Register all MCP components (tools, resources, prompts)."""
# Register tools
from freecad_mcp.tools import register_all_tools
register_all_tools(mcp, get_bridge)
# Register resources
from freecad_mcp.resources import register_resources
register_resources(mcp, get_bridge)
# Register prompts
from freecad_mcp.prompts import register_prompts
register_prompts(mcp, get_bridge)
# Register all components
register_all_components()
def main() -> None:
"""Run the FreeCAD MCP server."""
config = get_config()
# Set up logging
logging.getLogger().setLevel(config.log_level)
logger.info("Starting FreeCAD MCP server")
logger.info("Mode: %s", config.mode.value)
logger.info("Transport: %s", config.transport.value)
# Run the server
if config.transport == TransportType.HTTP:
logger.info("Starting HTTP transport on port %d", config.http_port)
mcp.run( # type: ignore[call-arg]
transport="streamable-http",
host="0.0.0.0", # noqa: S104
port=config.http_port,
)
else:
logger.info("Starting stdio transport")
mcp.run()
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
"""MCP tool implementations for FreeCAD.
This package contains all MCP tool definitions for interacting with FreeCAD.
Tools are organized by category:
- execution: Python code execution tools
- documents: Document management tools
- objects: Object creation and manipulation tools
- partdesign: PartDesign workbench tools
- export: Export functionality tools
- macros: Macro management tools
- view: View and screenshot tools
"""
from freecad_mcp.tools.documents import register_document_tools
from freecad_mcp.tools.execution import register_execution_tools
from freecad_mcp.tools.export import register_export_tools
from freecad_mcp.tools.macros import register_macro_tools
from freecad_mcp.tools.objects import register_object_tools
from freecad_mcp.tools.partdesign import register_partdesign_tools
from freecad_mcp.tools.view import register_view_tools
__all__ = [
"register_all_tools",
"register_document_tools",
"register_execution_tools",
"register_export_tools",
"register_macro_tools",
"register_object_tools",
"register_partdesign_tools",
"register_view_tools",
]
def register_all_tools(mcp, get_bridge_func) -> None:
"""Register all FreeCAD tools with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge_func: Async function to get the active bridge.
"""
register_execution_tools(mcp, get_bridge_func)
register_document_tools(mcp, get_bridge_func)
register_object_tools(mcp, get_bridge_func)
register_partdesign_tools(mcp, get_bridge_func)
register_export_tools(mcp, get_bridge_func)
register_macro_tools(mcp, get_bridge_func)
register_view_tools(mcp, get_bridge_func)
+205
View File
@@ -0,0 +1,205 @@
"""Document management tools for FreeCAD MCP server.
This module provides tools for managing FreeCAD documents:
creating, opening, saving, closing, and listing documents.
"""
from typing import Any
def register_document_tools(mcp, get_bridge) -> None:
"""Register document-related tools with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
"""
@mcp.tool()
async def list_documents() -> list[dict[str, Any]]:
"""List all open FreeCAD documents.
Returns:
List of dictionaries, each containing:
- name: Internal document name
- label: Display label
- path: File path (None if not saved)
- is_modified: Whether document has unsaved changes
- object_count: Number of objects in document
- active_object: Name of currently active object (None if none)
"""
bridge = await get_bridge()
docs = await bridge.get_documents()
return [
{
"name": doc.name,
"label": doc.label,
"path": doc.path,
"is_modified": doc.is_modified,
"object_count": len(doc.objects),
"active_object": doc.active_object,
}
for doc in docs
]
@mcp.tool()
async def get_active_document() -> dict[str, Any] | None:
"""Get the currently active FreeCAD document.
Returns:
Dictionary with document information, or None if no active document:
- name: Internal document name
- label: Display label
- path: File path (None if not saved)
- is_modified: Whether document has unsaved changes
- objects: List of object names
- active_object: Name of currently active object (None if none)
"""
bridge = await get_bridge()
doc = await bridge.get_active_document()
if doc is None:
return None
return {
"name": doc.name,
"label": doc.label,
"path": doc.path,
"is_modified": doc.is_modified,
"objects": doc.objects,
"active_object": doc.active_object,
}
@mcp.tool()
async def create_document(
name: str = "Unnamed",
label: str | None = None,
) -> dict[str, Any]:
"""Create a new FreeCAD document.
Args:
name: Internal document name (no spaces allowed, will be sanitized).
label: Display label (can contain spaces). Defaults to name.
Returns:
Dictionary with created document information:
- name: Internal document name
- label: Display label
- path: File path (None for new document)
"""
bridge = await get_bridge()
doc = await bridge.create_document(name, label)
return {
"name": doc.name,
"label": doc.label,
"path": doc.path,
}
@mcp.tool()
async def open_document(path: str) -> dict[str, Any]:
"""Open an existing FreeCAD document from file.
Args:
path: Full path to the .FCStd file to open.
Returns:
Dictionary with opened document information:
- name: Internal document name
- label: Display label
- path: File path
- objects: List of object names
Raises:
FileNotFoundError: If the file doesn't exist.
ValueError: If the file is not a valid FreeCAD document.
"""
bridge = await get_bridge()
doc = await bridge.open_document(path)
return {
"name": doc.name,
"label": doc.label,
"path": doc.path,
"objects": doc.objects,
}
@mcp.tool()
async def save_document(
doc_name: str | None = None,
path: str | None = None,
) -> dict[str, Any]:
"""Save a FreeCAD document.
Args:
doc_name: Name of document to save. Uses active document if None.
path: File path to save to. Uses existing path if None.
Required for new (unsaved) documents.
Returns:
Dictionary with save result:
- success: Whether save was successful
- path: Path where document was saved
Raises:
ValueError: If document not found or no path specified for new doc.
"""
bridge = await get_bridge()
saved_path = await bridge.save_document(doc_name, path)
return {
"success": True,
"path": saved_path,
}
@mcp.tool()
async def close_document(
doc_name: str | None = None,
save_changes: bool = False,
) -> dict[str, Any]:
"""Close a FreeCAD document.
Args:
doc_name: Name of document to close. Uses active document if None.
save_changes: Whether to save changes before closing. Defaults to False.
Returns:
Dictionary with close result:
- success: Whether close was successful
- saved: Whether document was saved before closing
"""
bridge = await get_bridge()
saved = False
if save_changes:
try:
await bridge.save_document(doc_name)
saved = True
except Exception:
pass
await bridge.close_document(doc_name)
return {
"success": True,
"saved": saved,
}
@mcp.tool()
async def recompute_document(doc_name: str | None = None) -> dict[str, Any]:
"""Recompute a FreeCAD document to update all dependent objects.
Args:
doc_name: Name of document to recompute. Uses active document if None.
Returns:
Dictionary with recompute result:
- success: Whether recompute was successful
"""
bridge = await get_bridge()
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
doc.recompute()
_result_ = True
"""
result = await bridge.execute_python(code)
return {
"success": result.success,
"error": result.error_traceback if not result.success else None,
}
+216
View File
@@ -0,0 +1,216 @@
"""Execution tools for FreeCAD MCP server.
This module provides tools for executing Python code in FreeCAD's context,
getting version information, and accessing the console.
"""
import os
import platform
import socket
from pathlib import Path
from typing import Any
def register_execution_tools(mcp, get_bridge) -> None:
"""Register execution-related tools with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
"""
@mcp.tool()
async def execute_python(
code: str,
timeout_ms: int = 30000,
) -> dict[str, Any]:
"""Execute Python code in FreeCAD's Python console context.
This tool allows you to run arbitrary Python code within FreeCAD's
environment, with access to all FreeCAD modules and the active document.
Args:
code: Python code to execute. Use `_result_ = value` to return data
to the caller. The code has access to FreeCAD, App, FreeCADGui,
and Gui modules.
timeout_ms: Maximum execution time in milliseconds. Defaults to 30000.
Returns:
Dictionary containing execution results:
- success: Whether execution completed without errors
- result: The value assigned to `_result_` variable
- stdout: Captured standard output
- stderr: Captured standard error
- execution_time_ms: Time taken in milliseconds
- error_type: Type of exception if failed (None if success)
- error_traceback: Full traceback if failed (None if success)
Example:
Create a simple box and return its volume::
execute_python('''
import Part
box = Part.makeBox(10, 20, 30)
_result_ = {"volume": box.Volume, "area": box.Area}
''')
List all objects in the active document::
execute_python('''
doc = FreeCAD.ActiveDocument
if doc:
_result_ = [obj.Name for obj in doc.Objects]
else:
_result_ = []
''')
"""
bridge = await get_bridge()
result = await bridge.execute_python(code, timeout_ms)
return {
"success": result.success,
"result": result.result,
"stdout": result.stdout,
"stderr": result.stderr,
"execution_time_ms": result.execution_time_ms,
"error_type": result.error_type,
"error_traceback": result.error_traceback,
}
@mcp.tool()
async def get_freecad_version() -> dict[str, Any]:
"""Get FreeCAD version and build information.
Returns:
Dictionary containing version information:
- version: Version string (e.g., "0.21.2")
- version_tuple: Version as list of integers
- build_date: Build date string
- python_version: Embedded Python version
- gui_available: Whether GUI is available
"""
bridge = await get_bridge()
return await bridge.get_freecad_version()
@mcp.tool()
async def get_connection_status() -> dict[str, Any]:
"""Get the current FreeCAD connection status.
Returns:
Dictionary containing connection information:
- connected: Whether bridge is connected
- mode: Connection mode (embedded, xmlrpc, socket)
- freecad_version: FreeCAD version string
- gui_available: Whether GUI is available
- last_ping_ms: Last ping latency in milliseconds
- error: Error message if not connected
"""
bridge = await get_bridge()
status = await bridge.get_status()
return {
"connected": status.connected,
"mode": status.mode,
"freecad_version": status.freecad_version,
"gui_available": status.gui_available,
"last_ping_ms": status.last_ping_ms,
"error": status.error,
}
@mcp.tool()
async def get_console_output(lines: int = 100) -> list[str]:
"""Get recent FreeCAD console output.
Args:
lines: Maximum number of lines to return. Defaults to 100.
Returns:
List of console output lines, most recent last.
"""
bridge = await get_bridge()
return await bridge.get_console_output(lines)
@mcp.tool()
async def get_mcp_server_environment() -> dict[str, Any]:
"""Get environment information about the MCP server process.
This tool returns information about the environment where the MCP server
is running, which is useful for debugging and verifying which MCP server
instance you are connected to (e.g., host vs Docker container).
Returns:
Dictionary containing environment information:
- hostname: Machine hostname
- os_name: Operating system name (Linux, Darwin, Windows)
- os_version: Operating system version
- platform: Platform identifier string
- python_version: Python version running the MCP server
- in_docker: Whether running inside a Docker container
- docker_container_id: Container ID if in Docker (first 12 chars)
- env_vars: Selected environment variables for debugging:
- FREECAD_MODE: Connection mode
- FREECAD_SOCKET_HOST: Socket host
- FREECAD_SOCKET_PORT: Socket port
- FREECAD_XMLRPC_PORT: XML-RPC port
Example:
Verify you're talking to the containerized MCP server::
env = get_mcp_server_environment()
if env["in_docker"]:
print(f"Connected to container: {env['docker_container_id']}")
else:
print("Connected to host MCP server")
"""
def _detect_docker() -> tuple[bool, str | None]:
"""Detect if running inside Docker and get container ID."""
# Check for .dockerenv file (most reliable)
dockerenv = Path("/.dockerenv")
if dockerenv.exists():
# Try to get container ID from cgroup
container_id = None
try:
cgroup_path = Path("/proc/self/cgroup")
with cgroup_path.open() as f:
for line in f:
if "docker" in line or "containerd" in line:
# Extract container ID from path
parts = line.strip().split("/")
if parts:
cid = parts[-1]
# Container IDs are 64 hex chars
if len(cid) >= 12:
container_id = cid[:12]
break
except (OSError, IndexError):
pass
return True, container_id
# Also check cgroup for containerized environments
try:
cgroup_init = Path("/proc/1/cgroup")
with cgroup_init.open() as f:
content = f.read()
if "docker" in content or "containerd" in content:
return True, None
except OSError:
pass
return False, None
in_docker, container_id = _detect_docker()
return {
"hostname": socket.gethostname(),
"os_name": platform.system(),
"os_version": platform.release(),
"platform": platform.platform(),
"python_version": platform.python_version(),
"in_docker": in_docker,
"docker_container_id": container_id,
"env_vars": {
"FREECAD_MODE": os.environ.get("FREECAD_MODE", ""),
"FREECAD_SOCKET_HOST": os.environ.get("FREECAD_SOCKET_HOST", ""),
"FREECAD_SOCKET_PORT": os.environ.get("FREECAD_SOCKET_PORT", ""),
"FREECAD_XMLRPC_PORT": os.environ.get("FREECAD_XMLRPC_PORT", ""),
},
}
+458
View File
@@ -0,0 +1,458 @@
"""Export tools for FreeCAD MCP server.
This module provides tools for exporting FreeCAD documents and objects
to various file formats: STEP, STL, 3MF, OBJ, IGES, and FreeCAD native.
"""
from typing import Any
def register_export_tools(mcp, get_bridge) -> None:
"""Register export-related tools with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
"""
@mcp.tool()
async def export_step(
file_path: str,
object_names: list[str] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Export objects to STEP format.
STEP (Standard for the Exchange of Product Data) is an ISO standard
for CAD data exchange, widely supported by CAD software.
Args:
file_path: Path for the output .step file.
object_names: List of object names to export. Exports all visible if None.
doc_name: Document to export from. Uses active document if None.
Returns:
Dictionary with export result:
- success: Whether export was successful
- path: Path to exported file
- object_count: Number of objects exported
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
# Combine shapes
if len(objects) == 1:
shape = objects[0].Shape
else:
shape = Part.makeCompound([obj.Shape for obj in objects])
shape.exportStep({file_path!r})
_result_ = {{
"success": True,
"path": {file_path!r},
"object_count": len(objects),
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "STEP export failed")
@mcp.tool()
async def export_stl(
file_path: str,
object_names: list[str] | None = None,
doc_name: str | None = None,
mesh_tolerance: float = 0.1,
) -> dict[str, Any]:
"""Export objects to STL format.
STL (Stereolithography) is commonly used for 3D printing and
rapid prototyping. It represents surfaces as triangular meshes.
Args:
file_path: Path for the output .stl file.
object_names: List of object names to export. Exports all visible if None.
doc_name: Document to export from. Uses active document if None.
mesh_tolerance: Mesh approximation tolerance. Lower = finer mesh.
Returns:
Dictionary with export result:
- success: Whether export was successful
- path: Path to exported file
- object_count: Number of objects exported
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Mesh
import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
# Create mesh from shapes
meshes = []
for obj in objects:
mesh = Mesh.Mesh()
mesh.addFacets(obj.Shape.tessellate({mesh_tolerance})[0])
meshes.append(mesh)
# Combine meshes
if len(meshes) == 1:
final_mesh = meshes[0]
else:
final_mesh = Mesh.Mesh()
for m in meshes:
final_mesh.addMesh(m)
final_mesh.write({file_path!r})
_result_ = {{
"success": True,
"path": {file_path!r},
"object_count": len(objects),
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "STL export failed")
@mcp.tool()
async def export_3mf(
file_path: str,
object_names: list[str] | None = None,
doc_name: str | None = None,
mesh_tolerance: float = 0.1,
) -> dict[str, Any]:
"""Export objects to 3MF format.
3MF (3D Manufacturing Format) is a modern 3D printing format that
supports richer data than STL, including colors, materials, and
print settings. It is increasingly preferred over STL for 3D printing.
Args:
file_path: Path for the output .3mf file.
object_names: List of object names to export. Exports all visible if None.
doc_name: Document to export from. Uses active document if None.
mesh_tolerance: Mesh approximation tolerance. Lower = finer mesh.
Returns:
Dictionary with export result:
- success: Whether export was successful
- path: Path to exported file
- object_count: Number of objects exported
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Mesh
import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
# Create mesh from shapes
meshes = []
for obj in objects:
mesh = Mesh.Mesh()
mesh.addFacets(obj.Shape.tessellate({mesh_tolerance})[0])
meshes.append(mesh)
# Combine meshes
if len(meshes) == 1:
final_mesh = meshes[0]
else:
final_mesh = Mesh.Mesh()
for m in meshes:
final_mesh.addMesh(m)
# Export to 3MF format
final_mesh.write({file_path!r})
_result_ = {{
"success": True,
"path": {file_path!r},
"object_count": len(objects),
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "3MF export failed")
@mcp.tool()
async def export_obj(
file_path: str,
object_names: list[str] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Export objects to OBJ format.
OBJ (Wavefront) is a common 3D model format supported by many
3D graphics applications and game engines.
Args:
file_path: Path for the output .obj file.
object_names: List of object names to export. Exports all visible if None.
doc_name: Document to export from. Uses active document if None.
Returns:
Dictionary with export result:
- success: Whether export was successful
- path: Path to exported file
- object_count: Number of objects exported
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Mesh
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
# Create mesh from shapes
meshes = []
for obj in objects:
mesh = Mesh.Mesh()
mesh.addFacets(obj.Shape.tessellate(0.1)[0])
meshes.append(mesh)
# Combine meshes
if len(meshes) == 1:
final_mesh = meshes[0]
else:
final_mesh = Mesh.Mesh()
for m in meshes:
final_mesh.addMesh(m)
final_mesh.write({file_path!r})
_result_ = {{
"success": True,
"path": {file_path!r},
"object_count": len(objects),
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "OBJ export failed")
@mcp.tool()
async def export_iges(
file_path: str,
object_names: list[str] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Export objects to IGES format.
IGES (Initial Graphics Exchange Specification) is an older but still
widely supported CAD data exchange format.
Args:
file_path: Path for the output .iges file.
object_names: List of object names to export. Exports all visible if None.
doc_name: Document to export from. Uses active document if None.
Returns:
Dictionary with export result:
- success: Whether export was successful
- path: Path to exported file
- object_count: Number of objects exported
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
# Combine shapes
if len(objects) == 1:
shape = objects[0].Shape
else:
shape = Part.makeCompound([obj.Shape for obj in objects])
shape.exportIges({file_path!r})
_result_ = {{
"success": True,
"path": {file_path!r},
"object_count": len(objects),
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "IGES export failed")
@mcp.tool()
async def import_step(
file_path: str,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Import a STEP file into FreeCAD.
Args:
file_path: Path to the .step file to import.
doc_name: Document to import into. Creates new if None.
Returns:
Dictionary with import result:
- success: Whether import was successful
- document: Name of document containing imported objects
- objects: List of imported object names
"""
bridge = await get_bridge()
code = f"""
import Part
import os
if not os.path.exists({file_path!r}):
raise FileNotFoundError(f"File not found: {file_path!r}")
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
doc = FreeCAD.newDocument("Imported")
# Get object count before import
before_count = len(doc.Objects)
Part.insert({file_path!r}, doc.Name)
doc.recompute()
# Get new objects
new_objects = [obj.Name for obj in doc.Objects[before_count:]]
_result_ = {{
"success": True,
"document": doc.Name,
"objects": new_objects,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "STEP import failed")
@mcp.tool()
async def import_stl(
file_path: str,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Import an STL file into FreeCAD.
Args:
file_path: Path to the .stl file to import.
doc_name: Document to import into. Creates new if None.
Returns:
Dictionary with import result:
- success: Whether import was successful
- document: Name of document containing imported object
- object: Name of imported mesh object
"""
bridge = await get_bridge()
code = f"""
import Mesh
import os
if not os.path.exists({file_path!r}):
raise FileNotFoundError(f"File not found: {file_path!r}")
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
doc = FreeCAD.newDocument("Imported")
Mesh.insert({file_path!r}, doc.Name)
doc.recompute()
# Get the last added object (the imported mesh)
mesh_obj = doc.Objects[-1]
_result_ = {{
"success": True,
"document": doc.Name,
"object": mesh_obj.Name,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "STL import failed")
+330
View File
@@ -0,0 +1,330 @@
"""Macro management tools for FreeCAD MCP server.
This module provides tools for managing FreeCAD macros:
listing, running, creating, and editing macros.
Based on learnings from ATOI-Ming/FreeCAD-MCP which has a
macro-centric workflow with templates and validation.
"""
from typing import Any
def register_macro_tools(mcp, get_bridge) -> None:
"""Register macro-related tools with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
"""
@mcp.tool()
async def list_macros() -> list[dict[str, Any]]:
"""List all available FreeCAD macros.
Returns:
List of dictionaries, each containing:
- name: Macro name (without extension)
- path: Full path to macro file
- description: Macro description from comments
- is_system: Whether it's a system macro
"""
bridge = await get_bridge()
macros = await bridge.get_macros()
return [
{
"name": macro.name,
"path": macro.path,
"description": macro.description,
"is_system": macro.is_system,
}
for macro in macros
]
@mcp.tool()
async def run_macro(
macro_name: str,
args: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Run a FreeCAD macro by name.
Args:
macro_name: Name of the macro to run (without .FCMacro extension).
args: Optional dictionary of arguments to pass to the macro.
These will be set as variables before execution.
Returns:
Dictionary with execution result:
- success: Whether macro executed successfully
- stdout: Captured standard output
- stderr: Captured standard error
- execution_time_ms: Execution time in milliseconds
- error_type: Error type if failed
- error_traceback: Full traceback if failed
"""
bridge = await get_bridge()
result = await bridge.run_macro(macro_name, args)
return {
"success": result.success,
"stdout": result.stdout,
"stderr": result.stderr,
"execution_time_ms": result.execution_time_ms,
"error_type": result.error_type,
"error_traceback": result.error_traceback,
}
@mcp.tool()
async def create_macro(
name: str,
code: str,
description: str = "",
) -> dict[str, Any]:
"""Create a new FreeCAD macro.
The macro will be created in the user's macro directory with
standard FreeCAD imports automatically added.
Args:
name: Macro name (without .FCMacro extension).
code: Python code for the macro body.
description: Optional description for the macro.
Returns:
Dictionary with created macro information:
- name: Macro name
- path: Full path to created macro file
- description: Macro description
"""
bridge = await get_bridge()
macro = await bridge.create_macro(name, code, description)
return {
"name": macro.name,
"path": macro.path,
"description": macro.description,
}
@mcp.tool()
async def read_macro(macro_name: str) -> dict[str, Any]:
"""Read the contents of a FreeCAD macro.
Args:
macro_name: Name of the macro to read (without .FCMacro extension).
Returns:
Dictionary with macro contents:
- name: Macro name
- code: Full macro code
- path: Path to macro file
"""
bridge = await get_bridge()
code = f"""
import os
# Find macro file
macro_name = {macro_name!r}
macro_file = None
user_path = FreeCAD.getUserMacroDir(True)
user_macro = os.path.join(user_path, macro_name + ".FCMacro")
if os.path.exists(user_macro):
macro_file = user_macro
if not macro_file:
system_path = FreeCAD.getResourceDir() + "Macro"
system_macro = os.path.join(system_path, macro_name + ".FCMacro")
if os.path.exists(system_macro):
macro_file = system_macro
if not macro_file:
raise FileNotFoundError(f"Macro not found: {{macro_name}}")
with open(macro_file, "r") as f:
macro_code = f.read()
_result_ = {{
"name": macro_name,
"code": macro_code,
"path": macro_file,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Read macro failed")
@mcp.tool()
async def delete_macro(macro_name: str) -> dict[str, Any]:
"""Delete a user macro.
Note: Only user macros can be deleted. System macros are protected.
Args:
macro_name: Name of the macro to delete (without .FCMacro extension).
Returns:
Dictionary with delete result:
- success: Whether deletion was successful
- path: Path of deleted macro
"""
bridge = await get_bridge()
code = f"""
import os
macro_name = {macro_name!r}
user_path = FreeCAD.getUserMacroDir(True)
macro_file = os.path.join(user_path, macro_name + ".FCMacro")
if not os.path.exists(macro_file):
raise FileNotFoundError(f"User macro not found: {{macro_name}}")
os.remove(macro_file)
_result_ = {{
"success": True,
"path": macro_file,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Delete macro failed")
@mcp.tool()
async def create_macro_from_template(
name: str,
template: str = "basic",
description: str = "",
) -> dict[str, Any]:
"""Create a new macro from a predefined template.
Available templates provide common starting points for FreeCAD macros.
Args:
name: Macro name (without .FCMacro extension).
template: Template name. Options:
- "basic" - Basic template with imports
- "part" - Part workbench operations template
- "sketch" - Sketcher operations template
- "gui" - GUI/dialog template
- "selection" - Selection handling template
description: Optional description for the macro.
Returns:
Dictionary with created macro information:
- name: Macro name
- path: Full path to created macro file
- template: Template used
"""
templates = {
"basic": """
# Your macro code goes here
doc = FreeCAD.ActiveDocument
if doc is None:
doc = FreeCAD.newDocument("MacroDoc")
# Add your operations here
print("Macro executed successfully!")
""",
"part": """
import Part
doc = FreeCAD.ActiveDocument
if doc is None:
doc = FreeCAD.newDocument("MacroDoc")
# Create a box as example
box = doc.addObject("Part::Box", "MyBox")
box.Length = 10
box.Width = 20
box.Height = 30
doc.recompute()
print(f"Created box with volume: {box.Shape.Volume}")
""",
"sketch": """
import Part
import Sketcher
doc = FreeCAD.ActiveDocument
if doc is None:
doc = FreeCAD.newDocument("MacroDoc")
# Create a sketch
sketch = doc.addObject("Sketcher::SketchObject", "MySketch")
# Add a rectangle
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(10, 0, 0)), False)
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(10, 0, 0), FreeCAD.Vector(10, 10, 0)), False)
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(10, 10, 0), FreeCAD.Vector(0, 10, 0)), False)
sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 10, 0), FreeCAD.Vector(0, 0, 0)), False)
doc.recompute()
print("Created sketch with rectangle")
""",
"gui": """
from PySide2 import QtWidgets
class MacroDialog(QtWidgets.QDialog):
def __init__(self):
super().__init__()
self.setWindowTitle("Macro Dialog")
self.setup_ui()
def setup_ui(self):
layout = QtWidgets.QVBoxLayout()
self.label = QtWidgets.QLabel("Enter value:")
layout.addWidget(self.label)
self.input = QtWidgets.QLineEdit()
layout.addWidget(self.input)
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self.setLayout(layout)
# Show dialog
dialog = MacroDialog()
if dialog.exec_():
value = dialog.input.text()
print(f"User entered: {value}")
""",
"selection": """
# Get current selection
sel = FreeCADGui.Selection.getSelection()
if not sel:
print("Nothing selected!")
else:
for obj in sel:
print(f"Selected: {obj.Name} ({obj.TypeId})")
# Access shape if available
if hasattr(obj, "Shape"):
shape = obj.Shape
print(f" - Volume: {shape.Volume}")
print(f" - Area: {shape.Area}")
print(f" - Faces: {len(shape.Faces)}")
print(f" - Edges: {len(shape.Edges)}")
""",
}
if template not in templates:
raise ValueError(
f"Unknown template: {template}. Available: {list(templates.keys())}"
)
bridge = await get_bridge()
macro = await bridge.create_macro(name, templates[template], description)
return {
"name": macro.name,
"path": macro.path,
"template": template,
}
+993
View File
@@ -0,0 +1,993 @@
"""Object management tools for FreeCAD MCP server.
This module provides tools for managing FreeCAD objects:
creating, editing, deleting, and inspecting objects.
"""
from typing import Any
def register_object_tools(mcp, get_bridge) -> None:
"""Register object-related tools with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
"""
@mcp.tool()
async def list_objects(doc_name: str | None = None) -> list[dict[str, Any]]:
"""List all objects in a FreeCAD document.
Args:
doc_name: Name of document. Uses active document if None.
Returns:
List of dictionaries, each containing:
- name: Object name
- label: Display label
- type_id: FreeCAD type identifier (e.g., "Part::Box")
- visibility: Whether object is visible
"""
bridge = await get_bridge()
objects = await bridge.get_objects(doc_name)
return [
{
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
"visibility": obj.visibility,
}
for obj in objects
]
@mcp.tool()
async def inspect_object(
object_name: str,
doc_name: str | None = None,
include_properties: bool = True,
include_shape: bool = True,
) -> dict[str, Any]:
"""Get detailed information about a FreeCAD object.
Args:
object_name: Name of the object to inspect.
doc_name: Document containing the object. Uses active document if None.
include_properties: Whether to include property values.
include_shape: Whether to include shape geometry details.
Returns:
Dictionary containing comprehensive object information:
- name: Object name
- label: Object label
- type_id: FreeCAD type identifier
- properties: Dictionary of property names and values (if requested)
- shape_info: Shape details (if requested and object has shape)
- children: List of child object names
- parents: List of parent object names
- visibility: Whether object is visible
"""
bridge = await get_bridge()
obj = await bridge.get_object(object_name, doc_name)
result = {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
"children": obj.children,
"parents": obj.parents,
"visibility": obj.visibility,
}
if include_properties:
result["properties"] = obj.properties
if include_shape and obj.shape_info:
result["shape_info"] = obj.shape_info
return result
@mcp.tool()
async def create_object(
type_id: str,
name: str | None = None,
properties: dict[str, Any] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a new FreeCAD object.
Args:
type_id: FreeCAD type ID for the object. Common types include:
- "Part::Box" - Parametric box
- "Part::Cylinder" - Parametric cylinder
- "Part::Sphere" - Parametric sphere
- "Part::Cone" - Parametric cone
- "Part::Torus" - Parametric torus
- "Part::Feature" - Generic Part feature
- "Sketcher::SketchObject" - Sketch
- "PartDesign::Body" - PartDesign body
name: Object name. Auto-generated if None.
properties: Initial property values to set.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(type_id, name, properties, doc_name)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def create_box(
length: float = 10.0,
width: float = 10.0,
height: float = 10.0,
name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a Part Box primitive.
Args:
length: Box length (X dimension). Defaults to 10.0.
width: Box width (Y dimension). Defaults to 10.0.
height: Box height (Z dimension). Defaults to 10.0.
name: Object name. Auto-generated if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- volume: Box volume
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(
"Part::Box",
name,
{"Length": length, "Width": width, "Height": height},
doc_name,
)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
"volume": length * width * height,
}
@mcp.tool()
async def create_cylinder(
radius: float = 5.0,
height: float = 10.0,
angle: float = 360.0,
name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a Part Cylinder primitive.
Args:
radius: Cylinder radius. Defaults to 5.0.
height: Cylinder height. Defaults to 10.0.
angle: Sweep angle in degrees (for partial cylinder). Defaults to 360.
name: Object name. Auto-generated if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(
"Part::Cylinder",
name,
{"Radius": radius, "Height": height, "Angle": angle},
doc_name,
)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def create_sphere(
radius: float = 5.0,
name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a Part Sphere primitive.
Args:
radius: Sphere radius. Defaults to 5.0.
name: Object name. Auto-generated if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(
"Part::Sphere",
name,
{"Radius": radius},
doc_name,
)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def create_cone(
radius1: float = 5.0,
radius2: float = 0.0,
height: float = 10.0,
angle: float = 360.0,
name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a Part Cone primitive.
Args:
radius1: Bottom radius. Defaults to 5.0.
radius2: Top radius (0 for pointed cone). Defaults to 0.0.
height: Cone height. Defaults to 10.0.
angle: Sweep angle in degrees (for partial cone). Defaults to 360.
name: Object name. Auto-generated if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(
"Part::Cone",
name,
{"Radius1": radius1, "Radius2": radius2, "Height": height, "Angle": angle},
doc_name,
)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def create_torus(
radius1: float = 10.0,
radius2: float = 2.0,
angle1: float = -180.0,
angle2: float = 180.0,
angle3: float = 360.0,
name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a Part Torus (donut shape) primitive.
Args:
radius1: Major radius (center to tube center). Defaults to 10.0.
radius2: Minor radius (tube radius). Defaults to 2.0.
angle1: Start angle for tube sweep. Defaults to -180.
angle2: End angle for tube sweep. Defaults to 180.
angle3: Rotation angle around axis. Defaults to 360.
name: Object name. Auto-generated if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(
"Part::Torus",
name,
{
"Radius1": radius1,
"Radius2": radius2,
"Angle1": angle1,
"Angle2": angle2,
"Angle3": angle3,
},
doc_name,
)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def create_wedge(
xmin: float = 0.0,
ymin: float = 0.0,
zmin: float = 0.0,
x2min: float = 2.0,
z2min: float = 2.0,
xmax: float = 10.0,
ymax: float = 10.0,
zmax: float = 10.0,
x2max: float = 8.0,
z2max: float = 8.0,
name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a Part Wedge primitive.
A wedge is a tapered box shape useful for ramps and similar geometry.
Args:
xmin: Minimum X at base. Defaults to 0.0.
ymin: Minimum Y (base position). Defaults to 0.0.
zmin: Minimum Z at base. Defaults to 0.0.
x2min: Minimum X at top. Defaults to 2.0.
z2min: Minimum Z at top. Defaults to 2.0.
xmax: Maximum X at base. Defaults to 10.0.
ymax: Maximum Y (top position). Defaults to 10.0.
zmax: Maximum Z at base. Defaults to 10.0.
x2max: Maximum X at top. Defaults to 8.0.
z2max: Maximum Z at top. Defaults to 8.0.
name: Object name. Auto-generated if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(
"Part::Wedge",
name,
{
"Xmin": xmin,
"Ymin": ymin,
"Zmin": zmin,
"X2min": x2min,
"Z2min": z2min,
"Xmax": xmax,
"Ymax": ymax,
"Zmax": zmax,
"X2max": x2max,
"Z2max": z2max,
},
doc_name,
)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def create_helix(
pitch: float = 5.0,
height: float = 20.0,
radius: float = 5.0,
angle: float = 0.0,
left_handed: bool = False,
name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a Part Helix curve.
A helix is a spiral curve, useful as a sweep path for threads and springs.
Args:
pitch: Distance between turns. Defaults to 5.0.
height: Total helix height. Defaults to 20.0.
radius: Helix radius. Defaults to 5.0.
angle: Taper angle in degrees. Defaults to 0.0.
left_handed: Whether helix is left-handed. Defaults to False.
name: Object name. Auto-generated if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with created object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.create_object(
"Part::Helix",
name,
{
"Pitch": pitch,
"Height": height,
"Radius": radius,
"Angle": angle,
"LocalCoord": 1 if left_handed else 0,
},
doc_name,
)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def edit_object(
object_name: str,
properties: dict[str, Any],
doc_name: str | None = None,
) -> dict[str, Any]:
"""Edit properties of an existing FreeCAD object.
Args:
object_name: Name of the object to edit.
properties: Dictionary of property names and new values.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with updated object information:
- name: Object name
- label: Object label
- type_id: Object type
"""
bridge = await get_bridge()
obj = await bridge.edit_object(object_name, properties, doc_name)
return {
"name": obj.name,
"label": obj.label,
"type_id": obj.type_id,
}
@mcp.tool()
async def delete_object(
object_name: str,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Delete an object from a FreeCAD document.
Args:
object_name: Name of the object to delete.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with delete result:
- success: Whether delete was successful
"""
bridge = await get_bridge()
await bridge.delete_object(object_name, doc_name)
return {"success": True}
@mcp.tool()
async def boolean_operation(
operation: str,
object1_name: str,
object2_name: str,
result_name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Perform a boolean operation on two FreeCAD objects.
Args:
operation: Boolean operation type: "fuse" (union), "cut" (subtract),
or "common" (intersection).
object1_name: Name of the first object.
object2_name: Name of the second object.
result_name: Name for the result object. Auto-generated if None.
doc_name: Document containing the objects. Uses active document if None.
Returns:
Dictionary with result object information:
- name: Result object name
- label: Result object label
- type_id: Result object type
"""
bridge = await get_bridge()
operation_map = {
"fuse": "Part::MultiFuse",
"cut": "Part::Cut",
"common": "Part::MultiCommon",
}
if operation not in operation_map:
raise ValueError(f"Invalid operation: {operation}. Use: fuse, cut, common")
op_type = operation_map[operation]
result_name = result_name or f"{operation.capitalize()}"
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
obj1 = doc.getObject({object1_name!r})
obj2 = doc.getObject({object2_name!r})
if obj1 is None:
raise ValueError(f"Object not found: {object1_name!r}")
if obj2 is None:
raise ValueError(f"Object not found: {object2_name!r}")
if {op_type!r} == "Part::Cut":
result = doc.addObject({op_type!r}, {result_name!r})
result.Base = obj1
result.Tool = obj2
else:
result = doc.addObject({op_type!r}, {result_name!r})
result.Shapes = [obj1, obj2]
doc.recompute()
_result_ = {{
"name": result.Name,
"label": result.Label,
"type_id": result.TypeId,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Boolean operation failed")
@mcp.tool()
async def set_placement(
object_name: str,
position: list[float] | None = None,
rotation: list[float] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Set the placement (position and rotation) of a FreeCAD object.
Args:
object_name: Name of the object to move.
position: Position as [x, y, z]. Keeps current if None.
rotation: Rotation as [yaw, pitch, roll] in degrees. Keeps current if None.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with new placement:
- position: New position [x, y, z]
- rotation: New rotation angles
"""
bridge = await get_bridge()
pos_str = (
f"FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})"
if position
else "obj.Placement.Base"
)
rot_str = (
f"FreeCAD.Rotation({rotation[0]}, {rotation[1]}, {rotation[2]})"
if rotation
else "obj.Placement.Rotation"
)
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
obj = doc.getObject({object_name!r})
if obj is None:
raise ValueError(f"Object not found: {object_name!r}")
pos = {pos_str}
rot = {rot_str}
obj.Placement = FreeCAD.Placement(pos, rot)
doc.recompute()
_result_ = {{
"position": [obj.Placement.Base.x, obj.Placement.Base.y, obj.Placement.Base.z],
"rotation": list(obj.Placement.Rotation.toEuler()),
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Set placement failed")
@mcp.tool()
async def scale_object(
object_name: str,
scale: float | list[float],
result_name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Scale an object uniformly or non-uniformly.
Creates a new scaled copy using Part.Scale.
Args:
object_name: Name of the object to scale.
scale: Scale factor. Can be:
- A single float for uniform scaling
- A list [sx, sy, sz] for non-uniform scaling
result_name: Name for the result object. Auto-generated if None.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with result object information:
- name: Result object name
- label: Result object label
- type_id: Result object type
"""
bridge = await get_bridge()
if isinstance(scale, int | float):
scale_vec = f"FreeCAD.Vector({scale}, {scale}, {scale})"
else:
scale_vec = f"FreeCAD.Vector({scale[0]}, {scale[1]}, {scale[2]})"
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
obj = doc.getObject({object_name!r})
if obj is None:
raise ValueError(f"Object not found: {object_name!r}")
if not hasattr(obj, "Shape"):
raise ValueError("Object has no shape to scale")
import Part
scale_vec = {scale_vec}
center = obj.Shape.BoundBox.Center
# Create scaled shape
mat = FreeCAD.Matrix()
mat.scale(scale_vec)
scaled_shape = obj.Shape.transformGeometry(mat)
# Create result object
result_name = {result_name!r} or f"{{obj.Name}}_scaled"
result = doc.addObject("Part::Feature", result_name)
result.Shape = scaled_shape
doc.recompute()
_result_ = {{
"name": result.Name,
"label": result.Label,
"type_id": result.TypeId,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Scale operation failed")
@mcp.tool()
async def rotate_object(
object_name: str,
axis: list[float],
angle: float,
center: list[float] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Rotate an object around an axis.
Modifies the object's placement in-place.
Args:
object_name: Name of the object to rotate.
axis: Rotation axis as [x, y, z] vector.
angle: Rotation angle in degrees.
center: Center point for rotation [x, y, z].
Uses object center if None.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with new placement:
- position: New position [x, y, z]
- rotation: New rotation angles
"""
bridge = await get_bridge()
center_str = (
f"FreeCAD.Vector({center[0]}, {center[1]}, {center[2]})"
if center
else "obj.Shape.BoundBox.Center if hasattr(obj, 'Shape') else FreeCAD.Vector(0,0,0)"
)
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
obj = doc.getObject({object_name!r})
if obj is None:
raise ValueError(f"Object not found: {object_name!r}")
axis = FreeCAD.Vector({axis[0]}, {axis[1]}, {axis[2]})
center = {center_str}
# Create rotation
rot = FreeCAD.Rotation(axis, {angle})
# Apply rotation around center
old_placement = obj.Placement
new_rot = rot.multiply(old_placement.Rotation)
# Adjust position for rotation around center
pos_vec = old_placement.Base - center
rotated_pos = rot.multVec(pos_vec) + center
obj.Placement = FreeCAD.Placement(rotated_pos, new_rot)
doc.recompute()
_result_ = {{
"position": [obj.Placement.Base.x, obj.Placement.Base.y, obj.Placement.Base.z],
"rotation": list(obj.Placement.Rotation.toEuler()),
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Rotate operation failed")
@mcp.tool()
async def copy_object(
object_name: str,
new_name: str | None = None,
offset: list[float] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Create a copy of an object.
Args:
object_name: Name of the object to copy.
new_name: Name for the copy. Auto-generated if None.
offset: Position offset [x, y, z] for the copy. [0,0,0] if None.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with copy object information:
- name: Copy object name
- label: Copy object label
- type_id: Copy object type
"""
bridge = await get_bridge()
offset_str = (
f"[{offset[0]}, {offset[1]}, {offset[2]}]" if offset else "[0, 0, 0]"
)
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
obj = doc.getObject({object_name!r})
if obj is None:
raise ValueError(f"Object not found: {object_name!r}")
# Create copy
new_name = {new_name!r} or f"{{obj.Name}}_copy"
if hasattr(obj, "Shape"):
copy_obj = doc.addObject("Part::Feature", new_name)
copy_obj.Shape = obj.Shape.copy()
else:
# For non-shape objects, create simple copy
copy_obj = doc.copyObject(obj, False)
copy_obj.Label = new_name
# Apply offset
offset = {offset_str}
copy_obj.Placement.Base = FreeCAD.Vector(
obj.Placement.Base.x + offset[0],
obj.Placement.Base.y + offset[1],
obj.Placement.Base.z + offset[2]
)
doc.recompute()
_result_ = {{
"name": copy_obj.Name,
"label": copy_obj.Label,
"type_id": copy_obj.TypeId,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Copy operation failed")
@mcp.tool()
async def mirror_object(
object_name: str,
plane: str = "XY",
result_name: str | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Mirror an object across a plane.
Creates a new mirrored copy of the object.
Args:
object_name: Name of the object to mirror.
plane: Mirror plane. Options: "XY", "XZ", "YZ".
result_name: Name for the result object. Auto-generated if None.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with result object information:
- name: Result object name
- label: Result object label
- type_id: Result object type
"""
bridge = await get_bridge()
plane_map = {
"XY": "(0, 0, 1)",
"XZ": "(0, 1, 0)",
"YZ": "(1, 0, 0)",
}
if plane not in plane_map:
raise ValueError(f"Invalid plane: {plane}. Use: XY, XZ, YZ")
normal = plane_map[plane]
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
obj = doc.getObject({object_name!r})
if obj is None:
raise ValueError(f"Object not found: {object_name!r}")
if not hasattr(obj, "Shape"):
raise ValueError("Object has no shape to mirror")
# Create mirror matrix
import Part
normal = FreeCAD.Vector{normal}
center = obj.Shape.BoundBox.Center
# Mirror the shape
mirrored = obj.Shape.mirror(center, normal)
# Create result object
result_name = {result_name!r} or f"{{obj.Name}}_mirror"
result = doc.addObject("Part::Feature", result_name)
result.Shape = mirrored
doc.recompute()
_result_ = {{
"name": result.Name,
"label": result.Label,
"type_id": result.TypeId,
}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Mirror operation failed")
@mcp.tool()
async def get_selection(doc_name: str | None = None) -> list[dict[str, Any]]:
"""Get the current selection in FreeCAD.
Requires GUI mode.
Args:
doc_name: Document to check selection in. Uses active document if None.
Returns:
List of selected objects with:
- name: Object name
- label: Object label
- type_id: Object type
- sub_elements: List of selected sub-elements (e.g., ["Face1", "Edge2"])
"""
bridge = await get_bridge()
code = f"""
if not FreeCAD.GuiUp:
_result_ = []
else:
sel = FreeCADGui.Selection.getSelectionEx({doc_name!r})
_result_ = []
for s in sel:
_result_.append({{
"name": s.Object.Name,
"label": s.Object.Label,
"type_id": s.Object.TypeId,
"sub_elements": list(s.SubElementNames) if s.SubElementNames else [],
}})
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
return []
@mcp.tool()
async def set_selection(
object_names: list[str],
clear_existing: bool = True,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Set the selection in FreeCAD.
Requires GUI mode.
Args:
object_names: List of object names to select.
clear_existing: Whether to clear existing selection first. Defaults to True.
doc_name: Document containing the objects. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
- selected_count: Number of objects selected
"""
bridge = await get_bridge()
code = f"""
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available"}}
else:
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
if {clear_existing}:
FreeCADGui.Selection.clearSelection()
count = 0
for name in {object_names!r}:
obj = doc.getObject(name)
if obj:
FreeCADGui.Selection.addSelection(obj)
count += 1
_result_ = {{"success": True, "selected_count": count}}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Set selection failed")
@mcp.tool()
async def clear_selection() -> dict[str, Any]:
"""Clear the current selection in FreeCAD.
Requires GUI mode.
Returns:
Dictionary with result:
- success: Whether operation was successful
"""
bridge = await get_bridge()
code = """
if not FreeCAD.GuiUp:
_result_ = {"success": False, "error": "GUI not available"}
else:
FreeCADGui.Selection.clearSelection()
_result_ = {"success": True}
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
raise ValueError(result.error_traceback or "Clear selection failed")
File diff suppressed because it is too large Load Diff
+782
View File
@@ -0,0 +1,782 @@
"""View and screenshot tools for FreeCAD MCP server.
This module provides tools for controlling the 3D view and
capturing screenshots. Based on learnings from neka-nat which
has excellent screenshot handling with view type detection.
"""
from typing import Any
def register_view_tools(mcp, get_bridge) -> None:
"""Register view-related tools with the MCP server.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
"""
@mcp.tool()
async def get_screenshot(
view_angle: str = "Isometric",
width: int = 800,
height: int = 600,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Capture a screenshot of the FreeCAD 3D view.
Requires GUI mode - will return an error in headless mode.
Args:
view_angle: View angle to set before capture. Options:
- "Isometric" - 3D isometric view (default)
- "Front" - Front view (XZ plane)
- "Back" - Back view
- "Top" - Top view (XY plane)
- "Bottom" - Bottom view
- "Left" - Left view (YZ plane)
- "Right" - Right view
- "FitAll" - Fit all objects in view
width: Image width in pixels. Defaults to 800.
height: Image height in pixels. Defaults to 600.
doc_name: Document to capture. Uses active document if None.
Returns:
Dictionary with screenshot result:
- success: Whether capture was successful
- data: Base64-encoded PNG image data (if success)
- format: Image format ("png")
- width: Actual image width
- height: Actual image height
- error: Error message (if not success)
"""
from freecad_mcp.bridge.base import ViewAngle
# Map string to ViewAngle enum
angle_map = {
"Isometric": ViewAngle.ISOMETRIC,
"Front": ViewAngle.FRONT,
"Back": ViewAngle.BACK,
"Top": ViewAngle.TOP,
"Bottom": ViewAngle.BOTTOM,
"Left": ViewAngle.LEFT,
"Right": ViewAngle.RIGHT,
"FitAll": ViewAngle.FIT_ALL,
}
if view_angle not in angle_map:
return {
"success": False,
"error": f"Invalid view_angle: {view_angle}. Options: {list(angle_map.keys())}",
}
bridge = await get_bridge()
result = await bridge.get_screenshot(
view_angle=angle_map[view_angle],
width=width,
height=height,
doc_name=doc_name,
)
return {
"success": result.success,
"data": result.data,
"format": result.format,
"width": result.width,
"height": result.height,
"error": result.error,
}
@mcp.tool()
async def set_view_angle(
view_angle: str,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Set the 3D view angle.
Args:
view_angle: View angle to set. Options:
- "Isometric" - 3D isometric view
- "Front" - Front view (XZ plane)
- "Back" - Back view
- "Top" - Top view (XY plane)
- "Bottom" - Bottom view
- "Left" - Left view (YZ plane)
- "Right" - Right view
- "FitAll" - Fit all objects in view
doc_name: Document to set view for. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
"""
from freecad_mcp.bridge.base import ViewAngle
angle_map = {
"Isometric": ViewAngle.ISOMETRIC,
"Front": ViewAngle.FRONT,
"Back": ViewAngle.BACK,
"Top": ViewAngle.TOP,
"Bottom": ViewAngle.BOTTOM,
"Left": ViewAngle.LEFT,
"Right": ViewAngle.RIGHT,
"FitAll": ViewAngle.FIT_ALL,
}
if view_angle not in angle_map:
return {
"success": False,
"error": f"Invalid view_angle: {view_angle}. Options: {list(angle_map.keys())}",
}
bridge = await get_bridge()
await bridge.set_view(angle_map[view_angle], doc_name)
return {"success": True}
@mcp.tool()
async def list_workbenches() -> list[dict[str, Any]]:
"""List all available FreeCAD workbenches.
Returns:
List of dictionaries, each containing:
- name: Workbench internal name
- label: Display label
- is_active: Whether workbench is currently active
"""
bridge = await get_bridge()
workbenches = await bridge.get_workbenches()
return [
{
"name": wb.name,
"label": wb.label,
"is_active": wb.is_active,
}
for wb in workbenches
]
@mcp.tool()
async def activate_workbench(workbench_name: str) -> dict[str, Any]:
"""Activate a FreeCAD workbench.
Args:
workbench_name: Internal name of the workbench to activate.
Common workbenches:
- "PartWorkbench" - Part modeling
- "PartDesignWorkbench" - Parametric part design
- "SketcherWorkbench" - 2D sketching
- "DraftWorkbench" - 2D drafting
- "MeshWorkbench" - Mesh operations
- "SpreadsheetWorkbench" - Spreadsheet
Returns:
Dictionary with result:
- success: Whether activation was successful
"""
bridge = await get_bridge()
await bridge.activate_workbench(workbench_name)
return {"success": True}
@mcp.tool()
async def fit_all(doc_name: str | None = None) -> dict[str, Any]:
"""Fit all objects in the current view.
Adjusts the camera to show all visible objects in the document.
Args:
doc_name: Document to fit view for. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
"""
from freecad_mcp.bridge.base import ViewAngle
bridge = await get_bridge()
await bridge.set_view(ViewAngle.FIT_ALL, doc_name)
return {"success": True}
@mcp.tool()
async def set_object_visibility(
object_name: str,
visible: bool,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Set the visibility of a FreeCAD object.
Args:
object_name: Name of the object.
visible: Whether object should be visible.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
- visible: New visibility state
"""
bridge = await get_bridge()
code = f"""
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available - visibility cannot be set in headless mode"}}
else:
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "error": "No document found"}}
else:
obj = doc.getObject({object_name!r})
if obj is None:
_result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}}
elif hasattr(obj, "ViewObject") and obj.ViewObject:
obj.ViewObject.Visibility = {visible}
_result_ = {{"success": True, "visible": {visible}}}
else:
_result_ = {{"success": False, "error": "Object has no ViewObject"}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Set visibility failed",
}
@mcp.tool()
async def set_display_mode(
object_name: str,
mode: str,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Set the display mode of a FreeCAD object.
Args:
object_name: Name of the object.
mode: Display mode. Common options:
- "Flat Lines" - Solid with edges
- "Shaded" - Solid without edges
- "Wireframe" - Wire frame only
- "Points" - Points only
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
- mode: New display mode
"""
bridge = await get_bridge()
code = f"""
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available - display mode cannot be set in headless mode"}}
else:
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "error": "No document found"}}
else:
obj = doc.getObject({object_name!r})
if obj is None:
_result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}}
elif hasattr(obj, "ViewObject") and obj.ViewObject:
obj.ViewObject.DisplayMode = {mode!r}
_result_ = {{"success": True, "mode": {mode!r}}}
else:
_result_ = {{"success": False, "error": "Object has no ViewObject"}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Set display mode failed",
}
@mcp.tool()
async def set_object_color(
object_name: str,
color: list[float],
doc_name: str | None = None,
) -> dict[str, Any]:
"""Set the color of a FreeCAD object.
Args:
object_name: Name of the object.
color: RGB color as [r, g, b] where each value is 0.0-1.0.
Example: [1.0, 0.0, 0.0] for red.
doc_name: Document containing the object. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
- color: New color values
"""
bridge = await get_bridge()
if len(color) != 3:
return {
"success": False,
"error": "Color must be [r, g, b] with values 0.0-1.0",
}
code = f"""
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available - color cannot be set in headless mode"}}
else:
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "error": "No document found"}}
else:
obj = doc.getObject({object_name!r})
if obj is None:
_result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}}
elif hasattr(obj, "ViewObject") and obj.ViewObject:
obj.ViewObject.ShapeColor = ({color[0]}, {color[1]}, {color[2]})
_result_ = {{"success": True, "color": {color}}}
else:
_result_ = {{"success": False, "error": "Object has no ViewObject"}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Set color failed",
}
@mcp.tool()
async def zoom_in(
factor: float = 1.5, doc_name: str | None = None
) -> dict[str, Any]:
"""Zoom in the 3D view.
Requires GUI mode.
Args:
factor: Zoom factor (>1 zooms in). Defaults to 1.5.
doc_name: Document to zoom in. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
"""
bridge = await get_bridge()
code = f"""
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available - zoom requires GUI mode"}}
else:
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "error": "No document found"}}
elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None:
_result_ = {{"success": False, "error": "No active view"}}
else:
view = FreeCADGui.ActiveDocument.ActiveView
cam = view.getCameraNode()
if hasattr(cam, "scaleHeight"):
# For orthographic views
cam.scaleHeight(1.0 / {factor})
else:
# For perspective views - move camera closer
view.zoomIn()
_result_ = {{"success": True}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Zoom in failed",
}
@mcp.tool()
async def zoom_out(
factor: float = 1.5, doc_name: str | None = None
) -> dict[str, Any]:
"""Zoom out the 3D view.
Requires GUI mode.
Args:
factor: Zoom factor (>1 zooms out). Defaults to 1.5.
doc_name: Document to zoom out. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
"""
bridge = await get_bridge()
code = f"""
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available - zoom requires GUI mode"}}
else:
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "error": "No document found"}}
elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None:
_result_ = {{"success": False, "error": "No active view"}}
else:
view = FreeCADGui.ActiveDocument.ActiveView
cam = view.getCameraNode()
if hasattr(cam, "scaleHeight"):
# For orthographic views
cam.scaleHeight({factor})
else:
# For perspective views - move camera farther
view.zoomOut()
_result_ = {{"success": True}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Zoom out failed",
}
@mcp.tool()
async def set_camera_position(
position: list[float],
look_at: list[float] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Set the camera position and orientation.
Requires GUI mode.
Args:
position: Camera position as [x, y, z].
look_at: Point to look at as [x, y, z]. Uses origin if None.
doc_name: Document to set camera for. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether operation was successful
"""
bridge = await get_bridge()
look_str = (
f"FreeCAD.Vector({look_at[0]}, {look_at[1]}, {look_at[2]})"
if look_at
else "FreeCAD.Vector(0, 0, 0)"
)
code = f"""
if not FreeCAD.GuiUp:
_result_ = {{"success": False, "error": "GUI not available - camera position requires GUI mode"}}
else:
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "error": "No document found"}}
elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None:
_result_ = {{"success": False, "error": "No active view"}}
else:
view = FreeCADGui.ActiveDocument.ActiveView
pos = FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})
look_at = {look_str}
# Calculate direction
direction = look_at - pos
direction.normalize()
# Set camera
view.setCameraOrientation(FreeCAD.Rotation(FreeCAD.Vector(0, 0, -1), direction))
cam = view.getCameraNode()
cam.position.setValue(pos.x, pos.y, pos.z)
_result_ = {{"success": True}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Set camera position failed",
}
@mcp.tool()
async def undo(doc_name: str | None = None) -> dict[str, Any]:
"""Undo the last operation.
Args:
doc_name: Document to undo in. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether undo was performed
- can_undo: Whether more undos are available
"""
bridge = await get_bridge()
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "can_undo": False, "error": "No document found"}}
elif doc.UndoCount > 0:
doc.undo()
_result_ = {{"success": True, "can_undo": doc.UndoCount > 0}}
else:
_result_ = {{"success": False, "can_undo": False, "error": "Nothing to undo"}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"can_undo": False,
"error": result.error_traceback or "Undo failed",
}
@mcp.tool()
async def redo(doc_name: str | None = None) -> dict[str, Any]:
"""Redo the last undone operation.
Args:
doc_name: Document to redo in. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether redo was performed
- can_redo: Whether more redos are available
"""
bridge = await get_bridge()
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "can_redo": False, "error": "No document found"}}
elif doc.RedoCount > 0:
doc.redo()
_result_ = {{"success": True, "can_redo": doc.RedoCount > 0}}
else:
_result_ = {{"success": False, "can_redo": False, "error": "Nothing to redo"}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"can_redo": False,
"error": result.error_traceback or "Redo failed",
}
@mcp.tool()
async def get_undo_redo_status(doc_name: str | None = None) -> dict[str, Any]:
"""Get the current undo/redo status.
Args:
doc_name: Document to check. Uses active document if None.
Returns:
Dictionary with status:
- undo_count: Number of undoable operations
- redo_count: Number of redoable operations
- undo_names: List of undo operation names (if available)
"""
bridge = await get_bridge()
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"error": "No document found", "undo_count": 0, "redo_count": 0, "undo_names": []}}
else:
_result_ = {{
"undo_count": doc.UndoCount,
"redo_count": doc.RedoCount,
"undo_names": list(doc.UndoNames) if hasattr(doc, "UndoNames") else [],
}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"error": result.error_traceback or "Get undo/redo status failed",
"undo_count": 0,
"redo_count": 0,
"undo_names": [],
}
@mcp.tool()
async def list_parts_library() -> list[dict[str, Any]]:
"""List available parts from the FreeCAD parts library.
Returns:
List of parts with:
- name: Part filename
- path: Full path to part file
- category: Part category/folder
"""
bridge = await get_bridge()
code = """
import os
parts = []
# Get parts library paths
try:
# Standard library path
lib_path = FreeCAD.getResourceDir() + "Mod/Parts_Library"
if not os.path.exists(lib_path):
lib_path = os.path.expanduser("~/.FreeCAD/Mod/PartsLibrary")
if os.path.exists(lib_path):
for root, dirs, files in os.walk(lib_path):
category = os.path.relpath(root, lib_path)
if category == ".":
category = "Root"
for f in files:
if f.endswith((".FCStd", ".step", ".stp", ".iges", ".igs")):
parts.append({
"name": f,
"path": os.path.join(root, f),
"category": category,
})
except Exception as e:
pass
_result_ = parts
"""
result = await bridge.execute_python(code)
if result.success:
return result.result
return []
@mcp.tool()
async def insert_part_from_library(
part_path: str,
name: str | None = None,
position: list[float] | None = None,
doc_name: str | None = None,
) -> dict[str, Any]:
"""Insert a part from the parts library into the document.
Args:
part_path: Path to the part file.
name: Name for the inserted part. Auto-generated if None.
position: Initial position [x, y, z]. Origin if None.
doc_name: Target document. Uses active document if None.
Returns:
Dictionary with inserted part information:
- name: Part name
- label: Part label
- type_id: Part type
"""
bridge = await get_bridge()
pos_str = (
f"FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})"
if position
else "FreeCAD.Vector(0, 0, 0)"
)
code = f"""
import os
import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
doc = FreeCAD.newDocument("Unnamed")
part_path = {part_path!r}
if not os.path.exists(part_path):
raise FileNotFoundError(f"Part file not found: {{part_path}}")
ext = os.path.splitext(part_path)[1].lower()
part_name = {name!r} or os.path.splitext(os.path.basename(part_path))[0]
if ext == ".fcstd":
# Import FreeCAD document
src_doc = FreeCAD.openDocument(part_path)
for obj in src_doc.Objects:
if hasattr(obj, "Shape"):
new_obj = doc.addObject("Part::Feature", part_name)
new_obj.Shape = obj.Shape.copy()
break
FreeCAD.closeDocument(src_doc.Name)
else:
# Import STEP/IGES
shape = Part.read(part_path)
new_obj = doc.addObject("Part::Feature", part_name)
new_obj.Shape = shape
# Set position
new_obj.Placement.Base = {pos_str}
doc.recompute()
_result_ = {{
"name": new_obj.Name,
"label": new_obj.Label,
"type_id": new_obj.TypeId,
}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Insert part from library failed",
}
@mcp.tool()
async def get_console_log(lines: int = 50) -> dict[str, Any]:
"""Get recent console output from FreeCAD.
Args:
lines: Maximum number of lines to return. Defaults to 50.
Returns:
Dictionary with:
- messages: List of console messages
- warnings: List of warning messages
- errors: List of error messages
"""
bridge = await get_bridge()
console_lines = await bridge.get_console_output(lines)
return {
"messages": console_lines,
"warnings": [line for line in console_lines if "warning" in line.lower()],
"errors": [line for line in console_lines if "error" in line.lower()],
}
@mcp.tool()
async def recompute(doc_name: str | None = None) -> dict[str, Any]:
"""Force recompute of all objects in a document.
Args:
doc_name: Document to recompute. Uses active document if None.
Returns:
Dictionary with result:
- success: Whether recompute completed
- touch_count: Number of objects that were recomputed
"""
bridge = await get_bridge()
code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
_result_ = {{"success": False, "error": "No document found", "touch_count": 0}}
else:
# Touch all objects to force recompute
touch_count = 0
for obj in doc.Objects:
if hasattr(obj, "touch"):
obj.touch()
touch_count += 1
doc.recompute()
_result_ = {{"success": True, "touch_count": touch_count}}
"""
result = await bridge.execute_python(code)
if result.success and result.result:
return result.result
return {
"success": False,
"error": result.error_traceback or "Recompute failed",
"touch_count": 0,
}
+4
View File
@@ -0,0 +1,4 @@
"""Utility modules for FreeCAD MCP server.
This package contains shared utilities for serialization, validation, etc.
"""