Skip to content

Bridge API Reference

The bridge module provides the communication layer between the Robust MCP Server and FreeCAD.

Base Classes

freecad_mcp.bridge.base

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 Robust 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

Classes

ConnectionStatus dataclass

Status of the FreeCAD connection.

Attributes:

Name Type Description
connected bool

Whether connection is established.

mode str

Connection mode (embedded, xmlrpc, socket).

freecad_version str

FreeCAD version string.

gui_available bool

Whether GUI is available.

last_ping_ms float

Last ping latency in milliseconds.

error str | None

Connection error message if any.

Source code in src/freecad_mcp/bridge/base.py
@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

DocumentInfo dataclass

Information about a FreeCAD document.

Attributes:

Name Type Description
name str

Internal document name (identifier).

label str

Display label (may differ from name).

path str | None

File path if saved, None otherwise.

objects list[str]

List of object names in the document.

is_modified bool

Whether document has unsaved changes.

active_object str | None

Name of the currently active object.

Source code in src/freecad_mcp/bridge/base.py
@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
Functions
__post_init__()

Set label to name if not provided.

Source code in src/freecad_mcp/bridge/base.py
def __post_init__(self) -> None:
    """Set label to name if not provided."""
    if not self.label:
        self.label = self.name

ExecutionResult dataclass

Result of Python code execution in FreeCAD.

Attributes:

Name Type Description
success bool

Whether execution completed without errors.

result Any

The value assigned to _result_ variable, or None.

stdout str

Captured standard output.

stderr str

Captured standard error.

execution_time_ms float

Time taken in milliseconds.

error_type str | None

Type of exception if failed, None otherwise.

error_traceback str | None

Full traceback if failed, None otherwise.

Source code in src/freecad_mcp/bridge/base.py
@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

FreecadBridge

Bases: ABC

Abstract base class for FreeCAD bridges.

A bridge provides communication between the Robust 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).

Source code in src/freecad_mcp/bridge/base.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
class FreecadBridge(ABC):
    """Abstract base class for FreeCAD bridges.

    A bridge provides communication between the Robust 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.
        """
Functions
activate_workbench(workbench_name) abstractmethod async

Activate a workbench.

Parameters:

Name Type Description Default
workbench_name str

Workbench internal name.

required

Raises:

Type Description
ValueError

If workbench not found.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
close_document(doc_name=None) abstractmethod async

Close a document.

Parameters:

Name Type Description Default
doc_name str | None

Document name (uses active if None).

None
Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def close_document(self, doc_name: str | None = None) -> None:
    """Close a document.

    Args:
        doc_name: Document name (uses active if None).
    """
connect() abstractmethod async

Establish connection to FreeCAD.

Raises:

Type Description
ConnectionError

If connection cannot be established.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def connect(self) -> None:
    """Establish connection to FreeCAD.

    Raises:
        ConnectionError: If connection cannot be established.
    """
create_document(name, label=None) abstractmethod async

Create a new document.

Parameters:

Name Type Description Default
name str

Internal document name (no spaces).

required
label str | None

Display label (optional, defaults to name).

None

Returns:

Type Description
DocumentInfo

DocumentInfo for the created document.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
create_macro(name, code, description='') abstractmethod async

Create a new macro.

Parameters:

Name Type Description Default
name str

Macro name (without extension).

required
code str

Python code for the macro.

required
description str

Macro description.

''

Returns:

Type Description
MacroInfo

MacroInfo for the created macro.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
create_object(type_id, name=None, properties=None, doc_name=None) abstractmethod async

Create a new object.

Parameters:

Name Type Description Default
type_id str

FreeCAD type ID (e.g., "Part::Box", "Part::Cylinder").

required
name str | None

Object name (auto-generated if None).

None
properties dict[str, Any] | None

Initial property values.

None
doc_name str | None

Target document (uses active if None).

None

Returns:

Type Description
ObjectInfo

ObjectInfo for the created object.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
delete_object(obj_name, doc_name=None) abstractmethod async

Delete an object.

Parameters:

Name Type Description Default
obj_name str

Name of the object to delete.

required
doc_name str | None

Document name (uses active if None).

None

Raises:

Type Description
ValueError

If object not found.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
disconnect() abstractmethod async

Close connection to FreeCAD.

Should be called during cleanup to release resources.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def disconnect(self) -> None:
    """Close connection to FreeCAD.

    Should be called during cleanup to release resources.
    """
edit_object(obj_name, properties, doc_name=None) abstractmethod async

Edit object properties.

Parameters:

Name Type Description Default
obj_name str

Name of the object to edit.

required
properties dict[str, Any]

Property values to set.

required
doc_name str | None

Document name (uses active if None).

None

Returns:

Type Description
ObjectInfo

Updated ObjectInfo.

Raises:

Type Description
ValueError

If object not found.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
execute_python(code, timeout_ms=30000) abstractmethod async

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.

Parameters:

Name Type Description Default
code str

Python code to execute.

required
timeout_ms int

Maximum execution time in milliseconds.

30000

Returns:

Type Description
ExecutionResult

ExecutionResult with success status, output, and any errors.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
get_active_document() abstractmethod async

Get the active document.

Returns:

Type Description
DocumentInfo | None

DocumentInfo for active document, or None if no document is active.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
get_console_output(lines=100) abstractmethod async

Get recent console output.

Parameters:

Name Type Description Default
lines int

Maximum number of lines to return.

100

Returns:

Type Description
list[str]

List of console output lines, most recent last.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
get_documents() abstractmethod async

Get list of open documents.

Returns:

Type Description
list[DocumentInfo]

List of DocumentInfo for each open document.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def get_documents(self) -> list[DocumentInfo]:
    """Get list of open documents.

    Returns:
        List of DocumentInfo for each open document.
    """
get_freecad_version() abstractmethod async

Get FreeCAD version information.

Returns:

Type Description
dict[str, Any]

Dictionary with version, build_date, python_version, gui_available.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def get_freecad_version(self) -> dict[str, Any]:
    """Get FreeCAD version information.

    Returns:
        Dictionary with version, build_date, python_version, gui_available.
    """
get_macros() abstractmethod async

Get list of available macros.

Returns:

Type Description
list[MacroInfo]

List of MacroInfo for each macro.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def get_macros(self) -> list[MacroInfo]:
    """Get list of available macros.

    Returns:
        List of MacroInfo for each macro.
    """
get_object(obj_name, doc_name=None) abstractmethod async

Get detailed object information.

Parameters:

Name Type Description Default
obj_name str

Name of the object.

required
doc_name str | None

Document name (uses active if None).

None

Returns:

Type Description
ObjectInfo

ObjectInfo with full object details.

Raises:

Type Description
ValueError

If object not found.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
get_objects(doc_name=None) abstractmethod async

Get all objects in a document.

Parameters:

Name Type Description Default
doc_name str | None

Document name (uses active if None).

None

Returns:

Type Description
list[ObjectInfo]

List of ObjectInfo for each object.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
get_screenshot(view_angle=None, width=800, height=600, doc_name=None) abstractmethod async

Capture a screenshot of the 3D view.

Parameters:

Name Type Description Default
view_angle ViewAngle | None

View angle to set before capture.

None
width int

Image width in pixels.

800
height int

Image height in pixels.

600
doc_name str | None

Document name (uses active if None).

None

Returns:

Type Description
ScreenshotResult

ScreenshotResult with image data or error.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
get_status() abstractmethod async

Get detailed connection status.

Returns:

Type Description
ConnectionStatus

ConnectionStatus with full status information.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def get_status(self) -> ConnectionStatus:
    """Get detailed connection status.

    Returns:
        ConnectionStatus with full status information.
    """
get_workbenches() abstractmethod async

Get list of available workbenches.

Returns:

Type Description
list[WorkbenchInfo]

List of WorkbenchInfo for each workbench.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def get_workbenches(self) -> list[WorkbenchInfo]:
    """Get list of available workbenches.

    Returns:
        List of WorkbenchInfo for each workbench.
    """
is_connected() abstractmethod async

Check if bridge is connected to FreeCAD.

Returns:

Type Description
bool

True if connected, False otherwise.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def is_connected(self) -> bool:
    """Check if bridge is connected to FreeCAD.

    Returns:
        True if connected, False otherwise.
    """
is_gui_available() abstractmethod async

Check if FreeCAD GUI is available.

Returns:

Type Description
bool

True if GUI is available, False for headless mode.

Source code in src/freecad_mcp/bridge/base.py
@abstractmethod
async def is_gui_available(self) -> bool:
    """Check if FreeCAD GUI is available.

    Returns:
        True if GUI is available, False for headless mode.
    """
open_document(path) abstractmethod async

Open an existing document.

Parameters:

Name Type Description Default
path str

Path to the .FCStd file.

required

Returns:

Type Description
DocumentInfo

DocumentInfo for the opened document.

Raises:

Type Description
FileNotFoundError

If file doesn't exist.

ValueError

If file is not a valid FreeCAD document.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
ping() abstractmethod async

Ping FreeCAD to check connection and measure latency.

Returns:

Type Description
float

Round-trip time in milliseconds.

Raises:

Type Description
ConnectionError

If not connected.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
run_macro(macro_name, args=None) abstractmethod async

Run a macro by name.

Parameters:

Name Type Description Default
macro_name str

Macro name (without .FCMacro extension).

required
args dict[str, Any] | None

Arguments to pass to the macro.

None

Returns:

Type Description
ExecutionResult

ExecutionResult from macro execution.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
save_document(doc_name=None, path=None) abstractmethod async

Save a document.

Parameters:

Name Type Description Default
doc_name str | None

Document name (uses active if None).

None
path str | None

Save path (uses existing path if None).

None

Returns:

Type Description
str

Path where document was saved.

Raises:

Type Description
ValueError

If document not found or no path specified for new doc.

Source code in src/freecad_mcp/bridge/base.py
@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.
    """
set_view(view_angle, doc_name=None) abstractmethod async

Set the 3D view angle.

Parameters:

Name Type Description Default
view_angle ViewAngle

View angle to set.

required
doc_name str | None

Document name (uses active if None).

None
Source code in src/freecad_mcp/bridge/base.py
@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).
    """

MacroInfo dataclass

Information about a FreeCAD macro.

Attributes:

Name Type Description
name str

Macro name (without extension).

path str

Full path to macro file.

description str

Macro description from comments.

is_system bool

Whether it's a system macro.

Source code in src/freecad_mcp/bridge/base.py
@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

ObjectInfo dataclass

Information about a FreeCAD object.

Attributes:

Name Type Description
name str

Object name (identifier).

label str

Display label.

type_id str

FreeCAD TypeId string (e.g., "Part::Box").

properties dict[str, Any]

Dictionary of property names to values.

shape_info dict[str, Any] | None

Shape geometry details if applicable.

children list[str]

List of child object names (OutList).

parents list[str]

List of parent object names (InList).

visibility bool

Whether object is visible in the view.

Source code in src/freecad_mcp/bridge/base.py
@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

ObjectType

Bases: str, Enum

FreeCAD object type categories.

Source code in src/freecad_mcp/bridge/base.py
class ObjectType(str, Enum):
    """FreeCAD object type categories."""

    PART = "Part"
    PART_DESIGN = "PartDesign"
    DRAFT = "Draft"
    SKETCHER = "Sketcher"
    FEM = "Fem"
    MESH = "Mesh"
    SPREADSHEET = "Spreadsheet"

ScreenshotResult dataclass

Result of a screenshot capture.

Attributes:

Name Type Description
success bool

Whether screenshot was captured successfully.

data str | None

Base64-encoded image data.

format str

Image format (png, jpg).

width int

Image width in pixels.

height int

Image height in pixels.

view_angle ViewAngle | None

The view angle used.

error str | None

Error message if failed.

Source code in src/freecad_mcp/bridge/base.py
@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

ShapeInfo dataclass

Detailed shape geometry information.

Attributes:

Name Type Description
shape_type str

Type of shape (Solid, Shell, Face, etc.).

volume float | None

Volume of the shape (for solids).

area float | None

Surface area of the shape.

center_of_mass tuple[float, float, float] | None

Center of mass coordinates.

bounding_box tuple[tuple[float, float, float], tuple[float, float, float]] | None

Bounding box as (min, max) tuples.

is_valid bool

Whether the shape is geometrically valid.

is_closed bool

Whether the shape is closed.

vertex_count int

Number of vertices.

edge_count int

Number of edges.

face_count int

Number of faces.

Source code in src/freecad_mcp/bridge/base.py
@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

ViewAngle

Bases: str, Enum

Standard view angles for screenshots.

Source code in src/freecad_mcp/bridge/base.py
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"

WorkbenchInfo dataclass

Information about a FreeCAD workbench.

Attributes:

Name Type Description
name str

Workbench internal name.

label str

Display label.

icon str

Icon resource path.

is_active bool

Whether workbench is currently active.

Source code in src/freecad_mcp/bridge/base.py
@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

options: show_root_heading: true show_source: true

XML-RPC Bridge

freecad_mcp.bridge.xmlrpc

XML-RPC bridge for FreeCAD GUI mode communication.

This bridge connects to a FreeCAD instance running an XML-RPC server, allowing remote control while FreeCAD has its GUI open.

Compatible with neka-nat/freecad-mcp XML-RPC protocol, providing interoperability with existing FreeCAD Robust MCP addons.

Design inspired by neka-nat/freecad-mcp (MIT License): - Uses neka-nat's proven XML-RPC protocol (port 9875) - Connection health monitoring and auto-reconnect - Timeout handling for long operations

Attribution

The XML-RPC protocol design and port selection (9875) 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 that maintains protocol compatibility.

Classes

XmlRpcBridge

Bases: FreecadBridge

Bridge that communicates with FreeCAD via XML-RPC.

This bridge connects to a FreeCAD instance that is running the XML-RPC server addon. It provides full access to FreeCAD functionality including GUI operations like screenshots.

Attributes:

Name Type Description
host

XML-RPC server hostname.

port

XML-RPC server port.

timeout

Connection and request timeout in seconds.

Source code in src/freecad_mcp/bridge/xmlrpc.py
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
class XmlRpcBridge(FreecadBridge):
    """Bridge that communicates with FreeCAD via XML-RPC.

    This bridge connects to a FreeCAD instance that is running the
    XML-RPC server addon. It provides full access to FreeCAD functionality
    including GUI operations like screenshots.

    Attributes:
        host: XML-RPC server hostname.
        port: XML-RPC server port.
        timeout: Connection and request timeout in seconds.
    """

    def __init__(
        self,
        host: str = DEFAULT_XMLRPC_HOST,
        port: int = DEFAULT_XMLRPC_PORT,
        timeout: float = DEFAULT_TIMEOUT,
    ) -> None:
        """Initialize the XML-RPC bridge.

        Args:
            host: XML-RPC server hostname.
            port: XML-RPC server port.
            timeout: Connection and request timeout in seconds.
        """
        self._host = host
        self._port = port
        self._timeout = timeout
        self._proxy: xmlrpc.client.ServerProxy | None = None
        self._connected = False

    @property
    def _server_url(self) -> str:
        """Get the XML-RPC server URL."""
        return f"http://{self._host}:{self._port}"

    async def connect(self) -> None:
        """Establish connection to FreeCAD XML-RPC server.

        Raises:
            ConnectionError: If connection cannot be established.
        """
        loop = asyncio.get_event_loop()
        try:
            self._proxy = await loop.run_in_executor(
                None,
                lambda: xmlrpc.client.ServerProxy(
                    self._server_url,
                    allow_none=True,
                ),
            )
            # Test connection with a ping
            await self.ping()
            self._connected = True
        except ConnectionRefusedError as e:
            self._connected = False
            msg = self._get_connection_refused_message()
            raise ConnectionError(msg) from e
        except Exception as e:
            self._connected = False
            error_str = str(e)
            if "Connection refused" in error_str or "Errno 61" in error_str:
                msg = self._get_connection_refused_message()
            else:
                msg = f"Failed to connect to XML-RPC server at {self._server_url}: {e}"
            raise ConnectionError(msg) from e

    def _get_connection_refused_message(self) -> str:
        """Get a helpful error message when connection is refused."""
        return f"""
================================================================================
CONNECTION REFUSED: Cannot connect to FreeCAD at {self._server_url}

The FreeCAD Robust MCP Bridge server is not running. To fix this:

1. Start FreeCAD (the GUI application)

2. Start the MCP bridge using one of these methods:

   Option A: Using the Robust MCP Bridge Workbench (recommended)
   - Install via FreeCAD Addon Manager: Tools → Addon Manager
   - Search for "Robust MCP Bridge" and install
   - Switch to the Robust MCP Bridge workbench
   - Click "Start MCP Bridge" in the toolbar

   Option B: From source (for developers)
   - Run: just freecad::run-gui

3. You should see: "MCP Bridge started!"
   - XML-RPC: localhost:{self._port}
   - Socket: localhost:9876

4. Then restart your MCP client (e.g., restart Claude Code)
================================================================================
"""

    async def disconnect(self) -> None:
        """Close connection to FreeCAD XML-RPC server."""
        self._proxy = None
        self._connected = False

    async def is_connected(self) -> bool:
        """Check if bridge is connected to FreeCAD."""
        if not self._connected or self._proxy is None:
            return False

        try:
            await self.ping()
            return True
        except Exception:
            self._connected = False
            return False

    async def ping(self) -> float:
        """Ping FreeCAD to check connection and measure latency.

        Returns:
            Round-trip time in milliseconds.

        Raises:
            ConnectionError: If not connected.
        """
        if self._proxy is None:
            msg = "Not connected to XML-RPC server"
            raise ConnectionError(msg)

        loop = asyncio.get_event_loop()
        start = time.perf_counter()

        try:
            # Try standard system.listMethods or a simple execute
            if self._proxy is None:
                msg = "Not connected"
                raise ConnectionError(msg)
            proxy = self._proxy  # Local reference for lambda
            await asyncio.wait_for(
                loop.run_in_executor(
                    None,
                    lambda: proxy.execute("_result_ = True"),
                ),
                timeout=self._timeout,
            )
        except TimeoutError as e:
            msg = "Ping timed out"
            raise ConnectionError(msg) from e
        except Exception as e:
            msg = f"Ping failed: {e}"
            raise ConnectionError(msg) from e

        return (time.perf_counter() - start) * 1000

    async def get_status(self) -> ConnectionStatus:
        """Get detailed connection status.

        Returns:
            ConnectionStatus with full status information.
        """
        if not self._connected or self._proxy is None:
            return ConnectionStatus(
                connected=False,
                mode="xmlrpc",
                error="Not connected",
            )

        try:
            ping_ms = await self.ping()
            version_info = await self.get_freecad_version()
            gui_available = await self.is_gui_available()

            return ConnectionStatus(
                connected=True,
                mode="xmlrpc",
                freecad_version=version_info.get("version", "unknown"),
                gui_available=gui_available,
                last_ping_ms=ping_ms,
            )
        except Exception as e:
            return ConnectionStatus(
                connected=False,
                mode="xmlrpc",
                error=str(e),
            )

    # =========================================================================
    # Code Execution
    # =========================================================================

    async def execute_python(
        self,
        code: str,
        timeout_ms: int = 30000,
    ) -> ExecutionResult:
        """Execute Python code in FreeCAD context via XML-RPC.

        Args:
            code: Python code to execute.
            timeout_ms: Maximum execution time in milliseconds.

        Returns:
            ExecutionResult with execution outcome.
        """
        if self._proxy is None:
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr="Not connected to XML-RPC server",
                execution_time_ms=0,
                error_type="ConnectionError",
            )

        loop = asyncio.get_event_loop()
        start = time.perf_counter()
        proxy = self._proxy  # Local reference for lambda

        try:
            result = await asyncio.wait_for(
                loop.run_in_executor(
                    None,
                    lambda: proxy.execute(code),
                ),
                timeout=timeout_ms / 1000,
            )
            elapsed = (time.perf_counter() - start) * 1000

            # Parse result from XML-RPC server
            if isinstance(result, dict):
                return ExecutionResult(
                    success=result.get("success", False),
                    result=result.get("result"),
                    stdout=result.get("stdout", ""),
                    stderr=result.get("stderr", ""),
                    execution_time_ms=elapsed,
                    error_type=result.get("error_type"),
                    error_traceback=result.get("error_traceback"),
                )
            else:
                # Simple result format
                return ExecutionResult(
                    success=True,
                    result=result,
                    stdout="",
                    stderr="",
                    execution_time_ms=elapsed,
                )

        except TimeoutError:
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=f"Execution timed out after {timeout_ms}ms",
                execution_time_ms=float(timeout_ms),
                error_type="TimeoutError",
            )
        except Exception as e:
            elapsed = (time.perf_counter() - start) * 1000
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=str(e),
                execution_time_ms=elapsed,
                error_type=type(e).__name__,
            )

    # =========================================================================
    # Document Management
    # =========================================================================

    async def get_documents(self) -> list[DocumentInfo]:
        """Get list of open documents."""
        result = await self.execute_python(
            """
_result_ = []
for doc in FreeCAD.listDocuments().values():
    _result_.append({
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    })
"""
        )

        if result.success and result.result:
            return [DocumentInfo(**doc) for doc in result.result]
        return []

    async def get_active_document(self) -> DocumentInfo | None:
        """Get the active document."""
        result = await self.execute_python(
            """
doc = FreeCAD.ActiveDocument
if doc:
    _result_ = {
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    }
else:
    _result_ = None
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)
        return None

    async def create_document(
        self, name: str, label: str | None = None
    ) -> DocumentInfo:
        """Create a new document."""
        label = label or name
        result = await self.execute_python(
            f"""
doc = FreeCAD.newDocument({name!r})
doc.Label = {label!r}
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [],
    "is_modified": False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create document"
        raise ValueError(error_msg)

    async def open_document(self, path: str) -> DocumentInfo:
        """Open an existing document."""
        result = await self.execute_python(
            f"""
import os
if not os.path.exists({path!r}):
    raise FileNotFoundError(f"File not found: {path!r}")

doc = FreeCAD.openDocument({path!r})
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [obj.Name for obj in doc.Objects],
    "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        if "FileNotFoundError" in (result.error_type or ""):
            raise FileNotFoundError(result.stderr)

        error_msg = result.error_traceback or "Failed to open document"
        raise ValueError(error_msg)

    async def save_document(
        self,
        doc_name: str | None = None,
        path: str | None = None,
    ) -> str:
        """Save a document."""
        code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}")

save_path = {path!r} or doc.FileName
if not save_path:
    raise ValueError("No path specified for new document")

doc.saveAs(save_path)
_result_ = save_path
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        error_msg = result.error_traceback or "Failed to save document"
        raise ValueError(error_msg)

    async def close_document(self, doc_name: str | None = None) -> None:
        """Close a document."""
        code = f"""
doc_name = {doc_name!r}
if doc_name is None:
    doc = FreeCAD.ActiveDocument
    if doc:
        doc_name = doc.Name
    else:
        raise ValueError("No active document")

FreeCAD.closeDocument(doc_name)
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to close document"
            raise ValueError(error_msg)

    # =========================================================================
    # Object Management
    # =========================================================================

    async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]:
        """Get all objects in a document."""
        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")

objects = []
for obj in doc.Objects:
    obj_info = {{
        "name": obj.Name,
        "label": obj.Label,
        "type_id": obj.TypeId,
        "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
        "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
        "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    }}
    objects.append(obj_info)

_result_ = objects
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [ObjectInfo(**obj) for obj in result.result]
        return []

    async def get_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Get detailed object information."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

props = {{}}
for prop in obj.PropertiesList:
    try:
        val = getattr(obj, prop)
        if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins':
            val = str(val)
        props[prop] = val
    except Exception:
        props[prop] = "<unreadable>"

shape_info = None
if hasattr(obj, "Shape"):
    shape = obj.Shape
    shape_info = {{
        "shape_type": shape.ShapeType,
        "volume": shape.Volume if hasattr(shape, "Volume") else None,
        "area": shape.Area if hasattr(shape, "Area") else None,
        "is_valid": shape.isValid(),
        "is_closed": shape.isClosed() if hasattr(shape, "isClosed") else False,
        "vertex_count": len(shape.Vertexes) if hasattr(shape, "Vertexes") else 0,
        "edge_count": len(shape.Edges) if hasattr(shape, "Edges") else 0,
        "face_count": len(shape.Faces) if hasattr(shape, "Faces") else 0,
    }}

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "properties": props,
    "shape_info": shape_info,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to get object"
        raise ValueError(error_msg)

    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."""
        properties = properties or {}
        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")

# Wrap in transaction for undo support
doc.openTransaction("Create Object")
try:
    obj = doc.addObject({type_id!r}, {name!r} or "")

    # Set properties
    for prop_name, prop_val in {properties!r}.items():
        if hasattr(obj, prop_name):
            setattr(obj, prop_name, prop_val)

    doc.recompute()
    doc.commitTransaction()
except Exception:
    doc.abortTransaction()
    raise

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create object"
        raise ValueError(error_msg)

    async def edit_object(
        self,
        obj_name: str,
        properties: dict[str, Any],
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Edit object properties."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Wrap in transaction for undo support
doc.openTransaction("Edit Object")
try:
    # Set properties
    for prop_name, prop_val in {properties!r}.items():
        if hasattr(obj, prop_name):
            setattr(obj, prop_name, prop_val)

    doc.recompute()
    doc.commitTransaction()
except Exception:
    doc.abortTransaction()
    raise

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to edit object"
        raise ValueError(error_msg)

    async def delete_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> None:
        """Delete an object."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Wrap in transaction for undo support
doc.openTransaction("Delete Object")
try:
    doc.removeObject({obj_name!r})
    doc.commitTransaction()
except Exception:
    doc.abortTransaction()
    raise

_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to delete object"
            raise ValueError(error_msg)

    # =========================================================================
    # View and Screenshot
    # =========================================================================

    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 via XML-RPC."""
        view_angle_str = view_angle.value if view_angle else "Isometric"
        code = f"""
import base64
import tempfile
import os

doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No document found")

# Check if GUI is available
if not FreeCAD.GuiUp:
    raise RuntimeError("GUI not available")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

# Check if this is a 3D view (not TechDraw, Spreadsheet, etc.)
# Note: Use type() instead of __class__ because FreeCAD's View3DInventor
# has a broken __class__ attribute that returns a dict of methods.
view_type = type(view).__name__
if view_type not in ["View3DInventor", "View3DInventorPy"]:
    raise ValueError(f"Cannot capture screenshot from {{view_type}} view")

# Set view angle
view_type_str = {view_angle_str!r}
if view_type_str == "FitAll":
    view.fitAll()
elif view_type_str == "Isometric":
    view.viewIsometric()
elif view_type_str == "Front":
    view.viewFront()
elif view_type_str == "Back":
    view.viewRear()
elif view_type_str == "Top":
    view.viewTop()
elif view_type_str == "Bottom":
    view.viewBottom()
elif view_type_str == "Left":
    view.viewLeft()
elif view_type_str == "Right":
    view.viewRight()

# Save to temp file and read
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 = await self.execute_python(code)

        if result.success and result.result:
            return ScreenshotResult(
                success=True,
                data=result.result["data"],
                format=result.result["format"],
                width=result.result["width"],
                height=result.result["height"],
                view_angle=view_angle,
            )

        return ScreenshotResult(
            success=False,
            error=result.error_traceback
            or result.stderr
            or "Failed to capture screenshot",
            width=width,
            height=height,
            view_angle=view_angle,
        )

    async def set_view(
        self,
        view_angle: ViewAngle,
        doc_name: str | None = None,
    ) -> None:
        """Set the 3D view angle."""
        view_angle_str = view_angle.value
        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")

if not FreeCAD.GuiUp:
    _result_ = True  # Silently succeed in headless mode
else:
    view = FreeCADGui.ActiveDocument.ActiveView
    if view is None:
        raise ValueError("No active view")

    view_type = {view_angle_str!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()

    _result_ = True
"""
        await self.execute_python(code)

    # =========================================================================
    # Macros
    # =========================================================================

    async def get_macros(self) -> list[MacroInfo]:
        """Get list of available macros."""
        code = """
import os

# Get macro paths
macro_paths = []

# User macro path
user_path = FreeCAD.getUserMacroDir(True)
if os.path.exists(user_path):
    macro_paths.append(("user", user_path))

# System macro path
system_path = FreeCAD.getResourceDir() + "Macro"
if os.path.exists(system_path):
    macro_paths.append(("system", system_path))

macros = []
for source, path in macro_paths:
    for filename in os.listdir(path):
        if filename.endswith(".FCMacro"):
            macro_file = os.path.join(path, filename)
            description = ""
            try:
                with open(macro_file, "r") as f:
                    for line in f:
                        if line.startswith("#"):
                            desc = line.lstrip("#").strip()
                            if desc and not desc.startswith("!") and not desc.startswith("-*-"):
                                description = desc
                                break
            except Exception:
                pass

            macros.append({
                "name": filename[:-8],  # Remove .FCMacro
                "path": macro_file,
                "description": description,
                "is_system": source == "system",
            })

_result_ = macros
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [MacroInfo(**m) for m in result.result]
        return []

    async def run_macro(
        self,
        macro_name: str,
        args: dict[str, Any] | None = None,
    ) -> ExecutionResult:
        """Run a macro by name."""
        args = args or {}
        code = f"""
import os

# Find macro file
macro_name = {macro_name!r}
macro_file = None

# Check user macros first
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

# Check system macros
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}}")

# Set up arguments
args = {args!r}
for k, v in args.items():
    exec(f"{{k}} = {{v!r}}")

# Execute macro
with open(macro_file, "r") as f:
    macro_code = f.read()

exec(macro_code)
_result_ = True
"""
        return await self.execute_python(code)

    async def create_macro(
        self,
        name: str,
        code: str,
        description: str = "",
    ) -> MacroInfo:
        """Create a new macro."""
        create_code = f"""
import os

macro_path = FreeCAD.getUserMacroDir(True)
os.makedirs(macro_path, exist_ok=True)

macro_file = os.path.join(macro_path, {name!r} + ".FCMacro")

header = ""
if {description!r}:
    header = f"# {description!r}\\n\\n"

full_code = header + '''# -*- coding: utf-8 -*-
# FreeCAD Macro: {name}
# Created via MCP Bridge

import FreeCAD
import FreeCADGui

''' + {code!r}

with open(macro_file, "w") as f:
    f.write(full_code)

_result_ = {{
    "name": {name!r},
    "path": macro_file,
    "description": {description!r},
    "is_system": False,
}}
"""
        result = await self.execute_python(create_code)

        if result.success and result.result:
            return MacroInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create macro"
        raise ValueError(error_msg)

    # =========================================================================
    # Workbenches
    # =========================================================================

    async def get_workbenches(self) -> list[WorkbenchInfo]:
        """Get list of available workbenches."""
        code = """
workbenches = []
active_wb = FreeCADGui.activeWorkbench() if FreeCAD.GuiUp else None
active_name = active_wb.__class__.__name__ if active_wb else None

if FreeCAD.GuiUp:
    for name in FreeCADGui.listWorkbenches():
        wb = FreeCADGui.getWorkbench(name)
        workbenches.append({
            "name": name,
            "label": wb.MenuText if hasattr(wb, "MenuText") else name,
            "icon": wb.Icon if hasattr(wb, "Icon") else "",
            "is_active": name == active_name,
        })
else:
    # Return common workbenches for headless mode
    common = ["StartWorkbench", "PartWorkbench", "PartDesignWorkbench",
              "DraftWorkbench", "SketcherWorkbench", "MeshWorkbench"]
    for name in common:
        workbenches.append({
            "name": name,
            "label": name.replace("Workbench", ""),
            "icon": "",
            "is_active": False,
        })

_result_ = workbenches
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [WorkbenchInfo(**wb) for wb in result.result]
        return []

    async def activate_workbench(self, workbench_name: str) -> None:
        """Activate a workbench."""
        code = f"""
if FreeCAD.GuiUp:
    try:
        FreeCADGui.activateWorkbench({workbench_name!r})
        _result_ = True
    except Exception as e:
        raise ValueError(f"Failed to activate workbench: {{e}}")
else:
    _result_ = True  # Silently succeed in headless mode
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to activate workbench"
            raise ValueError(error_msg)

    # =========================================================================
    # Version and Environment
    # =========================================================================

    async def get_freecad_version(self) -> dict[str, Any]:
        """Get FreeCAD version information."""
        code = """
import sys
_result_ = {
    "version": ".".join(str(x) for x in FreeCAD.Version()[:3]),
    "version_tuple": FreeCAD.Version()[:3],
    "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown",
    "python_version": sys.version,
    "gui_available": FreeCAD.GuiUp,
}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        return {
            "version": "unknown",
            "version_tuple": [],
            "build_date": "unknown",
            "python_version": "",
            "gui_available": False,
        }

    async def is_gui_available(self) -> bool:
        """Check if FreeCAD GUI is available."""
        result = await self.execute_python("_result_ = FreeCAD.GuiUp")
        return bool(result.success and result.result)

    # =========================================================================
    # Console
    # =========================================================================

    async def get_console_output(self, lines: int = 100) -> list[str]:
        """Get recent console output."""
        code = f"""
# Try to get console output from FreeCAD
output_lines = []

# Try FreeCAD.Console if available
if hasattr(FreeCAD, 'Console'):
    console = FreeCAD.Console
    if hasattr(console, 'GetLog'):
        log = console.GetLog()
        if log:
            output_lines = log.split('\\n')[-{lines}:]

_result_ = output_lines
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result
        return []
Functions
__init__(host=DEFAULT_XMLRPC_HOST, port=DEFAULT_XMLRPC_PORT, timeout=DEFAULT_TIMEOUT)

Initialize the XML-RPC bridge.

Parameters:

Name Type Description Default
host str

XML-RPC server hostname.

DEFAULT_XMLRPC_HOST
port int

XML-RPC server port.

DEFAULT_XMLRPC_PORT
timeout float

Connection and request timeout in seconds.

DEFAULT_TIMEOUT
Source code in src/freecad_mcp/bridge/xmlrpc.py
def __init__(
    self,
    host: str = DEFAULT_XMLRPC_HOST,
    port: int = DEFAULT_XMLRPC_PORT,
    timeout: float = DEFAULT_TIMEOUT,
) -> None:
    """Initialize the XML-RPC bridge.

    Args:
        host: XML-RPC server hostname.
        port: XML-RPC server port.
        timeout: Connection and request timeout in seconds.
    """
    self._host = host
    self._port = port
    self._timeout = timeout
    self._proxy: xmlrpc.client.ServerProxy | None = None
    self._connected = False
activate_workbench(workbench_name) async

Activate a workbench.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def activate_workbench(self, workbench_name: str) -> None:
        """Activate a workbench."""
        code = f"""
if FreeCAD.GuiUp:
    try:
        FreeCADGui.activateWorkbench({workbench_name!r})
        _result_ = True
    except Exception as e:
        raise ValueError(f"Failed to activate workbench: {{e}}")
else:
    _result_ = True  # Silently succeed in headless mode
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to activate workbench"
            raise ValueError(error_msg)
close_document(doc_name=None) async

Close a document.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def close_document(self, doc_name: str | None = None) -> None:
        """Close a document."""
        code = f"""
doc_name = {doc_name!r}
if doc_name is None:
    doc = FreeCAD.ActiveDocument
    if doc:
        doc_name = doc.Name
    else:
        raise ValueError("No active document")

FreeCAD.closeDocument(doc_name)
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to close document"
            raise ValueError(error_msg)
connect() async

Establish connection to FreeCAD XML-RPC server.

Raises:

Type Description
ConnectionError

If connection cannot be established.

Source code in src/freecad_mcp/bridge/xmlrpc.py
async def connect(self) -> None:
    """Establish connection to FreeCAD XML-RPC server.

    Raises:
        ConnectionError: If connection cannot be established.
    """
    loop = asyncio.get_event_loop()
    try:
        self._proxy = await loop.run_in_executor(
            None,
            lambda: xmlrpc.client.ServerProxy(
                self._server_url,
                allow_none=True,
            ),
        )
        # Test connection with a ping
        await self.ping()
        self._connected = True
    except ConnectionRefusedError as e:
        self._connected = False
        msg = self._get_connection_refused_message()
        raise ConnectionError(msg) from e
    except Exception as e:
        self._connected = False
        error_str = str(e)
        if "Connection refused" in error_str or "Errno 61" in error_str:
            msg = self._get_connection_refused_message()
        else:
            msg = f"Failed to connect to XML-RPC server at {self._server_url}: {e}"
        raise ConnectionError(msg) from e
create_document(name, label=None) async

Create a new document.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def create_document(
        self, name: str, label: str | None = None
    ) -> DocumentInfo:
        """Create a new document."""
        label = label or name
        result = await self.execute_python(
            f"""
doc = FreeCAD.newDocument({name!r})
doc.Label = {label!r}
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [],
    "is_modified": False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create document"
        raise ValueError(error_msg)
create_macro(name, code, description='') async

Create a new macro.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def create_macro(
        self,
        name: str,
        code: str,
        description: str = "",
    ) -> MacroInfo:
        """Create a new macro."""
        create_code = f"""
import os

macro_path = FreeCAD.getUserMacroDir(True)
os.makedirs(macro_path, exist_ok=True)

macro_file = os.path.join(macro_path, {name!r} + ".FCMacro")

header = ""
if {description!r}:
    header = f"# {description!r}\\n\\n"

full_code = header + '''# -*- coding: utf-8 -*-
# FreeCAD Macro: {name}
# Created via MCP Bridge

import FreeCAD
import FreeCADGui

''' + {code!r}

with open(macro_file, "w") as f:
    f.write(full_code)

_result_ = {{
    "name": {name!r},
    "path": macro_file,
    "description": {description!r},
    "is_system": False,
}}
"""
        result = await self.execute_python(create_code)

        if result.success and result.result:
            return MacroInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create macro"
        raise ValueError(error_msg)
create_object(type_id, name=None, properties=None, doc_name=None) async

Create a new object.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    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."""
        properties = properties or {}
        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")

# Wrap in transaction for undo support
doc.openTransaction("Create Object")
try:
    obj = doc.addObject({type_id!r}, {name!r} or "")

    # Set properties
    for prop_name, prop_val in {properties!r}.items():
        if hasattr(obj, prop_name):
            setattr(obj, prop_name, prop_val)

    doc.recompute()
    doc.commitTransaction()
except Exception:
    doc.abortTransaction()
    raise

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create object"
        raise ValueError(error_msg)
delete_object(obj_name, doc_name=None) async

Delete an object.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def delete_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> None:
        """Delete an object."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Wrap in transaction for undo support
doc.openTransaction("Delete Object")
try:
    doc.removeObject({obj_name!r})
    doc.commitTransaction()
except Exception:
    doc.abortTransaction()
    raise

_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to delete object"
            raise ValueError(error_msg)
disconnect() async

Close connection to FreeCAD XML-RPC server.

Source code in src/freecad_mcp/bridge/xmlrpc.py
async def disconnect(self) -> None:
    """Close connection to FreeCAD XML-RPC server."""
    self._proxy = None
    self._connected = False
edit_object(obj_name, properties, doc_name=None) async

Edit object properties.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def edit_object(
        self,
        obj_name: str,
        properties: dict[str, Any],
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Edit object properties."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Wrap in transaction for undo support
doc.openTransaction("Edit Object")
try:
    # Set properties
    for prop_name, prop_val in {properties!r}.items():
        if hasattr(obj, prop_name):
            setattr(obj, prop_name, prop_val)

    doc.recompute()
    doc.commitTransaction()
except Exception:
    doc.abortTransaction()
    raise

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to edit object"
        raise ValueError(error_msg)
execute_python(code, timeout_ms=30000) async

Execute Python code in FreeCAD context via XML-RPC.

Parameters:

Name Type Description Default
code str

Python code to execute.

required
timeout_ms int

Maximum execution time in milliseconds.

30000

Returns:

Type Description
ExecutionResult

ExecutionResult with execution outcome.

Source code in src/freecad_mcp/bridge/xmlrpc.py
async def execute_python(
    self,
    code: str,
    timeout_ms: int = 30000,
) -> ExecutionResult:
    """Execute Python code in FreeCAD context via XML-RPC.

    Args:
        code: Python code to execute.
        timeout_ms: Maximum execution time in milliseconds.

    Returns:
        ExecutionResult with execution outcome.
    """
    if self._proxy is None:
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr="Not connected to XML-RPC server",
            execution_time_ms=0,
            error_type="ConnectionError",
        )

    loop = asyncio.get_event_loop()
    start = time.perf_counter()
    proxy = self._proxy  # Local reference for lambda

    try:
        result = await asyncio.wait_for(
            loop.run_in_executor(
                None,
                lambda: proxy.execute(code),
            ),
            timeout=timeout_ms / 1000,
        )
        elapsed = (time.perf_counter() - start) * 1000

        # Parse result from XML-RPC server
        if isinstance(result, dict):
            return ExecutionResult(
                success=result.get("success", False),
                result=result.get("result"),
                stdout=result.get("stdout", ""),
                stderr=result.get("stderr", ""),
                execution_time_ms=elapsed,
                error_type=result.get("error_type"),
                error_traceback=result.get("error_traceback"),
            )
        else:
            # Simple result format
            return ExecutionResult(
                success=True,
                result=result,
                stdout="",
                stderr="",
                execution_time_ms=elapsed,
            )

    except TimeoutError:
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=f"Execution timed out after {timeout_ms}ms",
            execution_time_ms=float(timeout_ms),
            error_type="TimeoutError",
        )
    except Exception as e:
        elapsed = (time.perf_counter() - start) * 1000
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=str(e),
            execution_time_ms=elapsed,
            error_type=type(e).__name__,
        )
get_active_document() async

Get the active document.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_active_document(self) -> DocumentInfo | None:
        """Get the active document."""
        result = await self.execute_python(
            """
doc = FreeCAD.ActiveDocument
if doc:
    _result_ = {
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    }
else:
    _result_ = None
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)
        return None
get_console_output(lines=100) async

Get recent console output.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_console_output(self, lines: int = 100) -> list[str]:
        """Get recent console output."""
        code = f"""
# Try to get console output from FreeCAD
output_lines = []

# Try FreeCAD.Console if available
if hasattr(FreeCAD, 'Console'):
    console = FreeCAD.Console
    if hasattr(console, 'GetLog'):
        log = console.GetLog()
        if log:
            output_lines = log.split('\\n')[-{lines}:]

_result_ = output_lines
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result
        return []
get_documents() async

Get list of open documents.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_documents(self) -> list[DocumentInfo]:
        """Get list of open documents."""
        result = await self.execute_python(
            """
_result_ = []
for doc in FreeCAD.listDocuments().values():
    _result_.append({
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    })
"""
        )

        if result.success and result.result:
            return [DocumentInfo(**doc) for doc in result.result]
        return []
get_freecad_version() async

Get FreeCAD version information.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_freecad_version(self) -> dict[str, Any]:
        """Get FreeCAD version information."""
        code = """
import sys
_result_ = {
    "version": ".".join(str(x) for x in FreeCAD.Version()[:3]),
    "version_tuple": FreeCAD.Version()[:3],
    "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown",
    "python_version": sys.version,
    "gui_available": FreeCAD.GuiUp,
}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        return {
            "version": "unknown",
            "version_tuple": [],
            "build_date": "unknown",
            "python_version": "",
            "gui_available": False,
        }
get_macros() async

Get list of available macros.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_macros(self) -> list[MacroInfo]:
        """Get list of available macros."""
        code = """
import os

# Get macro paths
macro_paths = []

# User macro path
user_path = FreeCAD.getUserMacroDir(True)
if os.path.exists(user_path):
    macro_paths.append(("user", user_path))

# System macro path
system_path = FreeCAD.getResourceDir() + "Macro"
if os.path.exists(system_path):
    macro_paths.append(("system", system_path))

macros = []
for source, path in macro_paths:
    for filename in os.listdir(path):
        if filename.endswith(".FCMacro"):
            macro_file = os.path.join(path, filename)
            description = ""
            try:
                with open(macro_file, "r") as f:
                    for line in f:
                        if line.startswith("#"):
                            desc = line.lstrip("#").strip()
                            if desc and not desc.startswith("!") and not desc.startswith("-*-"):
                                description = desc
                                break
            except Exception:
                pass

            macros.append({
                "name": filename[:-8],  # Remove .FCMacro
                "path": macro_file,
                "description": description,
                "is_system": source == "system",
            })

_result_ = macros
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [MacroInfo(**m) for m in result.result]
        return []
get_object(obj_name, doc_name=None) async

Get detailed object information.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Get detailed object information."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

props = {{}}
for prop in obj.PropertiesList:
    try:
        val = getattr(obj, prop)
        if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins':
            val = str(val)
        props[prop] = val
    except Exception:
        props[prop] = "<unreadable>"

shape_info = None
if hasattr(obj, "Shape"):
    shape = obj.Shape
    shape_info = {{
        "shape_type": shape.ShapeType,
        "volume": shape.Volume if hasattr(shape, "Volume") else None,
        "area": shape.Area if hasattr(shape, "Area") else None,
        "is_valid": shape.isValid(),
        "is_closed": shape.isClosed() if hasattr(shape, "isClosed") else False,
        "vertex_count": len(shape.Vertexes) if hasattr(shape, "Vertexes") else 0,
        "edge_count": len(shape.Edges) if hasattr(shape, "Edges") else 0,
        "face_count": len(shape.Faces) if hasattr(shape, "Faces") else 0,
    }}

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "properties": props,
    "shape_info": shape_info,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to get object"
        raise ValueError(error_msg)
get_objects(doc_name=None) async

Get all objects in a document.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]:
        """Get all objects in a document."""
        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")

objects = []
for obj in doc.Objects:
    obj_info = {{
        "name": obj.Name,
        "label": obj.Label,
        "type_id": obj.TypeId,
        "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
        "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
        "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    }}
    objects.append(obj_info)

_result_ = objects
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [ObjectInfo(**obj) for obj in result.result]
        return []
get_screenshot(view_angle=None, width=800, height=600, doc_name=None) async

Capture a screenshot of the 3D view via XML-RPC.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    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 via XML-RPC."""
        view_angle_str = view_angle.value if view_angle else "Isometric"
        code = f"""
import base64
import tempfile
import os

doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No document found")

# Check if GUI is available
if not FreeCAD.GuiUp:
    raise RuntimeError("GUI not available")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

# Check if this is a 3D view (not TechDraw, Spreadsheet, etc.)
# Note: Use type() instead of __class__ because FreeCAD's View3DInventor
# has a broken __class__ attribute that returns a dict of methods.
view_type = type(view).__name__
if view_type not in ["View3DInventor", "View3DInventorPy"]:
    raise ValueError(f"Cannot capture screenshot from {{view_type}} view")

# Set view angle
view_type_str = {view_angle_str!r}
if view_type_str == "FitAll":
    view.fitAll()
elif view_type_str == "Isometric":
    view.viewIsometric()
elif view_type_str == "Front":
    view.viewFront()
elif view_type_str == "Back":
    view.viewRear()
elif view_type_str == "Top":
    view.viewTop()
elif view_type_str == "Bottom":
    view.viewBottom()
elif view_type_str == "Left":
    view.viewLeft()
elif view_type_str == "Right":
    view.viewRight()

# Save to temp file and read
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 = await self.execute_python(code)

        if result.success and result.result:
            return ScreenshotResult(
                success=True,
                data=result.result["data"],
                format=result.result["format"],
                width=result.result["width"],
                height=result.result["height"],
                view_angle=view_angle,
            )

        return ScreenshotResult(
            success=False,
            error=result.error_traceback
            or result.stderr
            or "Failed to capture screenshot",
            width=width,
            height=height,
            view_angle=view_angle,
        )
get_status() async

Get detailed connection status.

Returns:

Type Description
ConnectionStatus

ConnectionStatus with full status information.

Source code in src/freecad_mcp/bridge/xmlrpc.py
async def get_status(self) -> ConnectionStatus:
    """Get detailed connection status.

    Returns:
        ConnectionStatus with full status information.
    """
    if not self._connected or self._proxy is None:
        return ConnectionStatus(
            connected=False,
            mode="xmlrpc",
            error="Not connected",
        )

    try:
        ping_ms = await self.ping()
        version_info = await self.get_freecad_version()
        gui_available = await self.is_gui_available()

        return ConnectionStatus(
            connected=True,
            mode="xmlrpc",
            freecad_version=version_info.get("version", "unknown"),
            gui_available=gui_available,
            last_ping_ms=ping_ms,
        )
    except Exception as e:
        return ConnectionStatus(
            connected=False,
            mode="xmlrpc",
            error=str(e),
        )
get_workbenches() async

Get list of available workbenches.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def get_workbenches(self) -> list[WorkbenchInfo]:
        """Get list of available workbenches."""
        code = """
workbenches = []
active_wb = FreeCADGui.activeWorkbench() if FreeCAD.GuiUp else None
active_name = active_wb.__class__.__name__ if active_wb else None

if FreeCAD.GuiUp:
    for name in FreeCADGui.listWorkbenches():
        wb = FreeCADGui.getWorkbench(name)
        workbenches.append({
            "name": name,
            "label": wb.MenuText if hasattr(wb, "MenuText") else name,
            "icon": wb.Icon if hasattr(wb, "Icon") else "",
            "is_active": name == active_name,
        })
else:
    # Return common workbenches for headless mode
    common = ["StartWorkbench", "PartWorkbench", "PartDesignWorkbench",
              "DraftWorkbench", "SketcherWorkbench", "MeshWorkbench"]
    for name in common:
        workbenches.append({
            "name": name,
            "label": name.replace("Workbench", ""),
            "icon": "",
            "is_active": False,
        })

_result_ = workbenches
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [WorkbenchInfo(**wb) for wb in result.result]
        return []
is_connected() async

Check if bridge is connected to FreeCAD.

Source code in src/freecad_mcp/bridge/xmlrpc.py
async def is_connected(self) -> bool:
    """Check if bridge is connected to FreeCAD."""
    if not self._connected or self._proxy is None:
        return False

    try:
        await self.ping()
        return True
    except Exception:
        self._connected = False
        return False
is_gui_available() async

Check if FreeCAD GUI is available.

Source code in src/freecad_mcp/bridge/xmlrpc.py
async def is_gui_available(self) -> bool:
    """Check if FreeCAD GUI is available."""
    result = await self.execute_python("_result_ = FreeCAD.GuiUp")
    return bool(result.success and result.result)
open_document(path) async

Open an existing document.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def open_document(self, path: str) -> DocumentInfo:
        """Open an existing document."""
        result = await self.execute_python(
            f"""
import os
if not os.path.exists({path!r}):
    raise FileNotFoundError(f"File not found: {path!r}")

doc = FreeCAD.openDocument({path!r})
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [obj.Name for obj in doc.Objects],
    "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        if "FileNotFoundError" in (result.error_type or ""):
            raise FileNotFoundError(result.stderr)

        error_msg = result.error_traceback or "Failed to open document"
        raise ValueError(error_msg)
ping() async

Ping FreeCAD to check connection and measure latency.

Returns:

Type Description
float

Round-trip time in milliseconds.

Raises:

Type Description
ConnectionError

If not connected.

Source code in src/freecad_mcp/bridge/xmlrpc.py
async def ping(self) -> float:
    """Ping FreeCAD to check connection and measure latency.

    Returns:
        Round-trip time in milliseconds.

    Raises:
        ConnectionError: If not connected.
    """
    if self._proxy is None:
        msg = "Not connected to XML-RPC server"
        raise ConnectionError(msg)

    loop = asyncio.get_event_loop()
    start = time.perf_counter()

    try:
        # Try standard system.listMethods or a simple execute
        if self._proxy is None:
            msg = "Not connected"
            raise ConnectionError(msg)
        proxy = self._proxy  # Local reference for lambda
        await asyncio.wait_for(
            loop.run_in_executor(
                None,
                lambda: proxy.execute("_result_ = True"),
            ),
            timeout=self._timeout,
        )
    except TimeoutError as e:
        msg = "Ping timed out"
        raise ConnectionError(msg) from e
    except Exception as e:
        msg = f"Ping failed: {e}"
        raise ConnectionError(msg) from e

    return (time.perf_counter() - start) * 1000
run_macro(macro_name, args=None) async

Run a macro by name.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def run_macro(
        self,
        macro_name: str,
        args: dict[str, Any] | None = None,
    ) -> ExecutionResult:
        """Run a macro by name."""
        args = args or {}
        code = f"""
import os

# Find macro file
macro_name = {macro_name!r}
macro_file = None

# Check user macros first
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

# Check system macros
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}}")

# Set up arguments
args = {args!r}
for k, v in args.items():
    exec(f"{{k}} = {{v!r}}")

# Execute macro
with open(macro_file, "r") as f:
    macro_code = f.read()

exec(macro_code)
_result_ = True
"""
        return await self.execute_python(code)
save_document(doc_name=None, path=None) async

Save a document.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def save_document(
        self,
        doc_name: str | None = None,
        path: str | None = None,
    ) -> str:
        """Save a document."""
        code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}")

save_path = {path!r} or doc.FileName
if not save_path:
    raise ValueError("No path specified for new document")

doc.saveAs(save_path)
_result_ = save_path
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        error_msg = result.error_traceback or "Failed to save document"
        raise ValueError(error_msg)
set_view(view_angle, doc_name=None) async

Set the 3D view angle.

Source code in src/freecad_mcp/bridge/xmlrpc.py
    async def set_view(
        self,
        view_angle: ViewAngle,
        doc_name: str | None = None,
    ) -> None:
        """Set the 3D view angle."""
        view_angle_str = view_angle.value
        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")

if not FreeCAD.GuiUp:
    _result_ = True  # Silently succeed in headless mode
else:
    view = FreeCADGui.ActiveDocument.ActiveView
    if view is None:
        raise ValueError("No active view")

    view_type = {view_angle_str!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()

    _result_ = True
"""
        await self.execute_python(code)

options: show_root_heading: true show_source: true

Socket Bridge

freecad_mcp.bridge.socket

Socket bridge for FreeCAD communication via JSON-RPC.

This bridge communicates with FreeCAD using JSON-RPC over TCP sockets, providing a modern, lightweight alternative to XML-RPC.

Based on learnings from competitive analysis: - Simple socket-based approach (inspired by bonninr/freecad_mcp) - Connection recovery mechanisms (from jango-blockchained) - Automatic reconnection on connection loss

Classes

JsonRpcError

Bases: Exception

JSON-RPC error response.

Source code in src/freecad_mcp/bridge/socket.py
class JsonRpcError(Exception):
    """JSON-RPC error response."""

    def __init__(self, code: int, message: str, data: Any = None) -> None:
        """Initialize JSON-RPC error.

        Args:
            code: Error code.
            message: Error message.
            data: Additional error data.
        """
        super().__init__(message)
        self.code = code
        self.message = message
        self.data = data
Functions
__init__(code, message, data=None)

Initialize JSON-RPC error.

Parameters:

Name Type Description Default
code int

Error code.

required
message str

Error message.

required
data Any

Additional error data.

None
Source code in src/freecad_mcp/bridge/socket.py
def __init__(self, code: int, message: str, data: Any = None) -> None:
    """Initialize JSON-RPC error.

    Args:
        code: Error code.
        message: Error message.
        data: Additional error data.
    """
    super().__init__(message)
    self.code = code
    self.message = message
    self.data = data

SocketBridge

Bases: FreecadBridge

Bridge that communicates with FreeCAD via JSON-RPC over sockets.

This bridge provides a lightweight, modern protocol for FreeCAD communication. It supports automatic reconnection and connection health monitoring.

Attributes:

Name Type Description
host

Socket server hostname.

port

Socket server port.

timeout

Connection and request timeout in seconds.

Source code in src/freecad_mcp/bridge/socket.py
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
class SocketBridge(FreecadBridge):
    """Bridge that communicates with FreeCAD via JSON-RPC over sockets.

    This bridge provides a lightweight, modern protocol for FreeCAD
    communication. It supports automatic reconnection and connection
    health monitoring.

    Attributes:
        host: Socket server hostname.
        port: Socket server port.
        timeout: Connection and request timeout in seconds.
    """

    def __init__(
        self,
        host: str = DEFAULT_SOCKET_HOST,
        port: int = DEFAULT_SOCKET_PORT,
        timeout: float = DEFAULT_TIMEOUT,
        auto_reconnect: bool = True,
    ) -> None:
        """Initialize the socket bridge.

        Args:
            host: Socket server hostname.
            port: Socket server port.
            timeout: Connection and request timeout in seconds.
            auto_reconnect: Whether to automatically reconnect on connection loss.
        """
        self._host = host
        self._port = port
        self._timeout = timeout
        self._auto_reconnect = auto_reconnect
        self._reader: asyncio.StreamReader | None = None
        self._writer: asyncio.StreamWriter | None = None
        self._connected = False
        self._lock = asyncio.Lock()

    async def connect(self) -> None:
        """Establish connection to FreeCAD socket server.

        Raises:
            ConnectionError: If connection cannot be established.
        """
        try:
            self._reader, self._writer = await asyncio.wait_for(
                asyncio.open_connection(self._host, self._port),
                timeout=self._timeout,
            )
            self._connected = True

            # Verify connection with a ping
            await self.ping()
        except TimeoutError as e:
            msg = f"Connection to {self._host}:{self._port} timed out"
            raise ConnectionError(msg) from e
        except Exception as e:
            self._connected = False
            msg = f"Failed to connect to {self._host}:{self._port}: {e}"
            raise ConnectionError(msg) from e

    async def disconnect(self) -> None:
        """Close connection to FreeCAD socket server."""
        if self._writer:
            self._writer.close()
            with contextlib.suppress(Exception):
                await self._writer.wait_closed()
        self._reader = None
        self._writer = None
        self._connected = False

    async def is_connected(self) -> bool:
        """Check if bridge is connected to FreeCAD."""
        if not self._connected or self._writer is None:
            return False

        try:
            await self.ping()
            return True
        except Exception:
            self._connected = False
            return False

    async def _send_request(
        self,
        method: str,
        params: dict[str, Any] | None = None,
    ) -> Any:
        """Send a JSON-RPC request and wait for response.

        Args:
            method: Method name to call.
            params: Method parameters.

        Returns:
            Result from the JSON-RPC response.

        Raises:
            ConnectionError: If not connected.
            JsonRpcError: If server returns an error.
        """
        if self._writer is None or self._reader is None:
            msg = "Not connected to socket server"
            raise ConnectionError(msg)

        # Build JSON-RPC request
        request_id = str(uuid.uuid4())
        request = {
            "jsonrpc": "2.0",
            "id": request_id,
            "method": method,
            "params": params or {},
        }

        async with self._lock:
            try:
                # Send request
                request_data = json.dumps(request).encode("utf-8") + b"\n"
                self._writer.write(request_data)
                await self._writer.drain()

                # Read response
                response_data = await asyncio.wait_for(
                    self._reader.readline(),
                    timeout=self._timeout,
                )

                if not response_data:
                    self._connected = False
                    msg = "Connection closed by server"
                    raise ConnectionError(msg)

                response = json.loads(response_data.decode("utf-8"))

                # Check for error
                if "error" in response:
                    error = response["error"]
                    raise JsonRpcError(
                        code=error.get("code", -1),
                        message=error.get("message", "Unknown error"),
                        data=error.get("data"),
                    )

                return response.get("result")

            except TimeoutError as e:
                msg = "Request timed out"
                raise ConnectionError(msg) from e
            except json.JSONDecodeError as e:
                msg = f"Invalid JSON response: {e}"
                raise ConnectionError(msg) from e
            except (ConnectionResetError, BrokenPipeError) as e:
                self._connected = False
                if self._auto_reconnect:
                    # Try to reconnect
                    try:
                        await self.connect()
                        return await self._send_request(method, params)
                    except Exception:
                        pass
                msg = f"Connection lost: {e}"
                raise ConnectionError(msg) from e

    async def ping(self) -> float:
        """Ping FreeCAD to check connection and measure latency.

        Returns:
            Round-trip time in milliseconds.

        Raises:
            ConnectionError: If not connected.
        """
        start = time.perf_counter()
        await self._send_request("ping")
        return (time.perf_counter() - start) * 1000

    async def get_status(self) -> ConnectionStatus:
        """Get detailed connection status.

        Returns:
            ConnectionStatus with full status information.
        """
        if not self._connected:
            return ConnectionStatus(
                connected=False,
                mode="socket",
                error="Not connected",
            )

        try:
            ping_ms = await self.ping()
            version_info = await self.get_freecad_version()
            gui_available = await self.is_gui_available()

            return ConnectionStatus(
                connected=True,
                mode="socket",
                freecad_version=version_info.get("version", "unknown"),
                gui_available=gui_available,
                last_ping_ms=ping_ms,
            )
        except Exception as e:
            return ConnectionStatus(
                connected=False,
                mode="socket",
                error=str(e),
            )

    # =========================================================================
    # Code Execution
    # =========================================================================

    async def execute_python(
        self,
        code: str,
        timeout_ms: int = 30000,
    ) -> ExecutionResult:
        """Execute Python code in FreeCAD context via socket.

        Args:
            code: Python code to execute.
            timeout_ms: Maximum execution time in milliseconds.

        Returns:
            ExecutionResult with execution outcome.
        """
        start = time.perf_counter()

        try:
            result = await asyncio.wait_for(
                self._send_request("execute", {"code": code}),
                timeout=timeout_ms / 1000,
            )
            elapsed = (time.perf_counter() - start) * 1000

            if isinstance(result, dict):
                return ExecutionResult(
                    success=result.get("success", False),
                    result=result.get("result"),
                    stdout=result.get("stdout", ""),
                    stderr=result.get("stderr", ""),
                    execution_time_ms=elapsed,
                    error_type=result.get("error_type"),
                    error_traceback=result.get("error_traceback"),
                )
            else:
                return ExecutionResult(
                    success=True,
                    result=result,
                    stdout="",
                    stderr="",
                    execution_time_ms=elapsed,
                )

        except TimeoutError:
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=f"Execution timed out after {timeout_ms}ms",
                execution_time_ms=float(timeout_ms),
                error_type="TimeoutError",
            )
        except JsonRpcError as e:
            elapsed = (time.perf_counter() - start) * 1000
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=e.message,
                execution_time_ms=elapsed,
                error_type="JsonRpcError",
            )
        except ConnectionError as e:
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=str(e),
                execution_time_ms=0,
                error_type="ConnectionError",
            )

    # =========================================================================
    # Document Management
    # =========================================================================

    async def get_documents(self) -> list[DocumentInfo]:
        """Get list of open documents."""
        result = await self.execute_python(
            """
_result_ = []
for doc in FreeCAD.listDocuments().values():
    _result_.append({
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    })
"""
        )

        if result.success and result.result:
            return [DocumentInfo(**doc) for doc in result.result]
        return []

    async def get_active_document(self) -> DocumentInfo | None:
        """Get the active document."""
        result = await self.execute_python(
            """
doc = FreeCAD.ActiveDocument
if doc:
    _result_ = {
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    }
else:
    _result_ = None
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)
        return None

    async def create_document(
        self, name: str, label: str | None = None
    ) -> DocumentInfo:
        """Create a new document."""
        label = label or name
        result = await self.execute_python(
            f"""
doc = FreeCAD.newDocument({name!r})
doc.Label = {label!r}
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [],
    "is_modified": False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create document"
        raise ValueError(error_msg)

    async def open_document(self, path: str) -> DocumentInfo:
        """Open an existing document."""
        result = await self.execute_python(
            f"""
import os
if not os.path.exists({path!r}):
    raise FileNotFoundError(f"File not found: {path!r}")

doc = FreeCAD.openDocument({path!r})
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [obj.Name for obj in doc.Objects],
    "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        if "FileNotFoundError" in (result.error_type or ""):
            raise FileNotFoundError(result.stderr)

        error_msg = result.error_traceback or "Failed to open document"
        raise ValueError(error_msg)

    async def save_document(
        self,
        doc_name: str | None = None,
        path: str | None = None,
    ) -> str:
        """Save a document."""
        code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}")

save_path = {path!r} or doc.FileName
if not save_path:
    raise ValueError("No path specified for new document")

doc.saveAs(save_path)
_result_ = save_path
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        error_msg = result.error_traceback or "Failed to save document"
        raise ValueError(error_msg)

    async def close_document(self, doc_name: str | None = None) -> None:
        """Close a document."""
        code = f"""
doc_name = {doc_name!r}
if doc_name is None:
    doc = FreeCAD.ActiveDocument
    if doc:
        doc_name = doc.Name
    else:
        raise ValueError("No active document")

FreeCAD.closeDocument(doc_name)
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to close document"
            raise ValueError(error_msg)

    # =========================================================================
    # Object Management
    # =========================================================================

    async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]:
        """Get all objects in a document."""
        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")

objects = []
for obj in doc.Objects:
    obj_info = {{
        "name": obj.Name,
        "label": obj.Label,
        "type_id": obj.TypeId,
        "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
        "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
        "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    }}
    objects.append(obj_info)

_result_ = objects
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [ObjectInfo(**obj) for obj in result.result]
        return []

    async def get_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Get detailed object information."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

props = {{}}
for prop in obj.PropertiesList:
    try:
        val = getattr(obj, prop)
        if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins':
            val = str(val)
        props[prop] = val
    except Exception:
        props[prop] = "<unreadable>"

shape_info = None
if hasattr(obj, "Shape"):
    shape = obj.Shape
    shape_info = {{
        "shape_type": shape.ShapeType,
        "volume": shape.Volume if hasattr(shape, "Volume") else None,
        "area": shape.Area if hasattr(shape, "Area") else None,
        "is_valid": shape.isValid(),
        "is_closed": shape.isClosed() if hasattr(shape, "isClosed") else False,
        "vertex_count": len(shape.Vertexes) if hasattr(shape, "Vertexes") else 0,
        "edge_count": len(shape.Edges) if hasattr(shape, "Edges") else 0,
        "face_count": len(shape.Faces) if hasattr(shape, "Faces") else 0,
    }}

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "properties": props,
    "shape_info": shape_info,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to get object"
        raise ValueError(error_msg)

    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."""
        properties = properties or {}
        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.addObject({type_id!r}, {name!r} or "")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create object"
        raise ValueError(error_msg)

    async def edit_object(
        self,
        obj_name: str,
        properties: dict[str, Any],
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Edit object properties."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to edit object"
        raise ValueError(error_msg)

    async def delete_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> None:
        """Delete an object."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

doc.removeObject({obj_name!r})
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to delete object"
            raise ValueError(error_msg)

    # =========================================================================
    # View and Screenshot
    # =========================================================================

    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 via socket."""
        view_angle_str = view_angle.value if view_angle else "Isometric"
        code = f"""
import base64
import tempfile
import os

doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No document found")

# Check if GUI is available
if not FreeCAD.GuiUp:
    raise RuntimeError("GUI not available")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

# Check view type
view_type = view.__class__.__name__
if view_type not in ["View3DInventor", "View3DInventorPy"]:
    raise ValueError(f"Cannot capture screenshot from {{view_type}} view")

# Set view angle
view_type_str = {view_angle_str!r}
if view_type_str == "FitAll":
    view.fitAll()
elif view_type_str == "Isometric":
    view.viewIsometric()
elif view_type_str == "Front":
    view.viewFront()
elif view_type_str == "Back":
    view.viewRear()
elif view_type_str == "Top":
    view.viewTop()
elif view_type_str == "Bottom":
    view.viewBottom()
elif view_type_str == "Left":
    view.viewLeft()
elif view_type_str == "Right":
    view.viewRight()

# Save to temp file and read
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 = await self.execute_python(code)

        if result.success and result.result:
            return ScreenshotResult(
                success=True,
                data=result.result["data"],
                format=result.result["format"],
                width=result.result["width"],
                height=result.result["height"],
                view_angle=view_angle,
            )

        return ScreenshotResult(
            success=False,
            error=result.error_traceback
            or result.stderr
            or "Failed to capture screenshot",
            width=width,
            height=height,
            view_angle=view_angle,
        )

    async def set_view(
        self,
        view_angle: ViewAngle,
        doc_name: str | None = None,
    ) -> None:
        """Set the 3D view angle."""
        view_angle_str = view_angle.value
        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")

if not FreeCAD.GuiUp:
    _result_ = True
else:
    view = FreeCADGui.ActiveDocument.ActiveView
    if view is None:
        raise ValueError("No active view")

    view_type = {view_angle_str!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()

    _result_ = True
"""
        await self.execute_python(code)

    # =========================================================================
    # Macros
    # =========================================================================

    async def get_macros(self) -> list[MacroInfo]:
        """Get list of available macros."""
        code = """
import os

macro_paths = []

user_path = FreeCAD.getUserMacroDir(True)
if os.path.exists(user_path):
    macro_paths.append(("user", user_path))

system_path = FreeCAD.getResourceDir() + "Macro"
if os.path.exists(system_path):
    macro_paths.append(("system", system_path))

macros = []
for source, path in macro_paths:
    for filename in os.listdir(path):
        if filename.endswith(".FCMacro"):
            macro_file = os.path.join(path, filename)
            description = ""
            try:
                with open(macro_file, "r") as f:
                    for line in f:
                        if line.startswith("#"):
                            desc = line.lstrip("#").strip()
                            if desc and not desc.startswith("!") and not desc.startswith("-*-"):
                                description = desc
                                break
            except Exception:
                pass

            macros.append({
                "name": filename[:-8],
                "path": macro_file,
                "description": description,
                "is_system": source == "system",
            })

_result_ = macros
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [MacroInfo(**m) for m in result.result]
        return []

    async def run_macro(
        self,
        macro_name: str,
        args: dict[str, Any] | None = None,
    ) -> ExecutionResult:
        """Run a macro by name."""
        args = args or {}
        code = f"""
import os

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}}")

args = {args!r}
for k, v in args.items():
    exec(f"{{k}} = {{v!r}}")

with open(macro_file, "r") as f:
    macro_code = f.read()

exec(macro_code)
_result_ = True
"""
        return await self.execute_python(code)

    async def create_macro(
        self,
        name: str,
        code: str,
        description: str = "",
    ) -> MacroInfo:
        """Create a new macro."""
        create_code = f"""
import os

macro_path = FreeCAD.getUserMacroDir(True)
os.makedirs(macro_path, exist_ok=True)

macro_file = os.path.join(macro_path, {name!r} + ".FCMacro")

header = ""
if {description!r}:
    header = f"# {description!r}\\n\\n"

full_code = header + '''# -*- coding: utf-8 -*-
# FreeCAD Macro: {name}
# Created via MCP Bridge

import FreeCAD
import FreeCADGui

''' + {code!r}

with open(macro_file, "w") as f:
    f.write(full_code)

_result_ = {{
    "name": {name!r},
    "path": macro_file,
    "description": {description!r},
    "is_system": False,
}}
"""
        result = await self.execute_python(create_code)

        if result.success and result.result:
            return MacroInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create macro"
        raise ValueError(error_msg)

    # =========================================================================
    # Workbenches
    # =========================================================================

    async def get_workbenches(self) -> list[WorkbenchInfo]:
        """Get list of available workbenches."""
        code = """
workbenches = []
active_wb = FreeCADGui.activeWorkbench() if FreeCAD.GuiUp else None
active_name = active_wb.__class__.__name__ if active_wb else None

if FreeCAD.GuiUp:
    for name in FreeCADGui.listWorkbenches():
        wb = FreeCADGui.getWorkbench(name)
        workbenches.append({
            "name": name,
            "label": wb.MenuText if hasattr(wb, "MenuText") else name,
            "icon": wb.Icon if hasattr(wb, "Icon") else "",
            "is_active": name == active_name,
        })
else:
    common = ["StartWorkbench", "PartWorkbench", "PartDesignWorkbench",
              "DraftWorkbench", "SketcherWorkbench", "MeshWorkbench"]
    for name in common:
        workbenches.append({
            "name": name,
            "label": name.replace("Workbench", ""),
            "icon": "",
            "is_active": False,
        })

_result_ = workbenches
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [WorkbenchInfo(**wb) for wb in result.result]
        return []

    async def activate_workbench(self, workbench_name: str) -> None:
        """Activate a workbench."""
        code = f"""
if FreeCAD.GuiUp:
    try:
        FreeCADGui.activateWorkbench({workbench_name!r})
        _result_ = True
    except Exception as e:
        raise ValueError(f"Failed to activate workbench: {{e}}")
else:
    _result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to activate workbench"
            raise ValueError(error_msg)

    # =========================================================================
    # Version and Environment
    # =========================================================================

    async def get_freecad_version(self) -> dict[str, Any]:
        """Get FreeCAD version information."""
        code = """
import sys
_result_ = {
    "version": ".".join(str(x) for x in FreeCAD.Version()[:3]),
    "version_tuple": FreeCAD.Version()[:3],
    "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown",
    "python_version": sys.version,
    "gui_available": FreeCAD.GuiUp,
}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        return {
            "version": "unknown",
            "version_tuple": [],
            "build_date": "unknown",
            "python_version": "",
            "gui_available": False,
        }

    async def is_gui_available(self) -> bool:
        """Check if FreeCAD GUI is available."""
        result = await self.execute_python("_result_ = FreeCAD.GuiUp")
        return bool(result.success and result.result)

    # =========================================================================
    # Console
    # =========================================================================

    async def get_console_output(self, lines: int = 100) -> list[str]:
        """Get recent console output."""
        code = f"""
output_lines = []

if hasattr(FreeCAD, 'Console'):
    console = FreeCAD.Console
    if hasattr(console, 'GetLog'):
        log = console.GetLog()
        if log:
            output_lines = log.split('\\n')[-{lines}:]

_result_ = output_lines
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result
        return []
Functions
__init__(host=DEFAULT_SOCKET_HOST, port=DEFAULT_SOCKET_PORT, timeout=DEFAULT_TIMEOUT, auto_reconnect=True)

Initialize the socket bridge.

Parameters:

Name Type Description Default
host str

Socket server hostname.

DEFAULT_SOCKET_HOST
port int

Socket server port.

DEFAULT_SOCKET_PORT
timeout float

Connection and request timeout in seconds.

DEFAULT_TIMEOUT
auto_reconnect bool

Whether to automatically reconnect on connection loss.

True
Source code in src/freecad_mcp/bridge/socket.py
def __init__(
    self,
    host: str = DEFAULT_SOCKET_HOST,
    port: int = DEFAULT_SOCKET_PORT,
    timeout: float = DEFAULT_TIMEOUT,
    auto_reconnect: bool = True,
) -> None:
    """Initialize the socket bridge.

    Args:
        host: Socket server hostname.
        port: Socket server port.
        timeout: Connection and request timeout in seconds.
        auto_reconnect: Whether to automatically reconnect on connection loss.
    """
    self._host = host
    self._port = port
    self._timeout = timeout
    self._auto_reconnect = auto_reconnect
    self._reader: asyncio.StreamReader | None = None
    self._writer: asyncio.StreamWriter | None = None
    self._connected = False
    self._lock = asyncio.Lock()
activate_workbench(workbench_name) async

Activate a workbench.

Source code in src/freecad_mcp/bridge/socket.py
    async def activate_workbench(self, workbench_name: str) -> None:
        """Activate a workbench."""
        code = f"""
if FreeCAD.GuiUp:
    try:
        FreeCADGui.activateWorkbench({workbench_name!r})
        _result_ = True
    except Exception as e:
        raise ValueError(f"Failed to activate workbench: {{e}}")
else:
    _result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to activate workbench"
            raise ValueError(error_msg)
close_document(doc_name=None) async

Close a document.

Source code in src/freecad_mcp/bridge/socket.py
    async def close_document(self, doc_name: str | None = None) -> None:
        """Close a document."""
        code = f"""
doc_name = {doc_name!r}
if doc_name is None:
    doc = FreeCAD.ActiveDocument
    if doc:
        doc_name = doc.Name
    else:
        raise ValueError("No active document")

FreeCAD.closeDocument(doc_name)
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to close document"
            raise ValueError(error_msg)
connect() async

Establish connection to FreeCAD socket server.

Raises:

Type Description
ConnectionError

If connection cannot be established.

Source code in src/freecad_mcp/bridge/socket.py
async def connect(self) -> None:
    """Establish connection to FreeCAD socket server.

    Raises:
        ConnectionError: If connection cannot be established.
    """
    try:
        self._reader, self._writer = await asyncio.wait_for(
            asyncio.open_connection(self._host, self._port),
            timeout=self._timeout,
        )
        self._connected = True

        # Verify connection with a ping
        await self.ping()
    except TimeoutError as e:
        msg = f"Connection to {self._host}:{self._port} timed out"
        raise ConnectionError(msg) from e
    except Exception as e:
        self._connected = False
        msg = f"Failed to connect to {self._host}:{self._port}: {e}"
        raise ConnectionError(msg) from e
create_document(name, label=None) async

Create a new document.

Source code in src/freecad_mcp/bridge/socket.py
    async def create_document(
        self, name: str, label: str | None = None
    ) -> DocumentInfo:
        """Create a new document."""
        label = label or name
        result = await self.execute_python(
            f"""
doc = FreeCAD.newDocument({name!r})
doc.Label = {label!r}
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [],
    "is_modified": False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create document"
        raise ValueError(error_msg)
create_macro(name, code, description='') async

Create a new macro.

Source code in src/freecad_mcp/bridge/socket.py
    async def create_macro(
        self,
        name: str,
        code: str,
        description: str = "",
    ) -> MacroInfo:
        """Create a new macro."""
        create_code = f"""
import os

macro_path = FreeCAD.getUserMacroDir(True)
os.makedirs(macro_path, exist_ok=True)

macro_file = os.path.join(macro_path, {name!r} + ".FCMacro")

header = ""
if {description!r}:
    header = f"# {description!r}\\n\\n"

full_code = header + '''# -*- coding: utf-8 -*-
# FreeCAD Macro: {name}
# Created via MCP Bridge

import FreeCAD
import FreeCADGui

''' + {code!r}

with open(macro_file, "w") as f:
    f.write(full_code)

_result_ = {{
    "name": {name!r},
    "path": macro_file,
    "description": {description!r},
    "is_system": False,
}}
"""
        result = await self.execute_python(create_code)

        if result.success and result.result:
            return MacroInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create macro"
        raise ValueError(error_msg)
create_object(type_id, name=None, properties=None, doc_name=None) async

Create a new object.

Source code in src/freecad_mcp/bridge/socket.py
    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."""
        properties = properties or {}
        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.addObject({type_id!r}, {name!r} or "")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create object"
        raise ValueError(error_msg)
delete_object(obj_name, doc_name=None) async

Delete an object.

Source code in src/freecad_mcp/bridge/socket.py
    async def delete_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> None:
        """Delete an object."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

doc.removeObject({obj_name!r})
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to delete object"
            raise ValueError(error_msg)
disconnect() async

Close connection to FreeCAD socket server.

Source code in src/freecad_mcp/bridge/socket.py
async def disconnect(self) -> None:
    """Close connection to FreeCAD socket server."""
    if self._writer:
        self._writer.close()
        with contextlib.suppress(Exception):
            await self._writer.wait_closed()
    self._reader = None
    self._writer = None
    self._connected = False
edit_object(obj_name, properties, doc_name=None) async

Edit object properties.

Source code in src/freecad_mcp/bridge/socket.py
    async def edit_object(
        self,
        obj_name: str,
        properties: dict[str, Any],
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Edit object properties."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to edit object"
        raise ValueError(error_msg)
execute_python(code, timeout_ms=30000) async

Execute Python code in FreeCAD context via socket.

Parameters:

Name Type Description Default
code str

Python code to execute.

required
timeout_ms int

Maximum execution time in milliseconds.

30000

Returns:

Type Description
ExecutionResult

ExecutionResult with execution outcome.

Source code in src/freecad_mcp/bridge/socket.py
async def execute_python(
    self,
    code: str,
    timeout_ms: int = 30000,
) -> ExecutionResult:
    """Execute Python code in FreeCAD context via socket.

    Args:
        code: Python code to execute.
        timeout_ms: Maximum execution time in milliseconds.

    Returns:
        ExecutionResult with execution outcome.
    """
    start = time.perf_counter()

    try:
        result = await asyncio.wait_for(
            self._send_request("execute", {"code": code}),
            timeout=timeout_ms / 1000,
        )
        elapsed = (time.perf_counter() - start) * 1000

        if isinstance(result, dict):
            return ExecutionResult(
                success=result.get("success", False),
                result=result.get("result"),
                stdout=result.get("stdout", ""),
                stderr=result.get("stderr", ""),
                execution_time_ms=elapsed,
                error_type=result.get("error_type"),
                error_traceback=result.get("error_traceback"),
            )
        else:
            return ExecutionResult(
                success=True,
                result=result,
                stdout="",
                stderr="",
                execution_time_ms=elapsed,
            )

    except TimeoutError:
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=f"Execution timed out after {timeout_ms}ms",
            execution_time_ms=float(timeout_ms),
            error_type="TimeoutError",
        )
    except JsonRpcError as e:
        elapsed = (time.perf_counter() - start) * 1000
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=e.message,
            execution_time_ms=elapsed,
            error_type="JsonRpcError",
        )
    except ConnectionError as e:
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=str(e),
            execution_time_ms=0,
            error_type="ConnectionError",
        )
get_active_document() async

Get the active document.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_active_document(self) -> DocumentInfo | None:
        """Get the active document."""
        result = await self.execute_python(
            """
doc = FreeCAD.ActiveDocument
if doc:
    _result_ = {
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    }
else:
    _result_ = None
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)
        return None
get_console_output(lines=100) async

Get recent console output.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_console_output(self, lines: int = 100) -> list[str]:
        """Get recent console output."""
        code = f"""
output_lines = []

if hasattr(FreeCAD, 'Console'):
    console = FreeCAD.Console
    if hasattr(console, 'GetLog'):
        log = console.GetLog()
        if log:
            output_lines = log.split('\\n')[-{lines}:]

_result_ = output_lines
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result
        return []
get_documents() async

Get list of open documents.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_documents(self) -> list[DocumentInfo]:
        """Get list of open documents."""
        result = await self.execute_python(
            """
_result_ = []
for doc in FreeCAD.listDocuments().values():
    _result_.append({
        "name": doc.Name,
        "label": doc.Label,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
    })
"""
        )

        if result.success and result.result:
            return [DocumentInfo(**doc) for doc in result.result]
        return []
get_freecad_version() async

Get FreeCAD version information.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_freecad_version(self) -> dict[str, Any]:
        """Get FreeCAD version information."""
        code = """
import sys
_result_ = {
    "version": ".".join(str(x) for x in FreeCAD.Version()[:3]),
    "version_tuple": FreeCAD.Version()[:3],
    "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown",
    "python_version": sys.version,
    "gui_available": FreeCAD.GuiUp,
}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        return {
            "version": "unknown",
            "version_tuple": [],
            "build_date": "unknown",
            "python_version": "",
            "gui_available": False,
        }
get_macros() async

Get list of available macros.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_macros(self) -> list[MacroInfo]:
        """Get list of available macros."""
        code = """
import os

macro_paths = []

user_path = FreeCAD.getUserMacroDir(True)
if os.path.exists(user_path):
    macro_paths.append(("user", user_path))

system_path = FreeCAD.getResourceDir() + "Macro"
if os.path.exists(system_path):
    macro_paths.append(("system", system_path))

macros = []
for source, path in macro_paths:
    for filename in os.listdir(path):
        if filename.endswith(".FCMacro"):
            macro_file = os.path.join(path, filename)
            description = ""
            try:
                with open(macro_file, "r") as f:
                    for line in f:
                        if line.startswith("#"):
                            desc = line.lstrip("#").strip()
                            if desc and not desc.startswith("!") and not desc.startswith("-*-"):
                                description = desc
                                break
            except Exception:
                pass

            macros.append({
                "name": filename[:-8],
                "path": macro_file,
                "description": description,
                "is_system": source == "system",
            })

_result_ = macros
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [MacroInfo(**m) for m in result.result]
        return []
get_object(obj_name, doc_name=None) async

Get detailed object information.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Get detailed object information."""
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

props = {{}}
for prop in obj.PropertiesList:
    try:
        val = getattr(obj, prop)
        if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins':
            val = str(val)
        props[prop] = val
    except Exception:
        props[prop] = "<unreadable>"

shape_info = None
if hasattr(obj, "Shape"):
    shape = obj.Shape
    shape_info = {{
        "shape_type": shape.ShapeType,
        "volume": shape.Volume if hasattr(shape, "Volume") else None,
        "area": shape.Area if hasattr(shape, "Area") else None,
        "is_valid": shape.isValid(),
        "is_closed": shape.isClosed() if hasattr(shape, "isClosed") else False,
        "vertex_count": len(shape.Vertexes) if hasattr(shape, "Vertexes") else 0,
        "edge_count": len(shape.Edges) if hasattr(shape, "Edges") else 0,
        "face_count": len(shape.Faces) if hasattr(shape, "Faces") else 0,
    }}

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "properties": props,
    "shape_info": shape_info,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to get object"
        raise ValueError(error_msg)
get_objects(doc_name=None) async

Get all objects in a document.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]:
        """Get all objects in a document."""
        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")

objects = []
for obj in doc.Objects:
    obj_info = {{
        "name": obj.Name,
        "label": obj.Label,
        "type_id": obj.TypeId,
        "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
        "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
        "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    }}
    objects.append(obj_info)

_result_ = objects
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [ObjectInfo(**obj) for obj in result.result]
        return []
get_screenshot(view_angle=None, width=800, height=600, doc_name=None) async

Capture a screenshot of the 3D view via socket.

Source code in src/freecad_mcp/bridge/socket.py
    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 via socket."""
        view_angle_str = view_angle.value if view_angle else "Isometric"
        code = f"""
import base64
import tempfile
import os

doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No document found")

# Check if GUI is available
if not FreeCAD.GuiUp:
    raise RuntimeError("GUI not available")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

# Check view type
view_type = view.__class__.__name__
if view_type not in ["View3DInventor", "View3DInventorPy"]:
    raise ValueError(f"Cannot capture screenshot from {{view_type}} view")

# Set view angle
view_type_str = {view_angle_str!r}
if view_type_str == "FitAll":
    view.fitAll()
elif view_type_str == "Isometric":
    view.viewIsometric()
elif view_type_str == "Front":
    view.viewFront()
elif view_type_str == "Back":
    view.viewRear()
elif view_type_str == "Top":
    view.viewTop()
elif view_type_str == "Bottom":
    view.viewBottom()
elif view_type_str == "Left":
    view.viewLeft()
elif view_type_str == "Right":
    view.viewRight()

# Save to temp file and read
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 = await self.execute_python(code)

        if result.success and result.result:
            return ScreenshotResult(
                success=True,
                data=result.result["data"],
                format=result.result["format"],
                width=result.result["width"],
                height=result.result["height"],
                view_angle=view_angle,
            )

        return ScreenshotResult(
            success=False,
            error=result.error_traceback
            or result.stderr
            or "Failed to capture screenshot",
            width=width,
            height=height,
            view_angle=view_angle,
        )
get_status() async

Get detailed connection status.

Returns:

Type Description
ConnectionStatus

ConnectionStatus with full status information.

Source code in src/freecad_mcp/bridge/socket.py
async def get_status(self) -> ConnectionStatus:
    """Get detailed connection status.

    Returns:
        ConnectionStatus with full status information.
    """
    if not self._connected:
        return ConnectionStatus(
            connected=False,
            mode="socket",
            error="Not connected",
        )

    try:
        ping_ms = await self.ping()
        version_info = await self.get_freecad_version()
        gui_available = await self.is_gui_available()

        return ConnectionStatus(
            connected=True,
            mode="socket",
            freecad_version=version_info.get("version", "unknown"),
            gui_available=gui_available,
            last_ping_ms=ping_ms,
        )
    except Exception as e:
        return ConnectionStatus(
            connected=False,
            mode="socket",
            error=str(e),
        )
get_workbenches() async

Get list of available workbenches.

Source code in src/freecad_mcp/bridge/socket.py
    async def get_workbenches(self) -> list[WorkbenchInfo]:
        """Get list of available workbenches."""
        code = """
workbenches = []
active_wb = FreeCADGui.activeWorkbench() if FreeCAD.GuiUp else None
active_name = active_wb.__class__.__name__ if active_wb else None

if FreeCAD.GuiUp:
    for name in FreeCADGui.listWorkbenches():
        wb = FreeCADGui.getWorkbench(name)
        workbenches.append({
            "name": name,
            "label": wb.MenuText if hasattr(wb, "MenuText") else name,
            "icon": wb.Icon if hasattr(wb, "Icon") else "",
            "is_active": name == active_name,
        })
else:
    common = ["StartWorkbench", "PartWorkbench", "PartDesignWorkbench",
              "DraftWorkbench", "SketcherWorkbench", "MeshWorkbench"]
    for name in common:
        workbenches.append({
            "name": name,
            "label": name.replace("Workbench", ""),
            "icon": "",
            "is_active": False,
        })

_result_ = workbenches
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [WorkbenchInfo(**wb) for wb in result.result]
        return []
is_connected() async

Check if bridge is connected to FreeCAD.

Source code in src/freecad_mcp/bridge/socket.py
async def is_connected(self) -> bool:
    """Check if bridge is connected to FreeCAD."""
    if not self._connected or self._writer is None:
        return False

    try:
        await self.ping()
        return True
    except Exception:
        self._connected = False
        return False
is_gui_available() async

Check if FreeCAD GUI is available.

Source code in src/freecad_mcp/bridge/socket.py
async def is_gui_available(self) -> bool:
    """Check if FreeCAD GUI is available."""
    result = await self.execute_python("_result_ = FreeCAD.GuiUp")
    return bool(result.success and result.result)
open_document(path) async

Open an existing document.

Source code in src/freecad_mcp/bridge/socket.py
    async def open_document(self, path: str) -> DocumentInfo:
        """Open an existing document."""
        result = await self.execute_python(
            f"""
import os
if not os.path.exists({path!r}):
    raise FileNotFoundError(f"File not found: {path!r}")

doc = FreeCAD.openDocument({path!r})
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [obj.Name for obj in doc.Objects],
    "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        if "FileNotFoundError" in (result.error_type or ""):
            raise FileNotFoundError(result.stderr)

        error_msg = result.error_traceback or "Failed to open document"
        raise ValueError(error_msg)
ping() async

Ping FreeCAD to check connection and measure latency.

Returns:

Type Description
float

Round-trip time in milliseconds.

Raises:

Type Description
ConnectionError

If not connected.

Source code in src/freecad_mcp/bridge/socket.py
async def ping(self) -> float:
    """Ping FreeCAD to check connection and measure latency.

    Returns:
        Round-trip time in milliseconds.

    Raises:
        ConnectionError: If not connected.
    """
    start = time.perf_counter()
    await self._send_request("ping")
    return (time.perf_counter() - start) * 1000
run_macro(macro_name, args=None) async

Run a macro by name.

Source code in src/freecad_mcp/bridge/socket.py
    async def run_macro(
        self,
        macro_name: str,
        args: dict[str, Any] | None = None,
    ) -> ExecutionResult:
        """Run a macro by name."""
        args = args or {}
        code = f"""
import os

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}}")

args = {args!r}
for k, v in args.items():
    exec(f"{{k}} = {{v!r}}")

with open(macro_file, "r") as f:
    macro_code = f.read()

exec(macro_code)
_result_ = True
"""
        return await self.execute_python(code)
save_document(doc_name=None, path=None) async

Save a document.

Source code in src/freecad_mcp/bridge/socket.py
    async def save_document(
        self,
        doc_name: str | None = None,
        path: str | None = None,
    ) -> str:
        """Save a document."""
        code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}")

save_path = {path!r} or doc.FileName
if not save_path:
    raise ValueError("No path specified for new document")

doc.saveAs(save_path)
_result_ = save_path
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        error_msg = result.error_traceback or "Failed to save document"
        raise ValueError(error_msg)
set_view(view_angle, doc_name=None) async

Set the 3D view angle.

Source code in src/freecad_mcp/bridge/socket.py
    async def set_view(
        self,
        view_angle: ViewAngle,
        doc_name: str | None = None,
    ) -> None:
        """Set the 3D view angle."""
        view_angle_str = view_angle.value
        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")

if not FreeCAD.GuiUp:
    _result_ = True
else:
    view = FreeCADGui.ActiveDocument.ActiveView
    if view is None:
        raise ValueError("No active view")

    view_type = {view_angle_str!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()

    _result_ = True
"""
        await self.execute_python(code)

options: show_root_heading: true show_source: true

Embedded Bridge

Linux Only

The embedded bridge only works on Linux. See Connection Modes for details.

freecad_mcp.bridge.embedded

Embedded bridge - runs FreeCAD in-process.

This bridge imports FreeCAD directly into the Robust MCP Server process, providing the fastest execution but limited to headless mode.

Based on learnings from competitive analysis: - Thread-safe execution using ThreadPoolExecutor (from neka-nat) - Comprehensive object info including shape geometry - Macro support with templates and validation

Classes

EmbeddedBridge

Bases: FreecadBridge

Bridge that runs FreeCAD embedded in the Robust MCP Server process.

This bridge imports FreeCAD directly, providing fast execution but only supports headless mode (no GUI).

Attributes:

Name Type Description
freecad_path

Optional path to FreeCAD's lib directory.

Source code in src/freecad_mcp/bridge/embedded.py
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
class EmbeddedBridge(FreecadBridge):
    """Bridge that runs FreeCAD embedded in the Robust MCP Server process.

    This bridge imports FreeCAD directly, providing fast execution
    but only supports headless mode (no GUI).

    Attributes:
        freecad_path: Optional path to FreeCAD's lib directory.
    """

    def __init__(self, freecad_path: str | None = None) -> None:
        """Initialize the embedded bridge.

        Args:
            freecad_path: Path to FreeCAD's lib directory. If provided,
                this path will be added to sys.path before importing.
        """
        self._freecad_path = freecad_path
        self._fc_module: Any = None
        self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="freecad")
        self._connected = False

    async def connect(self) -> None:
        """Import and initialize FreeCAD.

        Raises:
            ConnectionError: If FreeCAD cannot be imported.
        """
        if self._freecad_path:
            sys.path.insert(0, self._freecad_path)

        loop = asyncio.get_event_loop()
        try:
            self._fc_module = await loop.run_in_executor(
                self._executor,
                self._import_freecad,
            )
            self._connected = True
        except ImportError as e:
            msg = f"Failed to import FreeCAD: {e}"
            raise ConnectionError(msg) from e

    def _import_freecad(self) -> Any:
        """Import FreeCAD module (runs in thread pool)."""
        import FreeCAD

        return FreeCAD

    async def disconnect(self) -> None:
        """Clean up resources."""
        self._connected = False
        self._executor.shutdown(wait=True)

    async def is_connected(self) -> bool:
        """Check if FreeCAD is imported and available."""
        return self._connected and self._fc_module is not None

    async def execute_python(
        self,
        code: str,
        timeout_ms: int = 30000,
    ) -> ExecutionResult:
        """Execute Python code in FreeCAD context.

        Args:
            code: Python code to execute.
            timeout_ms: Maximum execution time in milliseconds.

        Returns:
            ExecutionResult with execution outcome.
        """
        if not self._connected:
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr="FreeCAD bridge not connected",
                execution_time_ms=0,
                error_type="ConnectionError",
                error_traceback=None,
            )

        loop = asyncio.get_event_loop()

        try:
            result = await asyncio.wait_for(
                loop.run_in_executor(
                    self._executor,
                    lambda: self._execute_code(code),
                ),
                timeout=timeout_ms / 1000,
            )
        except TimeoutError:
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=f"Execution timed out after {timeout_ms}ms",
                execution_time_ms=float(timeout_ms),
                error_type="TimeoutError",
                error_traceback=None,
            )

        return result

    def _execute_code(self, code: str) -> ExecutionResult:
        """Execute code synchronously (runs in thread pool)."""
        start = time.perf_counter()
        stdout_capture = io.StringIO()
        stderr_capture = io.StringIO()

        # Build execution context
        exec_globals: dict[str, Any] = {
            "FreeCAD": self._fc_module,
            "App": self._fc_module,
            "__builtins__": __builtins__,
        }

        # Try to add GUI module if available
        try:
            import FreeCADGui

            exec_globals["FreeCADGui"] = FreeCADGui
            exec_globals["Gui"] = FreeCADGui
        except ImportError:
            pass

        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 ExecutionResult(
                success=True,
                result=exec_globals.get("_result_"),
                stdout=stdout_capture.getvalue(),
                stderr=stderr_capture.getvalue(),
                execution_time_ms=elapsed,
            )

        except Exception as e:
            import traceback

            elapsed = (time.perf_counter() - start) * 1000

            return ExecutionResult(
                success=False,
                result=None,
                stdout=stdout_capture.getvalue(),
                stderr=stderr_capture.getvalue(),
                execution_time_ms=elapsed,
                error_type=type(e).__name__,
                error_traceback=traceback.format_exc(),
            )

    async def get_documents(self) -> list[DocumentInfo]:
        """Get list of open documents."""
        result = await self.execute_python(
            """
_result_ = []
for doc in FreeCAD.listDocuments().values():
    _result_.append({
        "name": doc.Name,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "label": doc.Label,
    })
"""
        )

        if result.success and result.result:
            return [DocumentInfo(**doc) for doc in result.result]
        return []

    async def get_active_document(self) -> DocumentInfo | None:
        """Get the active document."""
        result = await self.execute_python(
            """
doc = FreeCAD.ActiveDocument
if doc:
    _result_ = {
        "name": doc.Name,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "label": doc.Label,
    }
else:
    _result_ = None
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)
        return None

    async def get_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Get detailed object information."""
        result = await self.execute_python(
            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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

props = {{}}
for prop in obj.PropertiesList:
    try:
        val = getattr(obj, prop)
        if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins':
            val = str(val)
        props[prop] = val
    except Exception:
        props[prop] = "<unreadable>"

shape_info = None
if hasattr(obj, "Shape"):
    shape = obj.Shape
    shape_info = {{
        "type": shape.ShapeType,
        "volume": shape.Volume if hasattr(shape, "Volume") else None,
        "area": shape.Area if hasattr(shape, "Area") else None,
        "is_valid": shape.isValid(),
    }}

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "properties": props,
    "shape_info": shape_info,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
}}
"""
        )

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to get object"
        raise ValueError(error_msg)

    async def get_console_output(
        self,
        lines: int = 100,
    ) -> list[str]:
        """Get recent console output.

        Note: In embedded mode, we don't have direct access to FreeCAD's
        console history, so we return an empty list or captured output.
        """
        return []

    async def get_freecad_version(self) -> dict[str, Any]:
        """Get FreeCAD version information."""
        result = await self.execute_python(
            """
import sys
_result_ = {
    "version": ".".join(str(x) for x in FreeCAD.Version()[:3]),
    "version_tuple": FreeCAD.Version()[:3],
    "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown",
    "python_version": sys.version,
    "gui_available": hasattr(FreeCAD, "GuiUp") and FreeCAD.GuiUp,
}
"""
        )

        if result.success and result.result:
            return result.result

        return {
            "version": "unknown",
            "version_tuple": [],
            "build_date": "unknown",
            "python_version": sys.version,
            "gui_available": False,
        }

    async def is_gui_available(self) -> bool:
        """Check if GUI is available."""
        result = await self.execute_python(
            "_result_ = hasattr(FreeCAD, 'GuiUp') and FreeCAD.GuiUp"
        )
        return bool(result.success and result.result)

    async def ping(self) -> float:
        """Ping FreeCAD to check connection and measure latency.

        Returns:
            Round-trip time in milliseconds.

        Raises:
            ConnectionError: If not connected.
        """
        if not self._connected:
            msg = "Not connected to FreeCAD"
            raise ConnectionError(msg)

        start = time.perf_counter()
        result = await self.execute_python("_result_ = True")
        elapsed = (time.perf_counter() - start) * 1000

        if not result.success:
            msg = "Ping failed"
            raise ConnectionError(msg)

        return elapsed

    async def get_status(self) -> ConnectionStatus:
        """Get detailed connection status.

        Returns:
            ConnectionStatus with full status information.
        """
        if not self._connected:
            return ConnectionStatus(
                connected=False,
                mode="embedded",
                error="Not connected",
            )

        try:
            ping_ms = await self.ping()
            version_info = await self.get_freecad_version()
            gui_available = await self.is_gui_available()

            return ConnectionStatus(
                connected=True,
                mode="embedded",
                freecad_version=version_info.get("version", "unknown"),
                gui_available=gui_available,
                last_ping_ms=ping_ms,
            )
        except Exception as e:
            return ConnectionStatus(
                connected=False,
                mode="embedded",
                error=str(e),
            )

    # =========================================================================
    # Document Management
    # =========================================================================

    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.
        """
        label = label or name
        result = await self.execute_python(
            f"""
doc = FreeCAD.newDocument({name!r})
doc.Label = {label!r}
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [],
    "is_modified": False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create document"
        raise ValueError(error_msg)

    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.
        """
        if not Path(path).exists():
            msg = f"File not found: {path}"
            raise FileNotFoundError(msg)

        result = await self.execute_python(
            f"""
doc = FreeCAD.openDocument({path!r})
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [obj.Name for obj in doc.Objects],
    "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to open document"
        raise ValueError(error_msg)

    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.
        """
        code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}")

save_path = {path!r} or doc.FileName
if not save_path:
    raise ValueError("No path specified for new document")

doc.saveAs(save_path)
_result_ = save_path
"""

        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        error_msg = result.error_traceback or "Failed to save document"
        raise ValueError(error_msg)

    async def close_document(self, doc_name: str | None = None) -> None:
        """Close a document.

        Args:
            doc_name: Document name (uses active if None).
        """
        code = f"""
doc_name = {doc_name!r}
if doc_name is None:
    doc = FreeCAD.ActiveDocument
    if doc:
        doc_name = doc.Name
    else:
        raise ValueError("No active document")

FreeCAD.closeDocument(doc_name)
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to close document"
            raise ValueError(error_msg)

    # =========================================================================
    # Object Management
    # =========================================================================

    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.
        """
        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")

objects = []
for obj in doc.Objects:
    obj_info = {{
        "name": obj.Name,
        "label": obj.Label,
        "type_id": obj.TypeId,
        "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
        "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
        "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    }}
    objects.append(obj_info)

_result_ = objects
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [ObjectInfo(**obj) for obj in result.result]
        return []

    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.
        """
        properties = properties or {}
        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.addObject({type_id!r}, {name!r} or "")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create object"
        raise ValueError(error_msg)

    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.
        """
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to edit object"
        raise ValueError(error_msg)

    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.
        """
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

doc.removeObject({obj_name!r})
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to delete object"
            raise ValueError(error_msg)

    # =========================================================================
    # View and Screenshot
    # =========================================================================

    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.

        Note: In embedded headless mode, screenshots are typically not available.

        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.
        """
        # Check if GUI is available
        gui_available = await self.is_gui_available()

        if not gui_available:
            return ScreenshotResult(
                success=False,
                error="Screenshots not available in headless mode",
                width=width,
                height=height,
                view_angle=view_angle,
            )

        # If GUI is available, attempt screenshot
        view_angle_str = view_angle.value if view_angle else "Isometric"
        code = f"""
import base64
import tempfile
import os

doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No document found")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

# Set view angle
view_type = {view_angle_str!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()

# Save to temp file and read
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 = await self.execute_python(code)

        if result.success and result.result:
            return ScreenshotResult(
                success=True,
                data=result.result["data"],
                format=result.result["format"],
                width=result.result["width"],
                height=result.result["height"],
                view_angle=view_angle,
            )

        return ScreenshotResult(
            success=False,
            error=result.error_traceback or "Failed to capture screenshot",
            width=width,
            height=height,
            view_angle=view_angle,
        )

    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).
        """
        gui_available = await self.is_gui_available()

        if not gui_available:
            return  # Silently ignore in headless mode

        view_angle_str = view_angle.value
        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")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

view_type = {view_angle_str!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()

_result_ = True
"""
        await self.execute_python(code)

    # =========================================================================
    # Macros
    # =========================================================================

    def _get_macro_path(self) -> Path:
        """Get the FreeCAD macro directory path."""
        # Default FreeCAD macro locations
        if sys.platform == "darwin":
            return Path.home() / "Library" / "Application Support" / "FreeCAD" / "Macro"
        elif sys.platform == "win32":
            return Path(os.environ.get("APPDATA", "")) / "FreeCAD" / "Macro"
        else:
            return Path.home() / ".local" / "share" / "FreeCAD" / "Macro"

    async def get_macros(self) -> list[MacroInfo]:
        """Get list of available macros.

        Returns:
            List of MacroInfo for each macro.
        """
        macro_path = self._get_macro_path()

        if not macro_path.exists():
            return []

        macros = []
        for macro_file in macro_path.glob("*.FCMacro"):
            description = ""
            try:
                content = macro_file.read_text()
                # Extract description from first comment block
                for line in content.split("\n"):
                    if line.startswith("#"):
                        desc_line = line.lstrip("#").strip()
                        if desc_line and not desc_line.startswith("!"):
                            description = desc_line
                            break
            except Exception:
                pass

            macros.append(
                MacroInfo(
                    name=macro_file.stem,
                    path=str(macro_file),
                    description=description,
                    is_system=False,
                )
            )

        return macros

    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.
        """
        macro_path = self._get_macro_path() / f"{macro_name}.FCMacro"

        if not macro_path.exists():
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=f"Macro not found: {macro_name}",
                execution_time_ms=0,
                error_type="FileNotFoundError",
            )

        try:
            macro_code = macro_path.read_text()
        except Exception as e:
            return ExecutionResult(
                success=False,
                result=None,
                stdout="",
                stderr=f"Failed to read macro: {e}",
                execution_time_ms=0,
                error_type=type(e).__name__,
            )

        # Prepend argument setup if provided
        if args:
            args_setup = "\n".join(f"{k} = {v!r}" for k, v in args.items())
            macro_code = args_setup + "\n" + macro_code

        return await self.execute_python(macro_code)

    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.
        """
        macro_path = self._get_macro_path()
        macro_path.mkdir(parents=True, exist_ok=True)

        macro_file = macro_path / f"{name}.FCMacro"

        # Add description as header comment
        header = f"# {description}\n\n" if description else ""

        # Add standard imports
        full_code = f"""{header}# -*- coding: utf-8 -*-
# FreeCAD Macro: {name}
# Created via MCP Bridge

import FreeCAD
import FreeCADGui

{code}
"""
        macro_file.write_text(full_code)

        return MacroInfo(
            name=name,
            path=str(macro_file),
            description=description,
            is_system=False,
        )

    # =========================================================================
    # Workbenches
    # =========================================================================

    async def get_workbenches(self) -> list[WorkbenchInfo]:
        """Get list of available workbenches.

        Returns:
            List of WorkbenchInfo for each workbench.
        """
        gui_available = await self.is_gui_available()

        if not gui_available:
            # Return common workbenches for headless mode
            common_workbenches = [
                "StartWorkbench",
                "PartWorkbench",
                "PartDesignWorkbench",
                "DraftWorkbench",
                "SketcherWorkbench",
                "MeshWorkbench",
                "SpreadsheetWorkbench",
            ]
            return [
                WorkbenchInfo(name=wb, label=wb.replace("Workbench", ""))
                for wb in common_workbenches
            ]

        code = """
workbenches = []
active_wb = FreeCADGui.activeWorkbench()
active_name = active_wb.__class__.__name__ if active_wb else None

for name in FreeCADGui.listWorkbenches():
    wb = FreeCADGui.getWorkbench(name)
    workbenches.append({
        "name": name,
        "label": wb.MenuText if hasattr(wb, "MenuText") else name,
        "icon": wb.Icon if hasattr(wb, "Icon") else "",
        "is_active": name == active_name,
    })

_result_ = workbenches
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [WorkbenchInfo(**wb) for wb in result.result]
        return []

    async def activate_workbench(self, workbench_name: str) -> None:
        """Activate a workbench.

        Args:
            workbench_name: Workbench internal name.

        Raises:
            ValueError: If workbench not found.
        """
        gui_available = await self.is_gui_available()

        if not gui_available:
            return  # Silently ignore in headless mode

        code = f"""
try:
    FreeCADGui.activateWorkbench({workbench_name!r})
    _result_ = True
except Exception as e:
    raise ValueError(f"Failed to activate workbench: {{e}}")
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to activate workbench"
            raise ValueError(error_msg)
Functions
__init__(freecad_path=None)

Initialize the embedded bridge.

Parameters:

Name Type Description Default
freecad_path str | None

Path to FreeCAD's lib directory. If provided, this path will be added to sys.path before importing.

None
Source code in src/freecad_mcp/bridge/embedded.py
def __init__(self, freecad_path: str | None = None) -> None:
    """Initialize the embedded bridge.

    Args:
        freecad_path: Path to FreeCAD's lib directory. If provided,
            this path will be added to sys.path before importing.
    """
    self._freecad_path = freecad_path
    self._fc_module: Any = None
    self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="freecad")
    self._connected = False
activate_workbench(workbench_name) async

Activate a workbench.

Parameters:

Name Type Description Default
workbench_name str

Workbench internal name.

required

Raises:

Type Description
ValueError

If workbench not found.

Source code in src/freecad_mcp/bridge/embedded.py
    async def activate_workbench(self, workbench_name: str) -> None:
        """Activate a workbench.

        Args:
            workbench_name: Workbench internal name.

        Raises:
            ValueError: If workbench not found.
        """
        gui_available = await self.is_gui_available()

        if not gui_available:
            return  # Silently ignore in headless mode

        code = f"""
try:
    FreeCADGui.activateWorkbench({workbench_name!r})
    _result_ = True
except Exception as e:
    raise ValueError(f"Failed to activate workbench: {{e}}")
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to activate workbench"
            raise ValueError(error_msg)
close_document(doc_name=None) async

Close a document.

Parameters:

Name Type Description Default
doc_name str | None

Document name (uses active if None).

None
Source code in src/freecad_mcp/bridge/embedded.py
    async def close_document(self, doc_name: str | None = None) -> None:
        """Close a document.

        Args:
            doc_name: Document name (uses active if None).
        """
        code = f"""
doc_name = {doc_name!r}
if doc_name is None:
    doc = FreeCAD.ActiveDocument
    if doc:
        doc_name = doc.Name
    else:
        raise ValueError("No active document")

FreeCAD.closeDocument(doc_name)
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to close document"
            raise ValueError(error_msg)
connect() async

Import and initialize FreeCAD.

Raises:

Type Description
ConnectionError

If FreeCAD cannot be imported.

Source code in src/freecad_mcp/bridge/embedded.py
async def connect(self) -> None:
    """Import and initialize FreeCAD.

    Raises:
        ConnectionError: If FreeCAD cannot be imported.
    """
    if self._freecad_path:
        sys.path.insert(0, self._freecad_path)

    loop = asyncio.get_event_loop()
    try:
        self._fc_module = await loop.run_in_executor(
            self._executor,
            self._import_freecad,
        )
        self._connected = True
    except ImportError as e:
        msg = f"Failed to import FreeCAD: {e}"
        raise ConnectionError(msg) from e
create_document(name, label=None) async

Create a new document.

Parameters:

Name Type Description Default
name str

Internal document name (no spaces).

required
label str | None

Display label (optional, defaults to name).

None

Returns:

Type Description
DocumentInfo

DocumentInfo for the created document.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        label = label or name
        result = await self.execute_python(
            f"""
doc = FreeCAD.newDocument({name!r})
doc.Label = {label!r}
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [],
    "is_modified": False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create document"
        raise ValueError(error_msg)
create_macro(name, code, description='') async

Create a new macro.

Parameters:

Name Type Description Default
name str

Macro name (without extension).

required
code str

Python code for the macro.

required
description str

Macro description.

''

Returns:

Type Description
MacroInfo

MacroInfo for the created macro.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        macro_path = self._get_macro_path()
        macro_path.mkdir(parents=True, exist_ok=True)

        macro_file = macro_path / f"{name}.FCMacro"

        # Add description as header comment
        header = f"# {description}\n\n" if description else ""

        # Add standard imports
        full_code = f"""{header}# -*- coding: utf-8 -*-
# FreeCAD Macro: {name}
# Created via MCP Bridge

import FreeCAD
import FreeCADGui

{code}
"""
        macro_file.write_text(full_code)

        return MacroInfo(
            name=name,
            path=str(macro_file),
            description=description,
            is_system=False,
        )
create_object(type_id, name=None, properties=None, doc_name=None) async

Create a new object.

Parameters:

Name Type Description Default
type_id str

FreeCAD type ID (e.g., "Part::Box", "Part::Cylinder").

required
name str | None

Object name (auto-generated if None).

None
properties dict[str, Any] | None

Initial property values.

None
doc_name str | None

Target document (uses active if None).

None

Returns:

Type Description
ObjectInfo

ObjectInfo for the created object.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        properties = properties or {}
        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.addObject({type_id!r}, {name!r} or "")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to create object"
        raise ValueError(error_msg)
delete_object(obj_name, doc_name=None) async

Delete an object.

Parameters:

Name Type Description Default
obj_name str

Name of the object to delete.

required
doc_name str | None

Document name (uses active if None).

None

Raises:

Type Description
ValueError

If object not found.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

doc.removeObject({obj_name!r})
_result_ = True
"""
        result = await self.execute_python(code)

        if not result.success:
            error_msg = result.error_traceback or "Failed to delete object"
            raise ValueError(error_msg)
disconnect() async

Clean up resources.

Source code in src/freecad_mcp/bridge/embedded.py
async def disconnect(self) -> None:
    """Clean up resources."""
    self._connected = False
    self._executor.shutdown(wait=True)
edit_object(obj_name, properties, doc_name=None) async

Edit object properties.

Parameters:

Name Type Description Default
obj_name str

Name of the object to edit.

required
properties dict[str, Any]

Property values to set.

required
doc_name str | None

Document name (uses active if None).

None

Returns:

Type Description
ObjectInfo

Updated ObjectInfo.

Raises:

Type Description
ValueError

If object not found.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

# Set properties
for prop_name, prop_val in {properties!r}.items():
    if hasattr(obj, prop_name):
        setattr(obj, prop_name, prop_val)

doc.recompute()

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
    "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
}}
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to edit object"
        raise ValueError(error_msg)
execute_python(code, timeout_ms=30000) async

Execute Python code in FreeCAD context.

Parameters:

Name Type Description Default
code str

Python code to execute.

required
timeout_ms int

Maximum execution time in milliseconds.

30000

Returns:

Type Description
ExecutionResult

ExecutionResult with execution outcome.

Source code in src/freecad_mcp/bridge/embedded.py
async def execute_python(
    self,
    code: str,
    timeout_ms: int = 30000,
) -> ExecutionResult:
    """Execute Python code in FreeCAD context.

    Args:
        code: Python code to execute.
        timeout_ms: Maximum execution time in milliseconds.

    Returns:
        ExecutionResult with execution outcome.
    """
    if not self._connected:
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr="FreeCAD bridge not connected",
            execution_time_ms=0,
            error_type="ConnectionError",
            error_traceback=None,
        )

    loop = asyncio.get_event_loop()

    try:
        result = await asyncio.wait_for(
            loop.run_in_executor(
                self._executor,
                lambda: self._execute_code(code),
            ),
            timeout=timeout_ms / 1000,
        )
    except TimeoutError:
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=f"Execution timed out after {timeout_ms}ms",
            execution_time_ms=float(timeout_ms),
            error_type="TimeoutError",
            error_traceback=None,
        )

    return result
get_active_document() async

Get the active document.

Source code in src/freecad_mcp/bridge/embedded.py
    async def get_active_document(self) -> DocumentInfo | None:
        """Get the active document."""
        result = await self.execute_python(
            """
doc = FreeCAD.ActiveDocument
if doc:
    _result_ = {
        "name": doc.Name,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "label": doc.Label,
    }
else:
    _result_ = None
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)
        return None
get_console_output(lines=100) async

Get recent console output.

Note: In embedded mode, we don't have direct access to FreeCAD's console history, so we return an empty list or captured output.

Source code in src/freecad_mcp/bridge/embedded.py
async def get_console_output(
    self,
    lines: int = 100,
) -> list[str]:
    """Get recent console output.

    Note: In embedded mode, we don't have direct access to FreeCAD's
    console history, so we return an empty list or captured output.
    """
    return []
get_documents() async

Get list of open documents.

Source code in src/freecad_mcp/bridge/embedded.py
    async def get_documents(self) -> list[DocumentInfo]:
        """Get list of open documents."""
        result = await self.execute_python(
            """
_result_ = []
for doc in FreeCAD.listDocuments().values():
    _result_.append({
        "name": doc.Name,
        "path": doc.FileName or None,
        "objects": [obj.Name for obj in doc.Objects],
        "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
        "label": doc.Label,
    })
"""
        )

        if result.success and result.result:
            return [DocumentInfo(**doc) for doc in result.result]
        return []
get_freecad_version() async

Get FreeCAD version information.

Source code in src/freecad_mcp/bridge/embedded.py
    async def get_freecad_version(self) -> dict[str, Any]:
        """Get FreeCAD version information."""
        result = await self.execute_python(
            """
import sys
_result_ = {
    "version": ".".join(str(x) for x in FreeCAD.Version()[:3]),
    "version_tuple": FreeCAD.Version()[:3],
    "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown",
    "python_version": sys.version,
    "gui_available": hasattr(FreeCAD, "GuiUp") and FreeCAD.GuiUp,
}
"""
        )

        if result.success and result.result:
            return result.result

        return {
            "version": "unknown",
            "version_tuple": [],
            "build_date": "unknown",
            "python_version": sys.version,
            "gui_available": False,
        }
get_macros() async

Get list of available macros.

Returns:

Type Description
list[MacroInfo]

List of MacroInfo for each macro.

Source code in src/freecad_mcp/bridge/embedded.py
async def get_macros(self) -> list[MacroInfo]:
    """Get list of available macros.

    Returns:
        List of MacroInfo for each macro.
    """
    macro_path = self._get_macro_path()

    if not macro_path.exists():
        return []

    macros = []
    for macro_file in macro_path.glob("*.FCMacro"):
        description = ""
        try:
            content = macro_file.read_text()
            # Extract description from first comment block
            for line in content.split("\n"):
                if line.startswith("#"):
                    desc_line = line.lstrip("#").strip()
                    if desc_line and not desc_line.startswith("!"):
                        description = desc_line
                        break
        except Exception:
            pass

        macros.append(
            MacroInfo(
                name=macro_file.stem,
                path=str(macro_file),
                description=description,
                is_system=False,
            )
        )

    return macros
get_object(obj_name, doc_name=None) async

Get detailed object information.

Source code in src/freecad_mcp/bridge/embedded.py
    async def get_object(
        self,
        obj_name: str,
        doc_name: str | None = None,
    ) -> ObjectInfo:
        """Get detailed object information."""
        result = await self.execute_python(
            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({obj_name!r})
if obj is None:
    raise ValueError(f"Object not found: {obj_name!r}")

props = {{}}
for prop in obj.PropertiesList:
    try:
        val = getattr(obj, prop)
        if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins':
            val = str(val)
        props[prop] = val
    except Exception:
        props[prop] = "<unreadable>"

shape_info = None
if hasattr(obj, "Shape"):
    shape = obj.Shape
    shape_info = {{
        "type": shape.ShapeType,
        "volume": shape.Volume if hasattr(shape, "Volume") else None,
        "area": shape.Area if hasattr(shape, "Area") else None,
        "is_valid": shape.isValid(),
    }}

_result_ = {{
    "name": obj.Name,
    "label": obj.Label,
    "type_id": obj.TypeId,
    "properties": props,
    "shape_info": shape_info,
    "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
}}
"""
        )

        if result.success and result.result:
            return ObjectInfo(**result.result)

        error_msg = result.error_traceback or "Failed to get object"
        raise ValueError(error_msg)
get_objects(doc_name=None) async

Get all objects in a document.

Parameters:

Name Type Description Default
doc_name str | None

Document name (uses active if None).

None

Returns:

Type Description
list[ObjectInfo]

List of ObjectInfo for each object.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        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")

objects = []
for obj in doc.Objects:
    obj_info = {{
        "name": obj.Name,
        "label": obj.Label,
        "type_id": obj.TypeId,
        "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
        "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
        "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
    }}
    objects.append(obj_info)

_result_ = objects
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [ObjectInfo(**obj) for obj in result.result]
        return []
get_screenshot(view_angle=None, width=800, height=600, doc_name=None) async

Capture a screenshot of the 3D view.

Note: In embedded headless mode, screenshots are typically not available.

Parameters:

Name Type Description Default
view_angle ViewAngle | None

View angle to set before capture.

None
width int

Image width in pixels.

800
height int

Image height in pixels.

600
doc_name str | None

Document name (uses active if None).

None

Returns:

Type Description
ScreenshotResult

ScreenshotResult with image data or error.

Source code in src/freecad_mcp/bridge/embedded.py
    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.

        Note: In embedded headless mode, screenshots are typically not available.

        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.
        """
        # Check if GUI is available
        gui_available = await self.is_gui_available()

        if not gui_available:
            return ScreenshotResult(
                success=False,
                error="Screenshots not available in headless mode",
                width=width,
                height=height,
                view_angle=view_angle,
            )

        # If GUI is available, attempt screenshot
        view_angle_str = view_angle.value if view_angle else "Isometric"
        code = f"""
import base64
import tempfile
import os

doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No document found")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

# Set view angle
view_type = {view_angle_str!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()

# Save to temp file and read
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 = await self.execute_python(code)

        if result.success and result.result:
            return ScreenshotResult(
                success=True,
                data=result.result["data"],
                format=result.result["format"],
                width=result.result["width"],
                height=result.result["height"],
                view_angle=view_angle,
            )

        return ScreenshotResult(
            success=False,
            error=result.error_traceback or "Failed to capture screenshot",
            width=width,
            height=height,
            view_angle=view_angle,
        )
get_status() async

Get detailed connection status.

Returns:

Type Description
ConnectionStatus

ConnectionStatus with full status information.

Source code in src/freecad_mcp/bridge/embedded.py
async def get_status(self) -> ConnectionStatus:
    """Get detailed connection status.

    Returns:
        ConnectionStatus with full status information.
    """
    if not self._connected:
        return ConnectionStatus(
            connected=False,
            mode="embedded",
            error="Not connected",
        )

    try:
        ping_ms = await self.ping()
        version_info = await self.get_freecad_version()
        gui_available = await self.is_gui_available()

        return ConnectionStatus(
            connected=True,
            mode="embedded",
            freecad_version=version_info.get("version", "unknown"),
            gui_available=gui_available,
            last_ping_ms=ping_ms,
        )
    except Exception as e:
        return ConnectionStatus(
            connected=False,
            mode="embedded",
            error=str(e),
        )
get_workbenches() async

Get list of available workbenches.

Returns:

Type Description
list[WorkbenchInfo]

List of WorkbenchInfo for each workbench.

Source code in src/freecad_mcp/bridge/embedded.py
    async def get_workbenches(self) -> list[WorkbenchInfo]:
        """Get list of available workbenches.

        Returns:
            List of WorkbenchInfo for each workbench.
        """
        gui_available = await self.is_gui_available()

        if not gui_available:
            # Return common workbenches for headless mode
            common_workbenches = [
                "StartWorkbench",
                "PartWorkbench",
                "PartDesignWorkbench",
                "DraftWorkbench",
                "SketcherWorkbench",
                "MeshWorkbench",
                "SpreadsheetWorkbench",
            ]
            return [
                WorkbenchInfo(name=wb, label=wb.replace("Workbench", ""))
                for wb in common_workbenches
            ]

        code = """
workbenches = []
active_wb = FreeCADGui.activeWorkbench()
active_name = active_wb.__class__.__name__ if active_wb else None

for name in FreeCADGui.listWorkbenches():
    wb = FreeCADGui.getWorkbench(name)
    workbenches.append({
        "name": name,
        "label": wb.MenuText if hasattr(wb, "MenuText") else name,
        "icon": wb.Icon if hasattr(wb, "Icon") else "",
        "is_active": name == active_name,
    })

_result_ = workbenches
"""
        result = await self.execute_python(code)

        if result.success and result.result:
            return [WorkbenchInfo(**wb) for wb in result.result]
        return []
is_connected() async

Check if FreeCAD is imported and available.

Source code in src/freecad_mcp/bridge/embedded.py
async def is_connected(self) -> bool:
    """Check if FreeCAD is imported and available."""
    return self._connected and self._fc_module is not None
is_gui_available() async

Check if GUI is available.

Source code in src/freecad_mcp/bridge/embedded.py
async def is_gui_available(self) -> bool:
    """Check if GUI is available."""
    result = await self.execute_python(
        "_result_ = hasattr(FreeCAD, 'GuiUp') and FreeCAD.GuiUp"
    )
    return bool(result.success and result.result)
open_document(path) async

Open an existing document.

Parameters:

Name Type Description Default
path str

Path to the .FCStd file.

required

Returns:

Type Description
DocumentInfo

DocumentInfo for the opened document.

Raises:

Type Description
FileNotFoundError

If file doesn't exist.

ValueError

If file is not a valid FreeCAD document.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        if not Path(path).exists():
            msg = f"File not found: {path}"
            raise FileNotFoundError(msg)

        result = await self.execute_python(
            f"""
doc = FreeCAD.openDocument({path!r})
_result_ = {{
    "name": doc.Name,
    "label": doc.Label,
    "path": doc.FileName or None,
    "objects": [obj.Name for obj in doc.Objects],
    "is_modified": doc.Modified if hasattr(doc, "Modified") else False,
}}
"""
        )

        if result.success and result.result:
            return DocumentInfo(**result.result)

        error_msg = result.error_traceback or "Failed to open document"
        raise ValueError(error_msg)
ping() async

Ping FreeCAD to check connection and measure latency.

Returns:

Type Description
float

Round-trip time in milliseconds.

Raises:

Type Description
ConnectionError

If not connected.

Source code in src/freecad_mcp/bridge/embedded.py
async def ping(self) -> float:
    """Ping FreeCAD to check connection and measure latency.

    Returns:
        Round-trip time in milliseconds.

    Raises:
        ConnectionError: If not connected.
    """
    if not self._connected:
        msg = "Not connected to FreeCAD"
        raise ConnectionError(msg)

    start = time.perf_counter()
    result = await self.execute_python("_result_ = True")
    elapsed = (time.perf_counter() - start) * 1000

    if not result.success:
        msg = "Ping failed"
        raise ConnectionError(msg)

    return elapsed
run_macro(macro_name, args=None) async

Run a macro by name.

Parameters:

Name Type Description Default
macro_name str

Macro name (without .FCMacro extension).

required
args dict[str, Any] | None

Arguments to pass to the macro.

None

Returns:

Type Description
ExecutionResult

ExecutionResult from macro execution.

Source code in src/freecad_mcp/bridge/embedded.py
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.
    """
    macro_path = self._get_macro_path() / f"{macro_name}.FCMacro"

    if not macro_path.exists():
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=f"Macro not found: {macro_name}",
            execution_time_ms=0,
            error_type="FileNotFoundError",
        )

    try:
        macro_code = macro_path.read_text()
    except Exception as e:
        return ExecutionResult(
            success=False,
            result=None,
            stdout="",
            stderr=f"Failed to read macro: {e}",
            execution_time_ms=0,
            error_type=type(e).__name__,
        )

    # Prepend argument setup if provided
    if args:
        args_setup = "\n".join(f"{k} = {v!r}" for k, v in args.items())
        macro_code = args_setup + "\n" + macro_code

    return await self.execute_python(macro_code)
save_document(doc_name=None, path=None) async

Save a document.

Parameters:

Name Type Description Default
doc_name str | None

Document name (uses active if None).

None
path str | None

Save path (uses existing path if None).

None

Returns:

Type Description
str

Path where document was saved.

Raises:

Type Description
ValueError

If document not found or no path specified for new doc.

Source code in src/freecad_mcp/bridge/embedded.py
    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.
        """
        code = f"""
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
    raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}")

save_path = {path!r} or doc.FileName
if not save_path:
    raise ValueError("No path specified for new document")

doc.saveAs(save_path)
_result_ = save_path
"""

        result = await self.execute_python(code)

        if result.success and result.result:
            return result.result

        error_msg = result.error_traceback or "Failed to save document"
        raise ValueError(error_msg)
set_view(view_angle, doc_name=None) async

Set the 3D view angle.

Parameters:

Name Type Description Default
view_angle ViewAngle

View angle to set.

required
doc_name str | None

Document name (uses active if None).

None
Source code in src/freecad_mcp/bridge/embedded.py
    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).
        """
        gui_available = await self.is_gui_available()

        if not gui_available:
            return  # Silently ignore in headless mode

        view_angle_str = view_angle.value
        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")

view = FreeCADGui.ActiveDocument.ActiveView
if view is None:
    raise ValueError("No active view")

view_type = {view_angle_str!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()

_result_ = True
"""
        await self.execute_python(code)

options: show_root_heading: true show_source: true