ci: Add CI and Release workflows and other test fixes (#7)
* ci: add caching and fix workflow failures - Add UV package caching to all workflows for faster dependency installation - Add pre-commit hook caching with OS-specific cache keys - Skip no-commit-to-branch hook in CI (fails on main branch) - Remove broken apt cache action (doesn't work with PPAs) - Simplify FreeCAD command detection (PPA installs to standard PATH) - Add FreeCAD Python version logging for debugging * ci: Add code Rabbit configuration file * ci: fix issues in CI workflows * ci: add uv.lock * ci: tweaks * ci: Fix errors and add UUID generation and checking * ci: Use the GitHub FreeCAD release latest stables * ci: skip macro test for now, due to headless mode * feat: Add a multi export macro * ci: Fix tests * ci: fix docker build workflow * ci: skip trufflehog in GitHub Actions due to wasm panic bug TruffleHog has a known wasm/go-re2 panic bug that causes failures in GitHub Actions environment. The hook still runs locally during development for secrets detection. - Add trufflehog to SKIP env var in pre-commit.yaml - Update trufflehog to v3.88.7 (latest) - Add reference to upstream issue #3321 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: use boolean holes instead of PartDesign::Hole in CI PartDesign::Hole has a CADKernelError bug in FreeCAD AppImage headless mode on Linux (used in GitHub Actions) where it fails with "Cannot make face from profile". The SmartCutter class already supports boolean holes as an alternative. Changes: - Modify SmartCutter.execute() to use boolean holes by default - Update all test assertions for Part::Feature output type - Update test docstrings and class descriptions - Remove PartDesign-specific checks (Group, Sketcher::SketchObject) - Update workflow comment explaining the CI limitation Boolean holes work reliably in both GUI and headless mode across all FreeCAD configurations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: clean up GitHub Actions workflows * ci: Dependabot improvements * ci: add CodeQL scanning workflow * ci: AI review suggested improvements * ci: add coderabbit updates for intentional decisions --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
8d1271995e
commit
cd77560297
@@ -1,5 +1,8 @@
|
||||
"""FreeCAD MCP Server - AI assistant integration for FreeCAD.
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
|
||||
|
||||
This package provides an MCP (Model Context Protocol) server that enables
|
||||
integration between AI assistants (Claude, GPT, etc.) and FreeCAD, allowing
|
||||
AI-assisted development and debugging of 3D models, macros, and workbenches.
|
||||
@@ -15,7 +18,18 @@ Example:
|
||||
>>> main()
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version("freecad-robust-mcp")
|
||||
except PackageNotFoundError:
|
||||
# Package is not installed (running from source without pip install -e)
|
||||
# Fall back to the generated _version.py if available
|
||||
try:
|
||||
from freecad_mcp._version import __version__
|
||||
except ImportError:
|
||||
__version__ = "0.0.0.dev0+unknown"
|
||||
|
||||
__author__ = "Sean P. Kane"
|
||||
__email__ = "spkane@gmail.com"
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
import xmlrpc.server
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Any
|
||||
@@ -96,6 +98,9 @@ class FreecadMCPPlugin:
|
||||
xmlrpc_port: Port for XML-RPC server.
|
||||
enable_xmlrpc: Whether to enable XML-RPC server.
|
||||
"""
|
||||
# Generate unique instance ID for this server
|
||||
self._instance_id = str(uuid.uuid4())
|
||||
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._xmlrpc_port = xmlrpc_port
|
||||
@@ -129,6 +134,14 @@ class FreecadMCPPlugin:
|
||||
|
||||
self._running = True
|
||||
|
||||
# Print instance ID to stdout for test automation to capture
|
||||
# This is printed before logging to ensure it's easily parseable
|
||||
print(
|
||||
f"FREECAD_MCP_BRIDGE_INSTANCE_ID={self._instance_id}",
|
||||
file=sys.stdout,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Start the queue processing timer on the main thread
|
||||
self._start_queue_processor()
|
||||
|
||||
@@ -151,7 +164,8 @@ class FreecadMCPPlugin:
|
||||
|
||||
if FREECAD_AVAILABLE:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"MCP Bridge started:\n - JSON-RPC: {self._host}:{self._port}\n"
|
||||
f"MCP Bridge started (Instance ID: {self._instance_id}):\n"
|
||||
f" - JSON-RPC: {self._host}:{self._port}\n"
|
||||
)
|
||||
if self._enable_xmlrpc:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
@@ -555,7 +569,19 @@ class FreecadMCPPlugin:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {"pong": True, "timestamp": time.time()},
|
||||
"result": {
|
||||
"pong": True,
|
||||
"timestamp": time.time(),
|
||||
"instance_id": self._instance_id,
|
||||
},
|
||||
}
|
||||
|
||||
# Handle get_instance_id specially (no queue needed)
|
||||
if method == "get_instance_id":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {"instance_id": self._instance_id},
|
||||
}
|
||||
|
||||
# Handle execute via queue
|
||||
@@ -602,6 +628,9 @@ class FreecadMCPPlugin:
|
||||
# Register methods (type: ignore needed - xmlrpc types are overly restrictive)
|
||||
self._xmlrpc_server.register_function(self._xmlrpc_execute, "execute") # type: ignore[arg-type]
|
||||
self._xmlrpc_server.register_function(self._xmlrpc_ping, "ping") # type: ignore[arg-type]
|
||||
self._xmlrpc_server.register_function(
|
||||
self._xmlrpc_get_instance_id, "get_instance_id"
|
||||
) # type: ignore[arg-type]
|
||||
self._xmlrpc_server.register_function(self._xmlrpc_get_view, "get_view") # type: ignore[arg-type]
|
||||
self._xmlrpc_server.register_introspection_functions()
|
||||
|
||||
@@ -610,7 +639,19 @@ class FreecadMCPPlugin:
|
||||
|
||||
def _xmlrpc_ping(self) -> dict[str, Any]:
|
||||
"""XML-RPC ping handler."""
|
||||
return {"pong": True, "timestamp": time.time()}
|
||||
return {
|
||||
"pong": True,
|
||||
"timestamp": time.time(),
|
||||
"instance_id": self._instance_id,
|
||||
}
|
||||
|
||||
def _xmlrpc_get_instance_id(self) -> dict[str, Any]:
|
||||
"""XML-RPC get_instance_id handler.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the unique instance ID for this bridge.
|
||||
"""
|
||||
return {"instance_id": self._instance_id}
|
||||
|
||||
def _xmlrpc_execute(self, code: str) -> dict[str, Any]:
|
||||
"""XML-RPC execute handler (neka-nat compatible).
|
||||
|
||||
@@ -358,7 +358,7 @@ def register_resources(mcp, get_bridge) -> None:
|
||||
},
|
||||
{
|
||||
"name": "get_mcp_server_environment",
|
||||
"description": "Get MCP server environment info (OS, hostname, Docker detection)",
|
||||
"description": "Get MCP server environment info (instance_id, OS, hostname, Docker detection)",
|
||||
"key_params": [],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,8 @@ Example:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -43,10 +45,23 @@ if TYPE_CHECKING:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Generate unique instance ID at module load time
|
||||
# This ID is stable for the lifetime of this server process
|
||||
INSTANCE_ID: str = str(uuid.uuid4())
|
||||
|
||||
# Global bridge instance (initialized on startup via lifespan)
|
||||
_bridge: Any = None
|
||||
|
||||
|
||||
def get_instance_id() -> str:
|
||||
"""Get the unique instance ID for this MCP server process.
|
||||
|
||||
Returns:
|
||||
The UUID string that uniquely identifies this server instance.
|
||||
"""
|
||||
return INSTANCE_ID
|
||||
|
||||
|
||||
async def get_bridge() -> "FreecadBridge":
|
||||
"""Get the active FreeCAD bridge.
|
||||
|
||||
@@ -169,7 +184,13 @@ def main() -> None:
|
||||
|
||||
# Set up logging
|
||||
logging.getLogger().setLevel(config.log_level)
|
||||
|
||||
# Print instance ID to stdout for test automation to capture
|
||||
# This is printed before logging to ensure it's easily parseable
|
||||
print(f"FREECAD_MCP_INSTANCE_ID={INSTANCE_ID}", file=sys.stdout, flush=True)
|
||||
|
||||
logger.info("Starting FreeCAD MCP server")
|
||||
logger.info("Instance ID: %s", INSTANCE_ID)
|
||||
logger.info("Mode: %s", config.mode.value)
|
||||
logger.info("Transport: %s", config.transport.value)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import socket
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from freecad_mcp.server import get_instance_id
|
||||
|
||||
|
||||
def register_execution_tools(mcp, get_bridge) -> None:
|
||||
"""Register execution-related tools with the MCP server.
|
||||
@@ -130,14 +132,18 @@ def register_execution_tools(mcp, get_bridge) -> None:
|
||||
|
||||
@mcp.tool()
|
||||
async def get_mcp_server_environment() -> dict[str, Any]:
|
||||
"""Get environment information about the MCP server process.
|
||||
"""Get environment information about the MCP server and FreeCAD connection.
|
||||
|
||||
This tool returns information about the environment where the MCP server
|
||||
is running, which is useful for debugging and verifying which MCP server
|
||||
instance you are connected to (e.g., host vs Docker container).
|
||||
is running and the FreeCAD connection state, which is useful for debugging,
|
||||
verifying which MCP server instance you are connected to (e.g., host vs
|
||||
Docker container), and determining if GUI features are available.
|
||||
|
||||
Returns:
|
||||
Dictionary containing environment information:
|
||||
- instance_id: Unique UUID for this server instance (generated at
|
||||
startup). Use this to verify you're connected to the expected
|
||||
server instance in tests and automation.
|
||||
- hostname: Machine hostname
|
||||
- os_name: Operating system name (Linux, Darwin, Windows)
|
||||
- os_version: Operating system version
|
||||
@@ -145,6 +151,14 @@ def register_execution_tools(mcp, get_bridge) -> None:
|
||||
- python_version: Python version running the MCP server
|
||||
- in_docker: Whether running inside a Docker container
|
||||
- docker_container_id: Container ID if in Docker (first 12 chars)
|
||||
- freecad: FreeCAD connection information:
|
||||
- connected: Whether bridge is connected to FreeCAD
|
||||
- mode: Connection mode (embedded, xmlrpc, socket)
|
||||
- version: FreeCAD version string
|
||||
- gui_available: Whether FreeCAD GUI is available (False in
|
||||
headless mode). Use this to skip GUI-only tests.
|
||||
- is_headless: Convenience boolean, True when GUI is NOT
|
||||
available (opposite of gui_available)
|
||||
- env_vars: Selected environment variables for debugging:
|
||||
- FREECAD_MODE: Connection mode
|
||||
- FREECAD_SOCKET_HOST: Socket host
|
||||
@@ -152,6 +166,18 @@ def register_execution_tools(mcp, get_bridge) -> None:
|
||||
- FREECAD_XMLRPC_PORT: XML-RPC port
|
||||
|
||||
Example:
|
||||
Verify you're connected to the expected server instance::
|
||||
|
||||
env = get_mcp_server_environment()
|
||||
expected_id = "abc123..." # Captured from server startup output
|
||||
assert env["instance_id"] == expected_id
|
||||
|
||||
Skip GUI-only tests in headless mode::
|
||||
|
||||
env = get_mcp_server_environment()
|
||||
if env["freecad"]["is_headless"]:
|
||||
pytest.skip("Test requires GUI mode")
|
||||
|
||||
Verify you're talking to the containerized MCP server::
|
||||
|
||||
env = get_mcp_server_environment()
|
||||
@@ -199,7 +225,12 @@ def register_execution_tools(mcp, get_bridge) -> None:
|
||||
|
||||
in_docker, container_id = _detect_docker()
|
||||
|
||||
# Get FreeCAD connection status
|
||||
bridge = await get_bridge()
|
||||
status = await bridge.get_status()
|
||||
|
||||
return {
|
||||
"instance_id": get_instance_id(),
|
||||
"hostname": socket.gethostname(),
|
||||
"os_name": platform.system(),
|
||||
"os_version": platform.release(),
|
||||
@@ -207,6 +238,13 @@ def register_execution_tools(mcp, get_bridge) -> None:
|
||||
"python_version": platform.python_version(),
|
||||
"in_docker": in_docker,
|
||||
"docker_container_id": container_id,
|
||||
"freecad": {
|
||||
"connected": status.connected,
|
||||
"mode": status.mode,
|
||||
"version": status.freecad_version,
|
||||
"gui_available": status.gui_available,
|
||||
"is_headless": not status.gui_available,
|
||||
},
|
||||
"env_vars": {
|
||||
"FREECAD_MODE": os.environ.get("FREECAD_MODE", ""),
|
||||
"FREECAD_SOCKET_HOST": os.environ.get("FREECAD_SOCKET_HOST", ""),
|
||||
|
||||
Reference in New Issue
Block a user