feat: MCP Bridge Workbench, just command cleanup, testing, etc. (#24)

* fix: lots of fixes and name refactoring

* feat: Add workbench preferences

* fix: MCP bridge status widget and just command fixes

* fix(tests): Use the correct mesa-glx package

* fix(ci): Add fontconfig to GUI test dependencies

FreeCAD GUI was failing to start with:
"Fontconfig error: Cannot load default config file: No such file"

Added fontconfig and fonts-dejavu-core packages to the GUI test job
dependencies to resolve the font configuration issue.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Extract path utilities into shared module

Create path_utils.py module that consolidates duplicated path-finding
logic from commands.py and InitGui.py:
- get_addon_path(): Find addon directory with caching and fallbacks
- get_icon_path(): Get full path to an icon file
- get_icons_dir(): Get path to icons directory
- get_workbench_icon(): Get path to workbench main icon

This removes ~100 lines of duplicated code while preserving the same
behavior including _addon_path_cache and all fallback methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(addon): Prevent stale plugin state on startup failure

The StartMCPBridgeCommand.Activated method could leave _mcp_plugin in
a partially initialized state if FreecadMCPPlugin.start() failed after
the plugin was instantiated.

Changes:
- Create plugin in a local variable first
- Only assign to _mcp_plugin after start() succeeds
- Explicitly clear _mcp_plugin and _running_config in exception
  handlers to ensure clean state for subsequent retry attempts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Lot of broad improvements

* fix(ci): Use blocking headless_server.py for GUI tests

The GUI test was using startup_bridge.py which is non-blocking
(designed for interactive use). For CI, even in GUI mode, we need
the blocking headless_server.py that calls run_forever() to keep
FreeCAD running. GUI features are still available since we use
the 'freecad' executable instead of 'freecadcmd'.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Rename headless_server.py to blocking_bridge.py

The old name was misleading because:
- It works with both GUI (freecad) and headless (freecadcmd) modes
- The key characteristic is that it BLOCKS with run_forever()

New naming convention clarifies the difference:
- blocking_bridge.py: Starts bridge and blocks (for CI, servers)
- startup_bridge.py: Starts bridge and returns (for interactive GUI)

Updated all references across:
- GitHub workflow (macro-test.yaml)
- Just commands (freecad.just)
- Unit tests (test_addon_structure.py)
- Documentation (5 files)
- CLAUDE.md

Also improved the script to detect GUI mode dynamically using
FreeCAD.GuiUp and display the appropriate status message.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(just): Remove erroneous rm of startup_bridge.py on error

The startup script is now a permanent source file in the repository,
not a generated temporary file. The rm -f would have deleted source
code if FreeCAD wasn't found.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: General improvements

* fix: Lots of general fixes and only stable to PyPi

* fix: small cleanup

* fix: Small fixes and hopefully fixes the GUI tests

* fix: Add proper library paths for FreeCAD GUI in CI

- Create wrapper scripts instead of symlinks for AppImage binaries
- Set LD_LIBRARY_PATH, QT_PLUGIN_PATH for GUI mode
- Add diagnostic output to identify startup failures

* fix: Use apprun for GUI tests in CI

* fix: Improving Xvfb tests

* fix: GUI tests worlk

* chore: remove invalid --no-splash comments

* fix: ARM64 architecture support and other fixes

* fix: cleanup

* test: just commands test suite

* test: improve just command tests

* fix: more general improvements

* fix: more cleanup

* fix: more updates

* fix: small tweaks

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sean P. Kane
2026-01-10 15:26:33 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent bcc3048876
commit 8c338f6da7
110 changed files with 7821 additions and 1276 deletions
+6 -7
View File
@@ -62,10 +62,9 @@ install:
echo ""
echo "CodeRabbit CLI installed. Run 'just coderabbit::login' to authenticate."
# Check if CodeRabbit CLI is installed
# Check if CodeRabbit CLI is installed (silent check)
check-installed:
@command -v coderabbit >/dev/null 2>&1 || { echo "CodeRabbit CLI not installed. Run: just coderabbit::install"; exit 1; }
@coderabbit --version
# =============================================================================
# Authentication
@@ -199,13 +198,13 @@ review-json: check-installed
fi
# =============================================================================
# Configuration
# Help & Info
# =============================================================================
# Show CodeRabbit configuration
config-show: check-installed
coderabbit config show
# Show help for all CodeRabbit commands
help: check-installed
coderabbit --help
# Show CodeRabbit CLI version
version: check-installed
@coderabbit --version
+23 -5
View File
@@ -16,7 +16,7 @@ install-deps:
@echo ""
@echo "Dependencies installed!"
@echo ""
@echo "To run the MCP server:"
@echo "To run the Robust MCP Server:"
@echo " just mcp::run # stdio mode"
@echo " just mcp::run-debug # with debug logging"
@echo " just mcp::run-http # HTTP mode for remote access"
@@ -26,11 +26,29 @@ install-pre-commit:
cd {{project_root}} && uv run pre-commit install
cd {{project_root}} && uv run pre-commit install --hook-type commit-msg
# Update all dependencies to latest versions (uv.lock + pre-commit hooks)
# Update all dependencies to latest versions (mise tools, uv.lock, pre-commit hooks)
update-deps:
cd {{project_root}} && uv lock --upgrade
cd {{project_root}} && uv sync --all-extras
cd {{project_root}} && uv run pre-commit autoupdate
#!/usr/bin/env bash
set -euo pipefail
cd "{{project_root}}"
echo "Updating mise-managed tools..."
mise upgrade
echo ""
echo "Updating Python dependencies..."
uv lock --upgrade
uv sync --all-extras
echo ""
echo "Updating pre-commit hooks..."
uv run pre-commit autoupdate
echo ""
echo "All dependencies updated!"
echo " - mise tools: updated (see .mise.toml)"
echo " - Python deps: updated (see uv.lock)"
echo " - pre-commit hooks: updated (see .pre-commit-config.yaml)"
# =============================================================================
# Development Utilities
+125 -64
View File
@@ -4,6 +4,7 @@
# Default Docker image name (matches Docker Hub and PyPI package name)
image_name := "freecad-robust-mcp"
registry := "spkane"
gui_test_image := "freecad-gui-test"
# Project root directory (justfile_directory() returns the main justfile's directory)
project_root := justfile_directory()
@@ -14,21 +15,37 @@ build:
# Build Docker image with specific tag
build-tag tag:
docker build -t {{image_name}}:{{tag}} {{project_root}}
docker build --load -t {{image_name}}:{{tag}} {{project_root}}
# Build multi-architecture image (amd64 and arm64)
build-multi:
# Validate multi-architecture build (amd64 and arm64)
# This is a dry-run that verifies both architectures compile successfully.
# The build populates the builder cache but does NOT produce a usable image.
# Use cases:
# - CI validation before pushing (verify PR doesn't break either arch)
# - Local verification that changes build on both architectures
# To actually publish a multi-arch image, use: just docker::build-push
build-multi: setup-buildx
docker buildx build --platform linux/amd64,linux/arm64 -t {{image_name}} {{project_root}}
# Build and push multi-architecture image to registry
build-push tag="latest":
# Build and push multi-architecture image to registry (produces usable multi-arch image)
build-push tag="latest": setup-buildx
docker buildx build --platform linux/amd64,linux/arm64 \
-t {{registry}}/{{image_name}}:{{tag}} \
--push {{project_root}}
# Build and load multi-architecture image locally (loads current arch only)
build-load:
docker buildx build --platform linux/amd64,linux/arm64 \
# Build and load image for current architecture using buildx
build-load: setup-buildx
#!/usr/bin/env bash
set -euo pipefail
# Detect current architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) PLATFORM="linux/amd64" ;;
aarch64|arm64) PLATFORM="linux/arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
echo "Building for detected architecture: $PLATFORM"
docker buildx build --platform "$PLATFORM" \
-t {{image_name}} \
--load {{project_root}}
@@ -101,8 +118,8 @@ scan-strict:
# Scan Docker image and output SARIF report
scan-sarif output="trivy-results.sarif":
trivy image --format sarif --output {{output}} {{image_name}}
@echo "SARIF report written to {{output}}"
trivy image --format sarif --output {{project_root}}/{{output}} {{image_name}}
@echo "SARIF report written to {{project_root}}/{{output}}"
# Create and configure buildx builder for multi-arch builds
setup-buildx:
@@ -124,6 +141,22 @@ test:
#!/usr/bin/env bash
set -euo pipefail
# Initialize variables for cleanup
STARTED_FREECAD=false
FREECAD_PID=""
FREECAD_LOG=""
MCP_INPUT=""
# Comprehensive cleanup trap for all exit paths
cleanup() {
[ -n "${MCP_INPUT:-}" ] && rm -f "$MCP_INPUT" 2>/dev/null || true
[ -n "${FREECAD_LOG:-}" ] && rm -f "$FREECAD_LOG" 2>/dev/null || true
if [ "$STARTED_FREECAD" = true ] && [ -n "${FREECAD_PID:-}" ]; then
kill "$FREECAD_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
echo "=========================================="
echo "Docker Integration Test"
echo "=========================================="
@@ -138,6 +171,14 @@ test:
http://localhost:9875 > /dev/null 2>&1
}
# Detect timeout command (not available on macOS by default)
TIMEOUT_CMD=""
if command -v timeout >/dev/null 2>&1; then
TIMEOUT_CMD="timeout"
elif command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout" # macOS coreutils
fi
# Build the Docker image
echo "Step 1: Building Docker image..."
docker build -t {{image_name}}:test {{project_root}}
@@ -167,9 +208,7 @@ test:
echo "✗ ERROR: FreeCAD MCP bridge did not start within ${MAX_RETRIES}s"
echo ""
echo "FreeCAD log output:"
cat "$FREECAD_LOG" | tail -30
rm -f "$FREECAD_LOG"
kill $FREECAD_PID 2>/dev/null || true
tail -30 "$FREECAD_LOG"
exit 1
fi
# Show progress less frequently to reduce noise
@@ -179,6 +218,7 @@ test:
sleep 1
done
rm -f "$FREECAD_LOG"
FREECAD_LOG="" # Clear so cleanup doesn't try to delete again
echo "✓ FreeCAD MCP bridge started (took ${RETRY_COUNT}s)"
STARTED_FREECAD=true
fi
@@ -186,63 +226,58 @@ test:
# Run the container and test communication
echo "Step 3: Running container and testing MCP communication..."
echo " Running MCP server in container..."
echo " Running Robust MCP Server in container..."
# Send MCP initialize and tool call requests via JSON-RPC over stdio
# Note: Using printf with \n to avoid just parsing issues with unindented lines
MCP_INIT='{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
MCP_CALL='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_mcp_server_environment","arguments":{}}}'
# Send MCP initialize request via JSON-RPC over stdio
# Using temp file for input so stdin closes after message
# Note: Container may exit non-zero when stdin closes (ClosedResourceError), which is expected
MCP_INPUT=$(mktemp)
echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' > "$MCP_INPUT"
CONTAINER_OUTPUT=$(printf '%s\n%s\n' "$MCP_INIT" "$MCP_CALL" | \
timeout 30 docker run --rm -i \
# Capture output; ignore exit code since stdin close causes expected error
# Use timeout command if available for better reliability
if [ -n "$TIMEOUT_CMD" ]; then
CONTAINER_OUTPUT=$($TIMEOUT_CMD 30 docker run --rm -i \
--add-host=host.docker.internal:host-gateway \
-e FREECAD_MODE=xmlrpc \
-e FREECAD_SOCKET_HOST=host.docker.internal \
{{image_name}}:test 2>&1) || {
echo "✗ Container failed to respond"
if [ "$STARTED_FREECAD" = true ]; then
kill $FREECAD_PID 2>/dev/null || true
fi
exit 1
}
{{image_name}}:test 2>&1 < "$MCP_INPUT" || true)
else
CONTAINER_OUTPUT=$(docker run --rm -i \
--add-host=host.docker.internal:host-gateway \
-e FREECAD_MODE=xmlrpc \
-e FREECAD_SOCKET_HOST=host.docker.internal \
{{image_name}}:test 2>&1 < "$MCP_INPUT" || true)
fi
rm -f "$MCP_INPUT"
MCP_INPUT="" # Clear so cleanup doesn't try to delete again
echo ""
echo "Step 4: Verifying response..."
# Track test results
TEST_PASSED=true
DOCKER_CONFIRMED=false
# Check if the response indicates we're in a Docker container
if echo "$CONTAINER_OUTPUT" | grep -q '"in_docker": true\|"in_docker":true'; then
echo "✓ Response confirms running in Docker container"
DOCKER_CONFIRMED=true
elif echo "$CONTAINER_OUTPUT" | grep -q '"os_name": "Linux"\|"os_name":"Linux"'; then
echo "✓ Response shows Linux OS (expected for Docker)"
DOCKER_CONFIRMED=true
# Check if we got a valid MCP initialize response
if echo "$CONTAINER_OUTPUT" | grep -q '"result".*"protocolVersion"'; then
echo "✓ Container responded with valid MCP initialize response"
else
echo "⚠ Warning: Could not confirm Docker detection"
echo "✗ Container failed to respond with valid MCP response"
TEST_PASSED=false
fi
# Check for hostname (containers have short random hostnames)
if echo "$CONTAINER_OUTPUT" | grep -q '"hostname"'; then
HOSTNAME=$(echo "$CONTAINER_OUTPUT" | grep -o '"hostname"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1)
echo "✓ Container hostname: $HOSTNAME"
# Check for server info in response
if echo "$CONTAINER_OUTPUT" | grep -q '"serverInfo".*"freecad-mcp"'; then
echo "✓ Server identified as freecad-mcp"
else
echo "⚠ Warning: No hostname in response"
echo "⚠ Warning: Could not confirm server identity"
fi
# Check we got a valid response (not an error)
if echo "$CONTAINER_OUTPUT" | grep -q '"error"'; then
# Check if it's just a "not connected" error (expected without proper init)
if echo "$CONTAINER_OUTPUT" | grep -q 'Not connected\|Connection refused'; then
echo " Note: FreeCAD connection test - bridge communication verified"
else
echo "✗ Error: Response contained an error"
echo " $CONTAINER_OUTPUT" | tail -5
TEST_PASSED=false
fi
# Check for FreeCAD bridge connection in logs (use grep -E for clearer alternation)
if echo "$CONTAINER_OUTPUT" | grep -Eq 'FreeCAD bridge connected|FreeCAD.*GUI'; then
echo "✓ FreeCAD bridge connection logged"
else
echo "⚠ Warning: No FreeCAD bridge connection in logs"
fi
echo ""
@@ -252,23 +287,14 @@ test:
echo "$CONTAINER_OUTPUT" | tail -20
echo ""
# Cleanup
if [ "$STARTED_FREECAD" = true ]; then
echo "Step 5: Cleaning up..."
kill $FREECAD_PID 2>/dev/null || true
echo "✓ Stopped FreeCAD headless server"
fi
# Note: FreeCAD cleanup is handled by the EXIT trap
echo ""
echo "=========================================="
if [ "$TEST_PASSED" = true ] && [ "$DOCKER_CONFIRMED" = true ]; then
if [ "$TEST_PASSED" = true ]; then
echo "✓ PASSED: Docker integration test succeeded!"
echo " - Container ran successfully"
echo " - Confirmed running in Docker environment"
elif [ "$TEST_PASSED" = true ]; then
echo "⚠ PARTIAL: Docker integration test completed with warnings"
echo " - Container ran successfully"
echo " - Could not confirm Docker environment detection"
echo " - Container built and ran successfully"
echo " - Robust MCP Server responded to initialize request"
else
echo "✗ FAILED: Docker integration test had errors"
echo " - Review the output above for details"
@@ -279,3 +305,38 @@ test:
if [ "$TEST_PASSED" = false ]; then
exit 1
fi
# ============================================================================
# GUI Test Container (for CI debugging)
# ============================================================================
# Build the GUI test container (replicates GitHub Actions CI environment)
# Supports both x86_64 and aarch64 architectures (downloads correct AppImage)
build-gui-test:
docker build -f {{project_root}}/tests/ci-test/Dockerfile.gui-test \
-t {{gui_test_image}} \
{{project_root}}
# Run GUI test container interactively for debugging
gui-test-shell:
docker run --rm -it \
-v {{project_root}}:/workspace \
{{gui_test_image}} \
/bin/bash
# Run the automated GUI tests in the container
gui-test-run:
docker run --rm -i \
-v {{project_root}}:/workspace \
{{gui_test_image}} \
/usr/local/bin/run-gui-test.sh
# Run GUI test with custom command
gui-test-cmd *args:
docker run --rm -it \
-v {{project_root}}:/workspace \
{{gui_test_image}} \
{{args}}
# Quick rebuild and test cycle for GUI debugging
gui-test: build-gui-test gui-test-run
+2 -1
View File
@@ -13,8 +13,9 @@ build-strict:
cd {{project_root}} && uv run mkdocs build --strict
# Serve documentation locally
# Note: The leading `-` suppresses the error when the user interrupts with Ctrl+C
serve:
cd {{project_root}} && uv run mkdocs serve
-cd {{project_root}} && uv run mkdocs serve
# Build and open documentation in browser
open:
+22 -82
View File
@@ -8,13 +8,13 @@ project_root := justfile_directory()
# Running FreeCAD with MCP Bridge
# =============================================================================
# Run MCP bridge server in FreeCAD headless mode
# Run MCP bridge server in FreeCAD headless mode (blocking)
run-headless:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
# Use the headless server from the addon directory (source of truth)
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py"
# Use the blocking bridge script from the addon directory (source of truth)
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py"
# Find FreeCADCmd executable based on OS
FREECAD_CMD=""
@@ -65,15 +65,15 @@ run-headless:
echo "Using FreeCAD: $FREECAD_CMD"
echo ""
# Run FreeCADCmd with the headless server script
# Run FreeCADCmd with the blocking bridge script
"$FREECAD_CMD" "$SCRIPT_PATH"
# Run MCP bridge with custom FreeCAD path
# Run MCP bridge with custom FreeCAD path (blocking)
run-headless-custom freecad_cmd:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py"
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py"
if [[ ! -x "{{freecad_cmd}}" ]]; then
echo "ERROR: FreeCADCmd not found or not executable: {{freecad_cmd}}"
@@ -83,60 +83,24 @@ run-headless-custom freecad_cmd:
echo "Using FreeCAD: {{freecad_cmd}}"
echo ""
# Run FreeCADCmd with the headless server script
# Run FreeCADCmd with the blocking bridge script
"{{freecad_cmd}}" "$SCRIPT_PATH"
# Run FreeCAD GUI with MCP bridge (requires workbench to be installed)
# Run FreeCAD GUI with MCP bridge (uses local source code for development)
# Note: Uses default ports (XML-RPC: 9875, Socket: 9876) regardless of workbench
# preferences. For custom ports, use the workbench GUI instead.
run-gui:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
# Create a temporary startup script that starts the MCP bridge
STARTUP_SCRIPT=$(mktemp /tmp/freecad_mcp_startup.XXXXXX.py)
# Use the shared startup script from the addon directory
STARTUP_SCRIPT="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/startup_bridge.py"
# Use the server from the installed workbench location
# Note: For development, use run-gui-custom which passes the addon path explicitly
cat > "$STARTUP_SCRIPT" << 'PYTHON_EOF'
# FreeCAD MCP Bridge Auto-Start Script
import sys
import os
from pathlib import Path
# Determine installed workbench location based on platform
if sys.platform == "darwin":
addon_path = Path.home() / "Library" / "Application Support" / "FreeCAD" / "Mod" / "FreecadRobustMCP" / "freecad_mcp_bridge"
elif sys.platform == "win32":
addon_path = Path(os.environ.get("APPDATA", "")) / "FreeCAD" / "Mod" / "FreecadRobustMCP" / "freecad_mcp_bridge"
else:
addon_path = Path.home() / ".local" / "share" / "FreeCAD" / "Mod" / "FreecadRobustMCP" / "freecad_mcp_bridge"
if not addon_path.exists():
import FreeCAD
FreeCAD.Console.PrintError("MCP Bridge workbench not found.\n")
FreeCAD.Console.PrintError(f"Expected at: {addon_path}\n")
FreeCAD.Console.PrintError("Install the workbench first: just freecad::install-workbench\n")
else:
try:
addon_path_str = str(addon_path)
if addon_path_str not in sys.path:
sys.path.insert(0, addon_path_str)
from server import FreecadMCPPlugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876,
xmlrpc_port=9875,
enable_xmlrpc=True,
)
plugin.start()
import FreeCAD
FreeCAD.Console.PrintMessage("\nMCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n\n")
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
PYTHON_EOF
if [[ ! -f "$STARTUP_SCRIPT" ]]; then
echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
exit 1
fi
# Find FreeCAD GUI executable based on OS
FREECAD_GUI=""
@@ -171,7 +135,6 @@ run-gui:
fi
if [[ -z "$FREECAD_GUI" ]]; then
rm -f "$STARTUP_SCRIPT"
echo "ERROR: FreeCAD not found!"
echo ""
echo "Please install FreeCAD or use:"
@@ -208,36 +171,13 @@ run-gui-custom freecad_path:
set -euo pipefail
PROJECT_DIR="{{project_root}}"
# Create a temporary startup script
STARTUP_SCRIPT=$(mktemp /tmp/freecad_mcp_startup.XXXXXX.py)
# Use the shared startup script from the addon directory
STARTUP_SCRIPT="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/startup_bridge.py"
# Use the server from the addon directory
ADDON_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge"
cat > "$STARTUP_SCRIPT" << EOF
# FreeCAD MCP Bridge Auto-Start Script
import sys
addon_path = "${ADDON_PATH}"
if addon_path not in sys.path:
sys.path.insert(0, addon_path)
try:
from server import FreecadMCPPlugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876,
xmlrpc_port=9875,
enable_xmlrpc=True,
)
plugin.start()
import FreeCAD
FreeCAD.Console.PrintMessage("\\nMCP Bridge started!\\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\\n\\n")
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\\n")
EOF
if [[ ! -f "$STARTUP_SCRIPT" ]]; then
echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
exit 1
fi
echo "Starting FreeCAD with MCP bridge..."
echo "Using FreeCAD: {{freecad_path}}"
+274 -27
View File
@@ -2,8 +2,8 @@
# Usage: just install::mcp-server, just install::mcp-bridge-workbench, etc.
#
# This module installs components for end users:
# - MCP Server (as a uv tool, available system-wide)
# - MCP Bridge Workbench (FreeCAD addon)
# - Robust MCP Server (as a uv tool, available system-wide)
# - Robust MCP Bridge Workbench (FreeCAD addon)
# - FreeCAD Macros (CutObjectForMagnets, MultiExport)
#
# For developer setup (Python dependencies in virtualenv), use: just dev::install-deps
@@ -23,52 +23,75 @@ project_root := justfile_directory()
# echo "Macro directory: $MACRO_DIR"
# Private recipe that outputs shell code to set FreeCAD directories
# FreeCAD 1.x uses versioned directories (v1-1, v1-2, etc.) for user data.
# This helper detects the latest versioned directory if present.
[private]
_freecad-dirs:
#!/usr/bin/env bash
cat << 'DIRS_EOF'
# Determine base FreeCAD directory based on OS
if [[ "$OSTYPE" == "darwin"* ]]; then
MOD_DIR="$HOME/Library/Application Support/FreeCAD/Mod"
MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro"
FREECAD_BASE="$HOME/Library/Application Support/FreeCAD"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
MOD_DIR="$HOME/.local/share/FreeCAD/Mod"
MACRO_DIR="$HOME/.local/share/FreeCAD/Macro"
FREECAD_BASE="$HOME/.local/share/FreeCAD"
else
# Windows: validate APPDATA or use fallback
if [[ -n "$APPDATA" ]]; then
FREECAD_BASE="$APPDATA"
FREECAD_BASE="$APPDATA/FreeCAD"
elif [[ -n "$HOME" ]]; then
# Fallback to standard Windows location under HOME
FREECAD_BASE="$HOME/AppData/Roaming"
FREECAD_BASE="$HOME/AppData/Roaming/FreeCAD"
echo "Warning: APPDATA not set, using fallback: $FREECAD_BASE" >&2
else
echo "Error: Neither APPDATA nor HOME is set. Cannot determine FreeCAD directory." >&2
exit 1
fi
MOD_DIR="$FREECAD_BASE/FreeCAD/Mod"
MACRO_DIR="$FREECAD_BASE/FreeCAD/Macro"
fi
# FreeCAD 1.x+ uses versioned directories (v1-1, v1-2, v2-0, etc.)
# Find the latest versioned directory if present
VERSIONED_DIR=""
if [[ -d "$FREECAD_BASE" ]]; then
# Find directories matching v*-* pattern (supports v1-*, v2-*, etc.)
# Use sort -t- -k1.2 -k2 -n to sort by major then minor version
LATEST_VERSION=$(ls -d "$FREECAD_BASE"/v*-* 2>/dev/null | sort -t- -k1.2 -k2 -n | tail -n 1)
if [[ -n "$LATEST_VERSION" && -d "$LATEST_VERSION" ]]; then
VERSIONED_DIR="$LATEST_VERSION"
fi
fi
# Use versioned directory if found, otherwise use base directory
if [[ -n "$VERSIONED_DIR" ]]; then
MOD_DIR="$VERSIONED_DIR/Mod"
MACRO_DIR="$VERSIONED_DIR/Macro"
echo "Note: Using FreeCAD versioned directory: $VERSIONED_DIR" >&2
else
MOD_DIR="$FREECAD_BASE/Mod"
MACRO_DIR="$FREECAD_BASE/Macro"
fi
DIRS_EOF
# =============================================================================
# MCP Server Installation
# Robust MCP Server Installation
# =============================================================================
# Install the MCP server as a user tool (available system-wide via uv)
# Install the Robust MCP Server as a user tool (available system-wide via uv)
# Uses cached builds for faster installation. For development with uncommitted
# changes, use mcp-server-clean instead.
mcp-server:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="{{project_root}}"
echo "Installing MCP server as a uv tool..."
echo "Installing Robust MCP Server as a uv tool..."
echo ""
# Install from the local project directory
# --force handles reinstallation automatically, no need to uninstall first
uv tool install --force "$PROJECT_DIR"
echo ""
echo "=========================================="
echo "MCP Server installed!"
echo "Robust MCP Server installed!"
echo "=========================================="
echo ""
echo "The 'freecad-mcp' command is now available system-wide."
@@ -82,16 +105,27 @@ mcp-server:
echo ' "command": "freecad-mcp"'
echo ' }'
echo ""
echo "Note: If you have uncommitted local changes, use 'just install::mcp-server-clean'"
echo ""
# Uninstall the MCP server tool
# Install with cache clearing (for development with uncommitted changes)
# Clears uv cache first to ensure the build picks up all local changes.
mcp-server-clean:
#!/usr/bin/env bash
set -euo pipefail
echo "Clearing uv cache for fresh build..."
uv cache clean --force 2>/dev/null || true
just install::mcp-server
# Uninstall the Robust MCP Server tool
uninstall-mcp-server:
#!/usr/bin/env bash
set -euo pipefail
echo "Uninstalling MCP server..."
uv tool uninstall freecad-robust-mcp || echo "MCP server was not installed as a uv tool"
echo "Uninstalling Robust MCP Server..."
uv tool uninstall freecad-robust-mcp || echo "Robust MCP Server was not installed as a uv tool"
# =============================================================================
# MCP Bridge Workbench Installation
# Robust MCP Bridge Workbench Installation
# =============================================================================
# Install the FreeCAD Robust MCP workbench addon to FreeCAD's Mod directory
@@ -125,6 +159,135 @@ mcp-bridge-workbench:
# Copy the addon directory
cp -r "$ADDON_SRC" "$ADDON_DEST"
# Generate package.xml for the workbench from root package.xml
# FreeCAD requires package.xml in the addon directory for proper workbench detection
ROOT_PACKAGE_XML="${PROJECT_DIR}/package.xml"
if [[ -f "$ROOT_PACKAGE_XML" ]]; then
echo "Generating package.xml from root package.xml..."
export ROOT_PACKAGE_XML ADDON_DEST
python3 << 'PYEOF'
import xml.etree.ElementTree as ET
import sys
import os
try:
root_pkg = os.environ.get('ROOT_PACKAGE_XML', '')
addon_dest = os.environ.get('ADDON_DEST', '')
tree = ET.parse(root_pkg)
root = tree.getroot()
ns = {'pkg': 'https://wiki.freecad.org/Package_Metadata'}
# Find the workbench content element
workbench = root.find('.//pkg:content/pkg:workbench', ns)
if workbench is None:
print("Warning: No workbench found in root package.xml", file=sys.stderr)
sys.exit(0)
# Extract workbench metadata
wb_name = workbench.find('pkg:name', ns)
wb_version = workbench.find('pkg:version', ns)
wb_date = workbench.find('pkg:date', ns)
wb_description = workbench.find('pkg:description', ns)
wb_classname = workbench.find('pkg:classname', ns)
wb_icon = workbench.find('pkg:icon', ns)
wb_freecadmin = workbench.find('pkg:freecadmin', ns)
# Get maintainer and license from root
maintainer = root.find('pkg:maintainer', ns)
license_el = root.find('pkg:license', ns)
repo_url = root.find('pkg:url[@type="repository"]', ns)
readme_url = root.find('pkg:url[@type="readme"]', ns)
# Create standalone package.xml
standalone = ET.Element('package', {
'format': '1',
'xmlns': 'https://wiki.freecad.org/Package_Metadata'
})
# Add metadata
name_text = wb_name.text if wb_name is not None else 'Robust MCP Bridge'
ET.SubElement(standalone, 'name').text = name_text
desc_text = wb_description.text if wb_description is not None else 'MCP Bridge for FreeCAD'
ET.SubElement(standalone, 'description').text = desc_text
ver_text = wb_version.text if wb_version is not None else '0.0.0'
ET.SubElement(standalone, 'version').text = ver_text
# Fall back to today's date if not specified
from datetime import date
date_text = wb_date.text if wb_date is not None else date.today().isoformat()
ET.SubElement(standalone, 'date').text = date_text
if maintainer is not None:
m = ET.SubElement(standalone, 'maintainer')
m.text = maintainer.text
if maintainer.get('email'):
m.set('email', maintainer.get('email'))
if license_el is not None:
l = ET.SubElement(standalone, 'license')
l.text = license_el.text
if license_el.get('file'):
l.set('file', license_el.get('file'))
if repo_url is not None:
u = ET.SubElement(standalone, 'url', type='repository')
u.text = repo_url.text
if repo_url.get('branch'):
u.set('branch', repo_url.get('branch'))
if readme_url is not None:
u = ET.SubElement(standalone, 'url', type='readme')
u.text = readme_url.text
icon_text = wb_icon.text if wb_icon is not None else 'FreecadRobustMCP.svg'
ET.SubElement(standalone, 'icon').text = icon_text
fcmin_text = wb_freecadmin.text if wb_freecadmin is not None else '0.21'
ET.SubElement(standalone, 'freecadmin').text = fcmin_text
# Add content/workbench section
content = ET.SubElement(standalone, 'content')
wb_el = ET.SubElement(content, 'workbench')
cls_text = wb_classname.text if wb_classname is not None else 'FreecadRobustMCPWorkbench'
ET.SubElement(wb_el, 'classname').text = cls_text
ET.SubElement(wb_el, 'subdirectory').text = './'
# Add tags
for tag in ['MCP', 'AI', 'automation', 'Claude', 'bridge', 'headless']:
ET.SubElement(wb_el, 'tag').text = tag
# Helper to indent XML for Python < 3.9 compatibility
def indent_xml(elem, level=0, space=' '):
"""Indent XML element tree (fallback for Python < 3.9)."""
indent_str = '\n' + level * space
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = indent_str + space
for child in elem:
indent_xml(child, level + 1, space)
if not child.tail or not child.tail.strip():
child.tail = indent_str
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = indent_str
# Write the standalone package.xml
# Use ET.indent if available (Python 3.9+), otherwise use fallback
if hasattr(ET, 'indent'):
ET.indent(standalone, space=' ')
else:
indent_xml(standalone)
tree = ET.ElementTree(standalone)
output_path = os.path.join(addon_dest, 'package.xml')
tree.write(output_path, encoding='UTF-8', xml_declaration=True)
print("Generated package.xml successfully")
except Exception as e:
print(f"Warning: Could not generate package.xml: {e}", file=sys.stderr)
# Don't fail the installation if package.xml generation fails
PYEOF
else
echo "Warning: Root package.xml not found, skipping package.xml generation"
fi
echo ""
echo "=========================================="
echo "FreeCAD Robust MCP Workbench installed!"
@@ -134,7 +297,7 @@ mcp-bridge-workbench:
echo ""
echo "To use:"
echo " 1. Start FreeCAD"
echo " 2. Select the 'MCP Bridge' workbench from the workbench selector"
echo " 2. Select the 'Robust MCP Bridge' workbench from the workbench selector"
echo " 3. Click 'Start MCP Bridge' in the toolbar"
echo " 4. Connect your MCP client (Claude Code, etc.) to FreeCAD"
echo ""
@@ -179,6 +342,13 @@ macro-cut:
mkdir -p "$MACRO_DIR"
# Remove existing installation if present (clean install)
if [[ -f "$MACRO_DIR/CutObjectForMagnets.FCMacro" ]]; then
echo "Removing existing CutObjectForMagnets macro..."
rm -f "$MACRO_DIR/CutObjectForMagnets.FCMacro"
rm -f "$MACRO_DIR/CutObjectForMagnets.svg"
fi
# Copy the macro file
cp "$MACRO_SRC" "$MACRO_DIR/"
@@ -230,6 +400,13 @@ macro-export:
mkdir -p "$MACRO_DIR"
# Remove existing installation if present (clean install)
if [[ -f "$MACRO_DIR/MultiExport.FCMacro" ]]; then
echo "Removing existing MultiExport macro..."
rm -f "$MACRO_DIR/MultiExport.FCMacro"
rm -f "$MACRO_DIR/MultiExport.svg"
fi
# Copy the macro file
cp "$MACRO_SRC" "$MACRO_DIR/"
@@ -281,53 +458,123 @@ status:
# Set FreeCAD directories
eval "$(just install::_freecad-dirs)"
# Helper function to get file modification time (cross-platform)
get_mod_time() {
local file="$1"
if [[ "$OSTYPE" == "darwin"* ]]; then
stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$file" 2>/dev/null || echo "unknown"
else
stat -c "%y" "$file" 2>/dev/null | cut -d'.' -f1 || echo "unknown"
fi
}
echo "=========================================="
echo "Installation Status"
echo "=========================================="
echo ""
# Check MCP Server (installed as uv tool)
# Check Robust MCP Server (installed as uv tool)
if command -v freecad-mcp &> /dev/null; then
echo "✓ MCP Server: INSTALLED (as uv tool)"
MCP_VERSION=$(freecad-mcp --version 2>/dev/null || echo "unknown")
MCP_PATH=$(command -v freecad-mcp)
MCP_MOD_TIME=$(get_mod_time "$MCP_PATH")
echo "✓ Robust MCP Server: INSTALLED (as uv tool)"
echo " Version: $MCP_VERSION"
echo " Updated: $MCP_MOD_TIME"
echo " Run: freecad-mcp"
elif [[ -f "{{project_root}}/pyproject.toml" ]] && grep -q 'name = "freecad-robust-mcp"' "{{project_root}}/pyproject.toml" 2>/dev/null; then
# Dev environment exists - check if synced by looking for .venv
if [[ -d "{{project_root}}/.venv" ]]; then
echo "✓ MCP Server: AVAILABLE (via dev environment)"
DEV_VERSION=$(cd "{{project_root}}" && uv run python -c "from freecad_mcp import __version__; print(__version__)" 2>/dev/null || echo "unknown")
echo "✓ Robust MCP Server: AVAILABLE (via dev environment)"
echo " Version: $DEV_VERSION"
echo " Run: just mcp::run"
echo " For system-wide install: just install::mcp-server"
else
echo "○ MCP Server: DEV SOURCE AVAILABLE (needs setup)"
echo "○ Robust MCP Server: DEV SOURCE AVAILABLE (needs setup)"
echo " Setup: uv sync --all-extras"
echo " Then run: just mcp::run"
fi
else
echo "✗ MCP Server: NOT INSTALLED"
echo "✗ Robust MCP Server: NOT INSTALLED"
echo " Install: just install::mcp-server"
fi
echo ""
# Helper function to extract version from package.xml files
# Uses environment variable to pass file path safely to Python (avoids shell interpolation)
extract_package_version() {
local package_file="$1"
PACKAGE_FILE="$package_file" python3 -c '
import os
import xml.etree.ElementTree as ET
try:
package_file = os.environ.get("PACKAGE_FILE", "")
tree = ET.parse(package_file)
root = tree.getroot()
ns = {"pkg": "https://wiki.freecad.org/Package_Metadata"}
# Try with namespace first, then without
ver = root.find("pkg:version", ns) or root.find("version")
print(ver.text if ver is not None else "unknown")
except Exception:
print("unknown")
' 2>/dev/null || echo "unknown"
}
# Helper function to extract __Version__ from macro files (handles single/double quotes)
# Uses environment variable to pass file path safely to Python (avoids shell interpolation)
extract_macro_version() {
local file="$1"
MACRO_FILE="$file" python3 -c '
import os
import re
try:
macro_file = os.environ.get("MACRO_FILE", "")
content = open(macro_file).read()
match = re.search(r"__Version__\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", content)
print(match.group(1) if match else "unknown")
except Exception:
print("unknown")
' 2>/dev/null || echo "unknown"
}
# Check workbench
if [[ -d "$MOD_DIR/FreecadRobustMCP" ]]; then
echo "✓ MCP Bridge Workbench: INSTALLED"
WB_VERSION="unknown"
if [[ -f "$MOD_DIR/FreecadRobustMCP/package.xml" ]]; then
WB_VERSION=$(extract_package_version "$MOD_DIR/FreecadRobustMCP/package.xml")
fi
WB_MOD_TIME=$(get_mod_time "$MOD_DIR/FreecadRobustMCP/InitGui.py")
echo "✓ Robust MCP Bridge Workbench: INSTALLED"
echo " Version: $WB_VERSION"
echo " Updated: $WB_MOD_TIME"
echo " Path: $MOD_DIR/FreecadRobustMCP"
else
echo "✗ MCP Bridge Workbench: NOT INSTALLED"
echo "✗ Robust MCP Bridge Workbench: NOT INSTALLED"
echo " Install: just install::mcp-bridge-workbench"
fi
echo ""
# Check macros
echo "Macros:"
if [[ -f "$MACRO_DIR/CutObjectForMagnets.FCMacro" ]]; then
CUT_VERSION=$(extract_macro_version "$MACRO_DIR/CutObjectForMagnets.FCMacro")
CUT_MOD_TIME=$(get_mod_time "$MACRO_DIR/CutObjectForMagnets.FCMacro")
echo " ✓ CutObjectForMagnets: INSTALLED"
echo " Version: $CUT_VERSION"
echo " Updated: $CUT_MOD_TIME"
else
echo " ✗ CutObjectForMagnets: NOT INSTALLED"
echo " Install: just install::macro-cut"
fi
if [[ -f "$MACRO_DIR/MultiExport.FCMacro" ]]; then
EXPORT_VERSION=$(extract_macro_version "$MACRO_DIR/MultiExport.FCMacro")
EXPORT_MOD_TIME=$(get_mod_time "$MACRO_DIR/MultiExport.FCMacro")
echo " ✓ MultiExport: INSTALLED"
echo " Version: $EXPORT_VERSION"
echo " Updated: $EXPORT_MOD_TIME"
else
echo " ✗ MultiExport: NOT INSTALLED"
echo " Install: just install::macro-export"
+48 -8
View File
@@ -1,18 +1,58 @@
# MCP Server commands
# Robust MCP Server commands
# Usage: just mcp::run, just mcp::run-debug, etc.
#
# These commands run the MCP server that connects to FreeCAD.
# These commands run the Robust MCP Server that connects to FreeCAD.
# Note: FreeCAD must be running with the MCP bridge for the server to connect.
# Start FreeCAD with: just freecad::run-gui or just freecad::run-headless
# Run the MCP server (stdio mode - default for Claude Code integration)
run:
uv run freecad-mcp
# Check if FreeCAD bridge is available (test connection without starting server)
check:
uv run freecad-mcp --check
# Run the MCP server with debug logging
# Run the Robust MCP Server (stdio mode - default for Claude Code integration)
run:
#!/usr/bin/env bash
set -euo pipefail
echo "Checking FreeCAD bridge connection..."
if uv run freecad-mcp --check; then
echo ""
echo "Starting MCP server..."
uv run freecad-mcp
else
echo ""
echo "Cannot start MCP server - FreeCAD bridge is not available."
echo "Start FreeCAD with: just freecad::run-gui"
exit 1
fi
# Run the Robust MCP Server with debug logging
run-debug:
FREECAD_MCP_LOG_LEVEL=DEBUG uv run freecad-mcp
#!/usr/bin/env bash
set -euo pipefail
echo "Checking FreeCAD bridge connection..."
if uv run freecad-mcp --check; then
echo ""
echo "Starting MCP server with debug logging..."
FREECAD_LOG_LEVEL=DEBUG uv run freecad-mcp
else
echo ""
echo "Cannot start MCP server - FreeCAD bridge is not available."
echo "Start FreeCAD with: just freecad::run-gui"
exit 1
fi
# Run in HTTP mode for remote access (useful for testing or remote clients)
run-http port="8000":
FREECAD_MCP_TRANSPORT=http FREECAD_MCP_PORT={{port}} uv run freecad-mcp
#!/usr/bin/env bash
set -euo pipefail
echo "Checking FreeCAD bridge connection..."
if uv run freecad-mcp --check; then
echo ""
echo "Starting MCP server in HTTP mode on port {{port}}..."
FREECAD_TRANSPORT=http FREECAD_HTTP_PORT={{port}} uv run freecad-mcp
else
echo ""
echo "Cannot start MCP server - FreeCAD bridge is not available."
echo "Start FreeCAD with: just freecad::run-gui"
exit 1
fi
+4 -4
View File
@@ -48,11 +48,11 @@ typecheck:
# Run security scanning (code vulnerabilities)
security:
uv run bandit -c {{project_root}}/pyproject.toml -r {{project_root}}/src
uv run safety scan --detailed-output
cd {{project_root}} && uv run safety scan --detailed-output
# Run spell checking
spellcheck:
uv run codespell {{project_root}}/src {{project_root}}/tests {{project_root}}/docs
uv run codespell --ignore-words {{project_root}}/.codespell-ignore-words.txt {{project_root}}/src {{project_root}}/tests {{project_root}}/docs
# =============================================================================
# Secrets Scanning (quality::scan-* commands)
@@ -78,9 +78,9 @@ scan-detect:
scan-audit:
uv run detect-secrets audit {{project_root}}/.secrets.baseline
# Update detect-secrets baseline with new findings
# Update detect-secrets baseline with new findings (preserves audit metadata)
scan-baseline-update:
uv run detect-secrets scan --baseline {{project_root}}/.secrets.baseline --update
uv run detect-secrets scan --baseline {{project_root}}/.secrets.baseline --update {{project_root}}
# Run trufflehog for verified secrets (via pre-commit - not installed standalone)
scan-trufflehog:
+257 -26
View File
@@ -18,11 +18,11 @@
#
# Version Format (SemVer 2.0):
# - X.Y.Z - Stable release
# - X.Y.Z-alpha - Alpha (TestPyPI only for MCP server)
# - X.Y.Z-alpha.N - Alpha with number
# - X.Y.Z-beta - Beta
# - X.Y.Z-beta.N - Beta with number
# - X.Y.Z-rc.N - Release candidate
# - X.Y.Z-alpha - Alpha (TestPyPI only)
# - X.Y.Z-alpha.N - Alpha with number (TestPyPI only)
# - X.Y.Z-beta - Beta (TestPyPI only)
# - X.Y.Z-beta.N - Beta with number (TestPyPI only)
# - X.Y.Z-rc.N - Release candidate (TestPyPI only)
# Project root directory
project_root := justfile_directory()
@@ -31,7 +31,7 @@ project_root := justfile_directory()
# Version Bump Commands
# =============================================================================
# Bump the MCP Bridge workbench version in all source files
# Bump the Robust MCP Bridge workbench version in all source files
bump-workbench version:
#!/usr/bin/env bash
set -euo pipefail
@@ -46,7 +46,7 @@ bump-workbench version:
exit 1
fi
echo "Bumping MCP Bridge Workbench to version: $VERSION (date: $TODAY)"
echo "Bumping Robust MCP Bridge Workbench to version: $VERSION (date: $TODAY)"
echo ""
# Update __version__ in the bridge module's __init__.py
@@ -191,8 +191,8 @@ bump-macro-export version: (_bump-macro "Multi_Export" "Multi Export" "MultiExpo
# Tag Creation Commands
# =============================================================================
# Create and push a release tag for the MCP server (triggers PyPI + Docker release)
# Note: MCP server uses setuptools-scm, so version is derived from git tag at build time
# Create and push a release tag for the Robust MCP Server (triggers PyPI + Docker release)
# Note: Robust MCP Server uses setuptools-scm, so version is derived from git tag at build time
tag-mcp-server version:
#!/usr/bin/env bash
set -euo pipefail
@@ -213,7 +213,7 @@ tag-mcp-server version:
echo "Creating tag: $TAG"
echo ""
echo "This will trigger:"
echo " - PyPI release (beta/rc/stable) or TestPyPI (alpha)"
echo " - PyPI release (stable) or TestPyPI (alpha/beta/rc)"
echo " - Docker Hub release"
echo " - GitHub release with wheel and tar.gz"
echo ""
@@ -224,13 +224,13 @@ tag-mcp-server version:
exit 1
fi
git tag -a "$TAG" -m "Release MCP Server v{{version}}"
git tag -a "$TAG" -m "Release Robust MCP Server v{{version}}"
git push origin "$TAG"
echo ""
echo "Tag $TAG created and pushed!"
echo "Watch the release at: https://github.com/spkane/freecad-robust-mcp-and-more/actions"
# Create and push a release tag for the MCP Bridge workbench
# Create and push a release tag for the Robust MCP Bridge workbench
tag-workbench version:
#!/usr/bin/env bash
set -euo pipefail
@@ -289,7 +289,7 @@ tag-workbench version:
exit 1
fi
git tag -a "$TAG" -m "Release MCP Bridge Workbench v{{version}}"
git tag -a "$TAG" -m "Release Robust MCP Bridge Workbench v{{version}}"
git push origin "$TAG"
echo ""
echo "Tag $TAG created and pushed!"
@@ -386,7 +386,7 @@ tag-macro-export version: (_tag-macro "Multi_Export" "Multi Export" "MultiExport
# List all release tags grouped by component
list-tags:
#!/usr/bin/env bash
echo "=== MCP Server Releases ==="
echo "=== Robust MCP Server Releases ==="
git tag -l 'robust-mcp-server-v*' --sort=-v:refname | head -10
echo ""
echo "=== MCP Workbench Releases ==="
@@ -407,8 +407,8 @@ latest-versions:
WORKBENCH_TAG=$(git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -n1)
MAGNETS_TAG=$(git tag -l 'macro-cut-object-for-magnets-v*' --sort=-v:refname | head -n1)
EXPORT_TAG=$(git tag -l 'macro-multi-export-v*' --sort=-v:refname | head -n1)
echo " MCP Server: ${SERVER_TAG:-none}"
echo " MCP Workbench: ${WORKBENCH_TAG:-none}"
echo " Robust MCP Server: ${SERVER_TAG:-none}"
echo " Robust MCP Workbench: ${WORKBENCH_TAG:-none}"
echo " Macro Magnets: ${MAGNETS_TAG:-none}"
echo " Macro Export: ${EXPORT_TAG:-none}"
@@ -518,27 +518,27 @@ status:
fi
}
# MCP Server
# Robust MCP Server
SERVER_CHANGES=$(count_changes "robust-mcp-server-v" "src/freecad_mcp pyproject.toml Dockerfile")
SERVER_TAG=$(git tag -l 'robust-mcp-server-v*' --sort=-v:refname | head -1)
if [ "$SERVER_CHANGES" -gt 0 ]; then
echo "MCP Server: $SERVER_CHANGES unreleased commit(s)"
echo "Robust MCP Server: $SERVER_CHANGES unreleased commit(s)"
echo " Latest: ${SERVER_TAG:-none}"
echo " View: just release::changes-since mcp-server"
else
echo "MCP Server: up to date (${SERVER_TAG:-no releases})"
echo "Robust MCP Server: up to date (${SERVER_TAG:-no releases})"
fi
echo ""
# Workbench
# Robust MCP Bridge Workbench
WORKBENCH_CHANGES=$(count_changes "robust-mcp-workbench-v" "addon/FreecadRobustMCP")
WORKBENCH_TAG=$(git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -1)
if [ "$WORKBENCH_CHANGES" -gt 0 ]; then
echo "MCP Workbench: $WORKBENCH_CHANGES unreleased commit(s)"
echo "Robust MCP Bridge Workbench: $WORKBENCH_CHANGES unreleased commit(s)"
echo " Latest: ${WORKBENCH_TAG:-none}"
echo " View: just release::changes-since workbench"
else
echo "MCP Workbench: up to date (${WORKBENCH_TAG:-no releases})"
echo "Robust MCP Bridge Workbench: up to date (${WORKBENCH_TAG:-no releases})"
fi
echo ""
@@ -580,12 +580,12 @@ draft-notes component:
mcp-server|server)
PREFIX="robust-mcp-server-v"
PATHS="src/freecad_mcp pyproject.toml Dockerfile"
COMPONENT_NAME="MCP Server"
COMPONENT_NAME="Robust MCP Server"
;;
workbench)
PREFIX="robust-mcp-workbench-v"
PATHS="addon/FreecadRobustMCP"
COMPONENT_NAME="MCP Bridge Workbench"
COMPONENT_NAME="Robust MCP Bridge Workbench"
;;
macro-magnets|magnets)
PREFIX="macro-cut-object-for-magnets-v"
@@ -659,10 +659,10 @@ extract-changelog component version:
# Match header exactly as it appears in CHANGELOG.md
case "{{component}}" in
mcp-server|server)
HEADER="### MCP Server v{{version}}"
HEADER="### Robust MCP Server v{{version}}"
;;
workbench)
HEADER="### MCP Bridge Workbench v{{version}}"
HEADER="### Robust MCP Bridge Workbench v{{version}}"
;;
macro-magnets|magnets)
HEADER="### Cut Object for Magnets Macro v{{version}}"
@@ -730,3 +730,234 @@ dry-run-tag component version:
exit 1
;;
esac
# =============================================================================
# FreeCAD Wiki Update Helpers
# =============================================================================
# Helper to update FreeCAD wiki for a macro (copies content to clipboard and opens edit page)
wiki-update macro:
#!/usr/bin/env bash
set -euo pipefail
case "{{macro}}" in
macro-magnets|magnets|cut)
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
WIKI_PAGE="Macro_Cut_Object_for_Magnets"
MACRO_NAME="Cut Object for Magnets"
;;
macro-export|export|multi)
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
WIKI_PAGE="Macro_Multi_Export"
MACRO_NAME="Multi Export"
;;
*)
echo "Unknown macro: {{macro}}"
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)"
exit 1
;;
esac
WIKI_URL="https://wiki.freecad.org/index.php?title=${WIKI_PAGE}&action=edit"
echo "=========================================="
echo "FreeCAD Wiki Update Helper"
echo "=========================================="
echo ""
echo "Macro: $MACRO_NAME"
echo "Wiki Page: https://wiki.freecad.org/${WIKI_PAGE}"
echo ""
# Check if wiki-source.txt exists
if [ ! -f "$WIKI_SOURCE" ]; then
echo "ERROR: Wiki source file not found: $WIKI_SOURCE"
exit 1
fi
# Extract current version from wiki-source.txt
CURRENT_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_SOURCE" | cut -d= -f2 | tr -d '\n')
CURRENT_DATE=$(grep -o '|Date=[^|]*' "$WIKI_SOURCE" | cut -d= -f2 | tr -d '\n')
echo "Current version in wiki-source.txt:"
echo " Version: $CURRENT_VERSION"
echo " Date: $CURRENT_DATE"
echo ""
# Try to copy to clipboard (platform-specific)
COPIED=false
if command -v pbcopy &> /dev/null; then
# macOS
cat "$WIKI_SOURCE" | pbcopy
COPIED=true
echo "Content copied to clipboard (macOS pbcopy)"
elif command -v xclip &> /dev/null; then
# Linux with xclip
cat "$WIKI_SOURCE" | xclip -selection clipboard
COPIED=true
echo "Content copied to clipboard (xclip)"
elif command -v xsel &> /dev/null; then
# Linux with xsel
cat "$WIKI_SOURCE" | xsel --clipboard --input
COPIED=true
echo "Content copied to clipboard (xsel)"
elif command -v wl-copy &> /dev/null; then
# Wayland
cat "$WIKI_SOURCE" | wl-copy
COPIED=true
echo "Content copied to clipboard (wl-copy)"
else
echo "NOTE: No clipboard utility found (pbcopy, xclip, xsel, wl-copy)"
echo " You'll need to manually copy the content."
fi
echo ""
echo "=========================================="
echo "INSTRUCTIONS"
echo "=========================================="
echo ""
echo "1. The wiki edit page will open in your browser"
echo "2. Log in to your FreeCAD wiki account if prompted"
echo "3. Select ALL content in the edit box (Ctrl+A / Cmd+A)"
echo "4. Paste the new content (Ctrl+V / Cmd+V)"
echo "5. Add an edit summary like: 'Update to version $CURRENT_VERSION'"
echo "6. Click 'Show preview' to verify changes"
echo "7. Click 'Save changes' when satisfied"
echo ""
# Ask for confirmation before opening browser
read -p "Open wiki edit page in browser? [Y/n] " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
echo ""
echo "Aborted. You can manually visit:"
echo " $WIKI_URL"
echo ""
echo "Wiki source file location:"
echo " $WIKI_SOURCE"
exit 0
fi
# Open the wiki edit page in browser (platform-specific)
if command -v open &> /dev/null; then
# macOS
open "$WIKI_URL"
elif command -v xdg-open &> /dev/null; then
# Linux
xdg-open "$WIKI_URL"
elif command -v wslview &> /dev/null; then
# WSL
wslview "$WIKI_URL"
else
echo "Could not open browser automatically."
echo "Please manually visit: $WIKI_URL"
fi
echo ""
echo "Browser opened to: $WIKI_URL"
if [ "$COPIED" = true ]; then
echo ""
echo "The wiki content is in your clipboard - ready to paste!"
fi
# Show the wiki source content for a macro (for review)
wiki-show macro:
#!/usr/bin/env bash
set -euo pipefail
case "{{macro}}" in
macro-magnets|magnets|cut)
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
MACRO_NAME="Cut Object for Magnets"
;;
macro-export|export|multi)
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
MACRO_NAME="Multi Export"
;;
*)
echo "Unknown macro: {{macro}}"
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)"
exit 1
;;
esac
echo "=========================================="
echo "Wiki Source: $MACRO_NAME"
echo "=========================================="
echo "File: $WIKI_SOURCE"
echo ""
# Show version info
CURRENT_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_SOURCE" | cut -d= -f2 | tr -d '\n')
CURRENT_DATE=$(grep -o '|Date=[^|]*' "$WIKI_SOURCE" | cut -d= -f2 | tr -d '\n')
echo "Version: $CURRENT_VERSION"
echo "Date: $CURRENT_DATE"
echo ""
echo "=========================================="
echo ""
cat "$WIKI_SOURCE"
# Diff the local wiki source against the current wiki page (requires curl)
wiki-diff macro:
#!/usr/bin/env bash
set -euo pipefail
case "{{macro}}" in
macro-magnets|magnets|cut)
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
WIKI_PAGE="Macro_Cut_Object_for_Magnets"
MACRO_NAME="Cut Object for Magnets"
;;
macro-export|export|multi)
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
WIKI_PAGE="Macro_Multi_Export"
MACRO_NAME="Multi Export"
;;
*)
echo "Unknown macro: {{macro}}"
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)"
exit 1
;;
esac
WIKI_RAW_URL="https://wiki.freecad.org/index.php?title=${WIKI_PAGE}&action=raw"
echo "Fetching current wiki content for $MACRO_NAME..."
echo ""
# Create temp file for wiki content
TEMP_WIKI=$(mktemp)
trap "rm -f $TEMP_WIKI" EXIT
# Fetch current wiki content
if ! curl -sS "$WIKI_RAW_URL" > "$TEMP_WIKI" 2>/dev/null; then
echo "ERROR: Could not fetch wiki page. The page may not exist yet."
echo "URL: $WIKI_RAW_URL"
exit 1
fi
# Check if page exists (MediaWiki returns specific content for missing pages)
if grep -q "There is currently no text in this page" "$TEMP_WIKI"; then
echo "NOTE: Wiki page does not exist yet."
echo "This will be a new page creation."
echo ""
echo "Local content to be uploaded:"
echo "=========================================="
head -20 "$WIKI_SOURCE"
echo "..."
echo "(truncated - run 'just release::wiki-show {{macro}}' to see full content)"
exit 0
fi
echo "Comparing local wiki-source.txt with live wiki page..."
echo ""
# Show diff
if diff -u "$TEMP_WIKI" "$WIKI_SOURCE"; then
echo "No differences found - wiki is up to date!"
else
echo ""
echo "=========================================="
echo "Differences found (above)"
echo "Run 'just release::wiki-update {{macro}}' to update the wiki"
fi
+189 -20
View File
@@ -9,8 +9,12 @@ unit:
uv run pytest {{project_root}}/tests/unit
# Run tests with coverage (excludes integration tests)
# Note: Uses bash script to ensure .coverage file is created in project root
cov:
cd {{project_root}} && uv run pytest tests/unit --cov=freecad_mcp --cov-report=term-missing --cov-report=html:{{project_root}}/htmlcov
#!/usr/bin/env bash
set -euo pipefail
cd "{{project_root}}"
uv run pytest tests/unit --cov=freecad_mcp --cov-report=term-missing --cov-report=html:htmlcov
# Run tests without slow markers (excludes integration tests)
fast:
@@ -24,37 +28,148 @@ integration:
verbose:
uv run pytest {{project_root}}/tests/unit -v --tb=long
# Run all tests including integration (requires running FreeCAD MCP bridge)
# Run all tests including integration (auto-starts FreeCAD headless)
# Runs unit tests first (no FreeCAD needed), then delegates to integration-freecad-auto
all:
uv run pytest {{project_root}}/tests
# Run tests in watch mode (re-runs on file changes)
watch:
uv run pytest-watch {{project_root}}/tests/unit
# Run integration tests with automatic FreeCAD headless startup
integration-freecad:
#!/usr/bin/env bash
set -euo pipefail
echo "Running unit tests..."
echo ""
uv run pytest "{{project_root}}/tests/unit" -v
echo ""
echo "Unit tests passed! Now running integration tests..."
echo ""
# Delegate to integration-freecad-auto for FreeCAD lifecycle management
just testing::integration-freecad-auto
# Run tests in watch mode (re-runs on file changes)
# Note: --config specifies .pytest-watch.cfg to avoid pytest-watch parsing
# pyproject.toml as INI (it fails on valid TOML [[array.tables]] syntax)
watch:
#!/usr/bin/env bash
set -euo pipefail
echo ""
echo "========================================"
echo " WATCH MODE - Running initial tests..."
echo "========================================"
echo ""
uv run pytest-watch \
--config "{{project_root}}/.pytest-watch.cfg" \
--afterrun "echo '' && echo '========================================' && echo ' WATCHING for file changes...' && echo ' Press Ctrl+C to exit watch mode' && echo '========================================' && echo ''" \
"{{project_root}}/tests/unit"
# Run integration tests with automatic FreeCAD headless startup
integration-freecad-auto:
#!/usr/bin/env bash
set -euo pipefail
# Track whether we started FreeCAD (so cleanup knows to stop it)
STARTED_FREECAD=false
# Helper function to kill process on a port (with fallback for systems without lsof)
# Usage: kill_port PORT [SIGNAL]
# Examples: kill_port 9875 (sends SIGTERM)
# kill_port 9875 -9 (sends SIGKILL)
kill_port() {
local port=$1
local signal=${2:--TERM} # Default to SIGTERM if no signal specified
local pids=""
if command -v lsof &>/dev/null; then
# Collect PIDs first, then kill only if non-empty
pids=$(lsof -ti:"$port" 2>/dev/null || true)
if [[ -n "$pids" ]]; then
echo "$pids" | xargs kill "$signal" 2>/dev/null || true
fi
elif command -v fuser &>/dev/null; then
# fuser -k sends SIGKILL by default; use --signal for others
# Check if port is in use first
if fuser "$port/tcp" 2>/dev/null; then
if [ "$signal" = "-9" ] || [ "$signal" = "-KILL" ]; then
fuser -k "$port/tcp" 2>/dev/null || true
else
fuser -k --signal "${signal#-}" "$port/tcp" 2>/dev/null || true
fi
fi
fi
}
# Cleanup function to ensure FreeCAD is stopped
# We kill by port since the subshell approach makes PID tracking unreliable
cleanup() {
if [ "$STARTED_FREECAD" = true ]; then
echo ""
echo "Stopping FreeCAD..."
# Kill processes on our ports - these are the ones we started
kill_port 9875
kill_port 9876
# Wait briefly for graceful shutdown
sleep 1
# Force kill if still running
kill_port 9875 -9
kill_port 9876 -9
fi
}
# Set trap: EXIT runs cleanup on normal exit, INT/TERM run cleanup then exit
trap cleanup EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
echo "Starting FreeCAD headless server for integration tests..."
echo ""
# Check if a bridge is already running and responsive
if curl -s --connect-timeout 1 --max-time 1 http://localhost:9875 > /dev/null 2>&1; then
# Try to ping - if it responds, there's a healthy bridge already running
if uv run python -c "import socket; socket.setdefaulttimeout(2); import xmlrpc.client; print(xmlrpc.client.ServerProxy('http://localhost:9875').ping())" 2>/dev/null | grep -q "pong"; then
echo "ERROR: A FreeCAD MCP bridge is already running on port 9875."
echo ""
echo "Options:"
echo " 1. Use 'just testing::integration' to run tests against the existing bridge"
echo " 2. Stop the existing FreeCAD instance and try again"
echo " 3. If this is a zombie process, run: just testing::kill-bridge"
exit 1
else
# Port is bound but not responding to ping - likely a zombie
echo "WARNING: Port 9875 is bound but not responding (zombie process?)"
echo "Attempting to kill zombie process..."
kill_port 9875 -9
kill_port 9876 -9
sleep 2
fi
fi
# Mark that we're starting FreeCAD (for cleanup)
STARTED_FREECAD=true
# Start FreeCAD headless in background
just freecad::run-headless &
FREECAD_PID=$!
# Redirect stderr to log file (not /dev/null) so startup failures are visible
# Background process won't fail the script when killed by cleanup trap
FREECAD_LOG="{{project_root}}/freecad-headless.log"
just freecad::run-headless 2>"$FREECAD_LOG" &
# Give FreeCAD time to start the XML-RPC server
echo "Waiting for FreeCAD MCP bridge to start..."
sleep 5
# Check if the bridge is ready
# Check if the bridge is ready (verify XML-RPC ping, not just port open)
MAX_RETRIES=30
RETRY_COUNT=0
while ! curl -s http://localhost:9875 > /dev/null 2>&1; do
while ! uv run python -c "import socket; socket.setdefaulttimeout(2); import xmlrpc.client; print(xmlrpc.client.ServerProxy('http://localhost:9875').ping())" 2>/dev/null | grep -q "pong"; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "ERROR: FreeCAD MCP bridge did not start within timeout"
kill $FREECAD_PID 2>/dev/null || true
echo "Check log file for details: $FREECAD_LOG"
if [ -f "$FREECAD_LOG" ]; then
echo "--- Last 20 lines of log ---"
tail -20 "$FREECAD_LOG"
fi
exit 1
fi
echo " Waiting... ($RETRY_COUNT/$MAX_RETRIES)"
@@ -68,9 +183,63 @@ integration-freecad:
TEST_EXIT_CODE=0
uv run pytest "{{project_root}}/tests/integration" -v || TEST_EXIT_CODE=$?
# Stop FreeCAD
echo ""
echo "Stopping FreeCAD..."
kill $FREECAD_PID 2>/dev/null || true
# Cleanup is handled by trap
exit $TEST_EXIT_CODE
# =============================================================================
# Just Command Tests
# =============================================================================
# Run just command syntax tests (fast, validates all commands parse correctly)
just-syntax:
uv run pytest {{project_root}}/tests/just_commands -m "just_syntax" -v
# Run just command runtime tests (slower, actually executes commands)
just-runtime:
uv run pytest {{project_root}}/tests/just_commands -m "just_runtime and not slow" -v
# Run all just command tests
just-all:
uv run pytest {{project_root}}/tests/just_commands -v
# Run just command release tests (tests release commands with cleanup)
just-release:
uv run pytest {{project_root}}/tests/just_commands -m "just_release" -v
# =============================================================================
# Bridge Management
# =============================================================================
# Kill any zombie FreeCAD MCP bridge processes on the default ports
kill-bridge:
#!/usr/bin/env bash
set -euo pipefail
echo "Killing any processes on MCP bridge ports (9875, 9876)..."
# Helper function to kill process on a port (with fallback for systems without lsof)
# Collects PIDs first to avoid calling kill with no arguments
kill_port() {
local port=$1
local pids=""
if command -v lsof &>/dev/null; then
# Collect PIDs first, then kill only if non-empty
pids=$(lsof -ti:"$port" 2>/dev/null || true)
if [[ -n "$pids" ]]; then
echo "$pids" | xargs kill -9 2>/dev/null || true
fi
elif command -v fuser &>/dev/null; then
# Check if port is in use first
if fuser "$port/tcp" 2>/dev/null; then
fuser -k -9 "$port/tcp" 2>/dev/null || true
fi
else
echo "Warning: Neither lsof nor fuser available, cannot kill port $port"
fi
}
kill_port 9875
kill_port 9876
echo "Done."