feat: Initial commit of FreeCAD MCP/tooling proj.
This commit is contained in:
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()`
|
||||
"""
|
||||
@@ -0,0 +1,2 @@
|
||||
# PEP 561 marker file
|
||||
# This file indicates that this package supports type checking
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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", ""),
|
||||
},
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Utility modules for FreeCAD MCP server.
|
||||
|
||||
This package contains shared utilities for serialization, validation, etc.
|
||||
"""
|
||||
Reference in New Issue
Block a user