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
+18
View File
@@ -21,6 +21,7 @@ fcstd
mcp
impl
vertexes
Vertexes
recomputation
heredoc
heredocs
@@ -44,3 +45,20 @@ jango
# File extensions and paths
md
js
# X11/Display/CI terms
AppImage
Xvfb
xvfb-run
xcb
ConfigureNotify
Expose
MapNotify
openbox
xdotool
qmlscene
ppoll
eventfd
# Environment variables
QT_QPA_PLATFORM
+2
View File
@@ -83,6 +83,8 @@ macros/
# Tests (not needed in runtime container)
tests/
# But allow CI test scripts for GUI testing container
!tests/ci-test/
# Development files
justfile
+215
View File
@@ -0,0 +1,215 @@
name: Setup FreeCAD
description: Downloads and sets up FreeCAD AppImage for CI testing (supports x86_64 and ARM64 runners)
inputs:
freecad-tag:
description: "Specific FreeCAD release tag to install (e.g., '1.0.2'). If empty, uses latest stable release."
required: false
default: ""
outputs:
freecad-tag:
description: The FreeCAD release tag that was installed
value: ${{ steps.freecad-release.outputs.tag }}
runs:
using: composite
steps:
- name: Get latest FreeCAD release info
id: freecad-release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
INPUT_FREECAD_TAG: ${{ inputs.freecad-tag }}
run: |
# Detect runner architecture
RUNNER_ARCH=$(uname -m)
case "$RUNNER_ARCH" in
x86_64)
ARCH_SUFFIX="x86_64"
;;
aarch64|arm64)
ARCH_SUFFIX="aarch64"
;;
*)
echo "ERROR: Unsupported architecture: $RUNNER_ARCH"
exit 1
;;
esac
echo "Detected architecture: $RUNNER_ARCH -> $ARCH_SUFFIX"
echo "arch=$ARCH_SUFFIX" >> "$GITHUB_OUTPUT"
# Get FreeCAD release info from GitHub API
# Use GitHub token to avoid rate limiting
if [ -n "$INPUT_FREECAD_TAG" ]; then
echo "Using specified FreeCAD tag: $INPUT_FREECAD_TAG"
RELEASE_INFO=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/FreeCAD/FreeCAD/releases/tags/$INPUT_FREECAD_TAG")
else
echo "Fetching latest FreeCAD release..."
RELEASE_INFO=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/FreeCAD/FreeCAD/releases/latest)
fi
# Validate response has required fields
TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name')
if [ -z "$TAG_NAME" ] || [ "$TAG_NAME" = "null" ]; then
echo "ERROR: Could not get tag_name from release info"
echo "Response: $RELEASE_INFO"
exit 1
fi
echo "FreeCAD release: $TAG_NAME"
echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT"
# Find the Linux AppImage asset URL for the detected architecture
# Priority: conda build with py311 > any conda build > any AppImage
# The conda builds are the officially recommended AppImages
APPIMAGE_URL=""
APPIMAGE_NAME=""
# First try: conda build with py311 (most specific, preferred)
APPIMAGE_URL=$(echo "$RELEASE_INFO" | jq -r ".assets[] | select(.name | test(\"conda-Linux-${ARCH_SUFFIX}-py311\\\\.AppImage$\")) | .browser_download_url" | head -1)
APPIMAGE_NAME=$(echo "$RELEASE_INFO" | jq -r ".assets[] | select(.name | test(\"conda-Linux-${ARCH_SUFFIX}-py311\\\\.AppImage$\")) | .name" | head -1)
# Second try: any conda build for the architecture
if [ -z "$APPIMAGE_URL" ] || [ "$APPIMAGE_URL" = "null" ]; then
echo "Note: py311 conda build not found, trying any conda build..."
APPIMAGE_URL=$(echo "$RELEASE_INFO" | jq -r ".assets[] | select(.name | test(\"conda-Linux-${ARCH_SUFFIX}.*\\\\.AppImage$\")) | .browser_download_url" | head -1)
APPIMAGE_NAME=$(echo "$RELEASE_INFO" | jq -r ".assets[] | select(.name | test(\"conda-Linux-${ARCH_SUFFIX}.*\\\\.AppImage$\")) | .name" | head -1)
fi
# Third try: any AppImage for the architecture (fallback)
if [ -z "$APPIMAGE_URL" ] || [ "$APPIMAGE_URL" = "null" ]; then
echo "Note: conda build not found, trying any ${ARCH_SUFFIX} AppImage..."
APPIMAGE_URL=$(echo "$RELEASE_INFO" | jq -r ".assets[] | select(.name | test(\"Linux-${ARCH_SUFFIX}.*\\\\.AppImage$\")) | .browser_download_url" | head -1)
APPIMAGE_NAME=$(echo "$RELEASE_INFO" | jq -r ".assets[] | select(.name | test(\"Linux-${ARCH_SUFFIX}.*\\\\.AppImage$\")) | .name" | head -1)
fi
if [ -z "$APPIMAGE_URL" ] || [ "$APPIMAGE_URL" = "null" ]; then
echo "ERROR: Could not find Linux ${ARCH_SUFFIX} AppImage in release assets"
echo "Available assets:"
echo "$RELEASE_INFO" | jq -r '.assets[].name'
exit 1
fi
echo "AppImage URL: $APPIMAGE_URL"
echo "AppImage name: $APPIMAGE_NAME"
echo "url=$APPIMAGE_URL" >> "$GITHUB_OUTPUT"
echo "name=$APPIMAGE_NAME" >> "$GITHUB_OUTPUT"
- name: Cache FreeCAD AppImage
id: cache-freecad
uses: actions/cache@v5
with:
path: ~/freecad-appimage
key: ${{ runner.os }}-${{ steps.freecad-release.outputs.arch }}-freecad-appimage-${{ steps.freecad-release.outputs.tag }}
- name: Download FreeCAD AppImage
if: steps.cache-freecad.outputs.cache-hit != 'true'
shell: bash
run: |
mkdir -p ~/freecad-appimage
echo "Downloading FreeCAD ${{ steps.freecad-release.outputs.tag }}..."
# Use retry, timeout, and fail-fast flags for reliable downloads
curl -L --retry 3 --retry-delay 5 --connect-timeout 30 --max-time 600 \
-f -o ~/freecad-appimage/FreeCAD.AppImage \
"${{ steps.freecad-release.outputs.url }}"
chmod +x ~/freecad-appimage/FreeCAD.AppImage
- name: Setup FreeCAD AppImage
shell: bash
run: |
# Make AppImage executable (in case restored from cache)
chmod +x ~/freecad-appimage/FreeCAD.AppImage
# Extract AppImage for headless use (AppImages need FUSE which isn't available in CI)
# Skip extraction if already extracted (from cache)
cd ~/freecad-appimage
if [ ! -d "squashfs-root" ]; then
echo "Extracting AppImage..."
if ! ./FreeCAD.AppImage --appimage-extract > /dev/null 2>&1; then
echo "ERROR: AppImage extraction failed"
exit 1
fi
# Verify extraction produced expected structure
if [ ! -d "squashfs-root/usr/bin" ]; then
echo "ERROR: Extracted AppImage missing expected structure"
exit 1
fi
else
echo "Using cached extracted AppImage"
fi
# Create wrapper scripts that use AppRun to properly set up the environment
# The conda-based FreeCAD AppImage has complex environment requirements
# Using AppRun ensures all paths and variables are correctly configured
APPIMAGE_DIR="$HOME/freecad-appimage/squashfs-root"
# Check if AppRun exists and show its structure
echo "Checking AppImage structure..."
ls -la "$APPIMAGE_DIR/" | head -20
if [ -f "$APPIMAGE_DIR/AppRun" ]; then
echo "AppRun found, will use it for wrappers"
else
echo "WARNING: AppRun not found, falling back to direct execution"
fi
# Create freecadcmd wrapper - use AppRun with freecadcmd as argument
{
echo '#!/bin/bash'
echo "export APPDIR=\"$APPIMAGE_DIR\""
echo "export APPIMAGE_EXTRACT_AND_RUN=1"
echo "# Use AppRun if available, otherwise direct execution"
echo "if [ -f \"\$APPDIR/AppRun\" ]; then"
echo " exec \"\$APPDIR/AppRun\" freecadcmd \"\$@\""
echo "else"
echo " export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib:\$LD_LIBRARY_PATH\""
echo " exec \"\$APPDIR/usr/bin/freecadcmd\" \"\$@\""
echo "fi"
} | sudo tee /usr/local/bin/freecadcmd > /dev/null
sudo chmod +x /usr/local/bin/freecadcmd
# Create freecad (GUI) wrapper - use AppRun with freecad as argument
{
echo '#!/bin/bash'
echo "export APPDIR=\"$APPIMAGE_DIR\""
echo "export APPIMAGE_EXTRACT_AND_RUN=1"
echo "# Use AppRun if available, otherwise direct execution"
echo "if [ -f \"\$APPDIR/AppRun\" ]; then"
echo " exec \"\$APPDIR/AppRun\" freecad \"\$@\""
echo "else"
echo " export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib:\$LD_LIBRARY_PATH\""
echo " exec \"\$APPDIR/usr/bin/freecad\" \"\$@\""
echo "fi"
} | sudo tee /usr/local/bin/freecad > /dev/null
sudo chmod +x /usr/local/bin/freecad
echo "Created wrapper scripts for freecad and freecadcmd"
- name: Verify FreeCAD installation
shell: bash
run: |
echo "Checking FreeCAD installation..."
# Only verify with freecadcmd (headless) - freecad --version displays a GUI dialog
# and would hang without a window manager (Xvfb + openbox)
if freecadcmd --version; then
echo "FreeCAD version check passed"
else
echo "ERROR: freecadcmd --version failed"
echo ""
echo "=== Diagnostic Information ==="
echo "--- which freecadcmd ---"
which freecadcmd || echo "freecadcmd not found in PATH"
echo "--- which freecad ---"
which freecad || echo "freecad not found in PATH"
echo "--- FreeCAD AppImage bin directory ---"
ls -la ~/freecad-appimage/squashfs-root/usr/bin/ | head -20 || echo "Directory not found"
echo "--- PATH ---"
echo "$PATH"
echo "=== End Diagnostic Information ==="
exit 1
fi
which freecadcmd
which freecad
# Show Python version bundled with FreeCAD
freecadcmd -c "import sys; print(f'FreeCAD Python: {sys.version}')" || true
+249 -61
View File
@@ -10,6 +10,7 @@ on:
- "addon/FreecadRobustMCP/**/*.py"
- "tests/integration/**/*.py"
- ".github/workflows/macro-test.yaml"
- ".github/actions/setup-freecad/**"
pull_request:
branches: [main, master]
paths:
@@ -19,6 +20,7 @@ on:
- "addon/FreecadRobustMCP/**/*.py"
- "tests/integration/**/*.py"
- ".github/workflows/macro-test.yaml"
- ".github/actions/setup-freecad/**"
workflow_dispatch:
workflow_call:
@@ -31,6 +33,7 @@ jobs:
test-integration:
name: Integration Tests with FreeCAD
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
@@ -39,64 +42,8 @@ jobs:
- name: Install mise
uses: jdx/mise-action@v3
- name: Get latest FreeCAD release info
id: freecad-release
run: |
# Get the latest stable release tag from GitHub API
RELEASE_INFO=$(curl -s https://api.github.com/repos/FreeCAD/FreeCAD/releases/latest)
TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name')
echo "Latest FreeCAD release: $TAG_NAME"
echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT"
# Find the Linux AppImage asset URL
APPIMAGE_URL=$(echo "$RELEASE_INFO" | jq -r '.assets[] | select(.name | test("Linux-x86_64.*\\.AppImage$")) | .browser_download_url' | head -1)
APPIMAGE_NAME=$(echo "$RELEASE_INFO" | jq -r '.assets[] | select(.name | test("Linux-x86_64.*\\.AppImage$")) | .name' | head -1)
if [ -z "$APPIMAGE_URL" ] || [ "$APPIMAGE_URL" = "null" ]; then
echo "ERROR: Could not find Linux AppImage in release assets"
exit 1
fi
echo "AppImage URL: $APPIMAGE_URL"
echo "AppImage name: $APPIMAGE_NAME"
echo "url=$APPIMAGE_URL" >> "$GITHUB_OUTPUT"
echo "name=$APPIMAGE_NAME" >> "$GITHUB_OUTPUT"
- name: Cache FreeCAD AppImage
id: cache-freecad
uses: actions/cache@v5
with:
path: ~/freecad-appimage
key: freecad-appimage-${{ steps.freecad-release.outputs.tag }}
- name: Download FreeCAD AppImage
if: steps.cache-freecad.outputs.cache-hit != 'true'
run: |
mkdir -p ~/freecad-appimage
echo "Downloading FreeCAD ${{ steps.freecad-release.outputs.tag }}..."
curl -L -o ~/freecad-appimage/FreeCAD.AppImage "${{ steps.freecad-release.outputs.url }}"
chmod +x ~/freecad-appimage/FreeCAD.AppImage
- name: Setup FreeCAD AppImage
run: |
# Make AppImage executable (in case restored from cache)
chmod +x ~/freecad-appimage/FreeCAD.AppImage
# Extract AppImage for headless use (AppImages need FUSE which isn't available in CI)
cd ~/freecad-appimage
./FreeCAD.AppImage --appimage-extract > /dev/null 2>&1
# Create symlinks for easy access
sudo ln -sf ~/freecad-appimage/squashfs-root/usr/bin/freecadcmd /usr/local/bin/freecadcmd
sudo ln -sf ~/freecad-appimage/squashfs-root/usr/bin/freecad /usr/local/bin/freecad
- name: Verify FreeCAD installation
run: |
echo "Checking FreeCAD installation..."
freecadcmd --version || freecad --version || echo "Version check failed"
which freecadcmd
# Show Python version bundled with FreeCAD
freecadcmd -c "import sys; print(f'FreeCAD Python: {sys.version}')" || true
- name: Setup FreeCAD
uses: ./.github/actions/setup-freecad
- name: Cache uv dependencies
uses: astral-sh/setup-uv@v7
@@ -124,8 +71,9 @@ jobs:
echo "Using FreeCAD: freecadcmd"
# Start FreeCAD headless with MCP bridge in background
# Uses the workbench addon's headless server script
freecadcmd addon/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py > /tmp/freecad_bridge.log 2>&1 &
# Uses the workbench addon's blocking bridge script
# Use setsid to create a new process group for reliable cleanup
setsid freecadcmd addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 &
FREECAD_PID=$!
echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV"
@@ -181,11 +129,251 @@ jobs:
run: |
uv run pytest tests/integration/test_headless_mode.py -v --tb=short
- name: Upload FreeCAD logs on failure
if: failure() || cancelled()
uses: actions/upload-artifact@v6
with:
name: freecad-headless-logs
path: /tmp/freecad_bridge.log
retention-days: 7
if-no-files-found: ignore
- name: Stop FreeCAD
if: always()
run: |
if [ -n "$FREECAD_PID" ]; then
kill "$FREECAD_PID" 2>/dev/null || true
# Kill process group (handles child processes spawned by FreeCAD/AppImage)
kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true
fi
test-gui:
name: GUI Tests with FreeCAD + Xvfb
runs-on: ubuntu-latest
timeout-minutes: 30
# FreeCAD GUI requires a window manager to generate expose/configure events.
# Without a WM, the GUI binary hangs during Qt initialization.
# Solution: Xvfb + openbox window manager + xdotool for synthetic events.
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install mise
uses: jdx/mise-action@v3
# Note: apt cache removed - caching /var/cache/apt/archives causes permission
# issues and is largely negated by the immediate sudo apt-get update
- name: Install Xvfb, openbox, and X11 dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
xvfb \
openbox \
xdotool \
libxkbcommon-x11-0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-xinerama0 \
libxcb-xfixes0 \
libxcb-shape0 \
libxcb-cursor0 \
x11-utils \
libegl1 \
libgl1-mesa-dri \
libgl1 \
mesa-utils \
fontconfig \
fonts-dejavu-core
# Rebuild font cache (required after installing fonts, especially from cache)
sudo fc-cache -f -v
- name: Setup FreeCAD
uses: ./.github/actions/setup-freecad
- name: Start Xvfb and openbox
run: |
echo "Starting Xvfb virtual display..."
# Use -nolisten tcp to prevent TCP listeners for security
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
XVFB_PID=$!
echo "XVFB_PID=$XVFB_PID" >> "$GITHUB_ENV"
echo "DISPLAY=:99" >> "$GITHUB_ENV"
sleep 2
# Verify Xvfb is running
if ! ps -p $XVFB_PID > /dev/null; then
echo "ERROR: Xvfb failed to start"
exit 1
fi
echo "Xvfb started on display :99 (PID: $XVFB_PID)"
# Start openbox window manager (required for FreeCAD GUI)
# FreeCAD needs a WM to generate expose/configure events
echo "Starting openbox window manager..."
DISPLAY=:99 openbox &
OPENBOX_PID=$!
echo "OPENBOX_PID=$OPENBOX_PID" >> "$GITHUB_ENV"
sleep 1
if ! ps -p $OPENBOX_PID > /dev/null; then
echo "WARNING: openbox may have failed to start"
else
echo "openbox started (PID: $OPENBOX_PID)"
fi
- name: Verify X11 display
env:
DISPLAY: ":99"
run: |
echo "Checking X11 display..."
xdpyinfo | head -10 || echo "xdpyinfo not available"
- name: Cache uv dependencies
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "**/uv.lock"
- name: Install dependencies
run: uv sync --all-extras
- name: Verify fontconfig setup
run: |
echo "Checking fontconfig configuration..."
# Verify the system fontconfig config exists
if [ -f /etc/fonts/fonts.conf ]; then
echo "System fontconfig found at /etc/fonts/fonts.conf"
else
echo "ERROR: /etc/fonts/fonts.conf not found!"
ls -la /etc/fonts/ || echo "/etc/fonts/ directory doesn't exist"
fi
# Test fontconfig is working
echo "Testing fc-list..."
fc-list | head -5 || echo "fc-list failed"
echo "Fontconfig setup verified."
- name: Start FreeCAD GUI with MCP bridge
env:
DISPLAY: ":99"
QT_QPA_PLATFORM: xcb
# Use software rendering for OpenGL (more reliable in CI)
LIBGL_ALWAYS_SOFTWARE: "1"
# Explicit fontconfig paths for AppImage compatibility
# The AppImage's bundled fontconfig may not find system config automatically
FONTCONFIG_FILE: /etc/fonts/fonts.conf
FONTCONFIG_PATH: /etc/fonts
# Set XDG_RUNTIME_DIR to avoid Qt warning
XDG_RUNTIME_DIR: /tmp/runtime-root
run: |
# Create XDG_RUNTIME_DIR
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
echo "Starting FreeCAD GUI with MCP bridge under Xvfb + openbox..."
echo "DISPLAY=$DISPLAY"
echo "QT_QPA_PLATFORM=$QT_QPA_PLATFORM"
echo "LIBGL_ALWAYS_SOFTWARE=$LIBGL_ALWAYS_SOFTWARE"
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
# Verify freecadcmd works (headless mode)
echo "=== Testing freecadcmd (headless) ==="
freecadcmd --version || echo "freecadcmd failed"
# Export fontconfig vars so they're available to backgrounded process
export FONTCONFIG_FILE FONTCONFIG_PATH
# Start FreeCAD GUI (not headless) with MCP bridge
# Uses blocking_bridge.py which blocks with run_forever() to keep process alive
# GUI features are available since we're using 'freecad' not 'freecadcmd'
# Use setsid to create a new process group for reliable cleanup
setsid freecad addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_gui.log 2>&1 &
FREECAD_PID=$!
echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV"
echo "Waiting for MCP bridge to start..."
# Send xdotool events to help FreeCAD GUI initialize
# FreeCAD needs mouse/keyboard events to process its event queue
for i in {1..90}; do
# Send synthetic events to help FreeCAD initialize
xdotool mousemove $((400 + i*3)) $((300 + i*3)) click 1 key Escape 2>/dev/null || true
# Check if process is still running
if ! ps -p "$FREECAD_PID" > /dev/null 2>&1; then
echo "ERROR: FreeCAD process died (PID $FREECAD_PID)"
echo "=== FreeCAD GUI log ==="
cat /tmp/freecad_gui.log || echo "Log file is empty or missing"
exit 1
fi
if curl -s --max-time 2 -X POST \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
http://localhost:9875 > /dev/null 2>&1; then
echo "MCP bridge is ready (took ${i}s)"
break
fi
if [ "$i" -eq 90 ]; then
echo "ERROR: MCP bridge did not start within 90s"
echo "=== FreeCAD GUI log ==="
cat /tmp/freecad_gui.log || echo "Log file is empty or missing"
echo "=== Process status ==="
pgrep -a freecad || echo "No freecad processes found"
kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true
exit 1
fi
# Show progress every 10 seconds
if [ $((i % 10)) -eq 0 ]; then
echo "Still waiting... (${i}s)"
# Show last few lines of log
tail -5 /tmp/freecad_gui.log 2>/dev/null || true
fi
sleep 1
done
# Verify GUI is available
sleep 2
BRIDGE_INSTANCE_ID=$(grep -o 'FREECAD_MCP_BRIDGE_INSTANCE_ID=[^ ]*' /tmp/freecad_gui.log | cut -d= -f2 | head -1)
echo "Bridge Instance ID: $BRIDGE_INSTANCE_ID"
# Check if GUI is up
if grep -q "GuiUp.*True\|GUI.*available" /tmp/freecad_gui.log 2>/dev/null; then
echo "FreeCAD GUI is available"
else
echo "Note: Could not confirm GUI status from log (may still work)"
fi
- name: Run GUI mode integration tests
env:
FREECAD_MODE: xmlrpc
DISPLAY: ":99"
# Tests for GUI-only features (screenshots, visibility, colors, camera, etc.)
# GUI tests under Xvfb can be flaky; don't fail the workflow during stabilization
continue-on-error: true
run: |
uv run pytest tests/integration/test_gui_mode.py -v --tb=short
- name: Upload FreeCAD logs on failure
if: failure() || cancelled()
uses: actions/upload-artifact@v6
with:
name: freecad-gui-logs
path: /tmp/freecad_gui.log
retention-days: 7
if-no-files-found: ignore
- name: Stop FreeCAD, openbox, and Xvfb
if: always()
run: |
if [ -n "$FREECAD_PID" ]; then
# Kill process group (handles child processes spawned by FreeCAD/AppImage)
kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true
fi
if [ -n "$OPENBOX_PID" ]; then
kill "$OPENBOX_PID" 2>/dev/null || true
fi
if [ -n "$XVFB_PID" ]; then
kill "$XVFB_PID" 2>/dev/null || true
fi
lint-macros:
+7 -7
View File
@@ -1,4 +1,4 @@
name: MCP Server Release
name: Robust MCP Server Release
# Trigger on tag push matching the component-specific pattern
on:
@@ -176,7 +176,7 @@ jobs:
name: Publish to TestPyPI
needs: [validate-tag, build, test-install]
runs-on: ubuntu-latest
if: contains(needs.validate-tag.outputs.version, '-alpha')
if: contains(needs.validate-tag.outputs.version, '-')
environment:
name: testpypi
url: https://test.pypi.org/p/freecad-robust-mcp
@@ -199,7 +199,7 @@ jobs:
name: Publish to PyPI
needs: [validate-tag, build, test-install]
runs-on: ubuntu-latest
if: ${{ !contains(needs.validate-tag.outputs.version, '-alpha') }}
if: ${{ !contains(needs.validate-tag.outputs.version, '-') }}
environment:
name: pypi
url: https://pypi.org/p/freecad-robust-mcp
@@ -299,7 +299,7 @@ jobs:
run: |
VERSION="${{ needs.validate-tag.outputs.version }}"
# Match header exactly as it appears in CHANGELOG.md
HEADER="### MCP Server v${VERSION}"
HEADER="### Robust MCP Server v${VERSION}"
# Extract section between this version header and the next component header or separator
# Only exit on: "---" separator OR "### " followed by component name (capital letter)
@@ -327,7 +327,7 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "MCP Server v${{ needs.validate-tag.outputs.version }}"
name: "Robust MCP Server v${{ needs.validate-tag.outputs.version }}"
tag_name: ${{ github.ref_name }}
prerelease: ${{ needs.validate-tag.outputs.is_prerelease == 'true' }}
generate_release_notes: true
@@ -349,14 +349,14 @@ jobs:
IS_PRERELEASE: ${{ needs.validate-tag.outputs.is_prerelease }}
run: |
{
echo "## MCP Server Release Summary"
echo "## Robust MCP Server Release Summary"
echo ""
echo "**Version:** $VERSION"
echo "**Prerelease:** $IS_PRERELEASE"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$VERSION" == *"-alpha"* ]]; then
if [[ "$VERSION" == *"-"* ]]; then
{
echo "### Install from TestPyPI"
echo ""
+10 -10
View File
@@ -96,7 +96,7 @@ jobs:
# Create README for the archive
cat > "staging/freecad-mcp-workbench-${VERSION}/README.md" << EOF
# FreeCAD MCP Bridge Workbench
# Robust MCP Bridge Workbench
**Version:** ${VERSION}
@@ -106,7 +106,7 @@ jobs:
1. Open FreeCAD
2. Go to **Tools → Addon Manager**
3. Search for "MCP Bridge"
3. Search for "Robust MCP Bridge"
4. Click **Install**
5. Restart FreeCAD
@@ -120,7 +120,7 @@ jobs:
## Usage
1. Switch to the **MCP Bridge** workbench
1. Switch to the **Robust MCP Bridge** workbench
2. Click **Start MCP Bridge** in the toolbar
3. Connect your MCP client (Claude Code, etc.)
@@ -152,7 +152,7 @@ jobs:
run: |
VERSION="${{ steps.version.outputs.version }}"
# Match header exactly as it appears in CHANGELOG.md (with optional space before v)
HEADER="### MCP Bridge Workbench v${VERSION}"
HEADER="### Robust MCP Bridge Workbench v${VERSION}"
# Extract section between this version header and the next component header or separator
# Only exit on: "---" separator OR "### " followed by component name (capital letter)
@@ -167,19 +167,19 @@ jobs:
# Build release body with changelog content if available
cat > release_body.md << 'STATIC_EOF'
## FreeCAD MCP Bridge Workbench v${{ steps.version.outputs.version }}
## Robust MCP Bridge Workbench v${{ steps.version.outputs.version }}
This release contains the MCP Bridge workbench for FreeCAD.
This release contains the Robust MCP Bridge workbench for FreeCAD.
### Installation
**Recommended:** Install via FreeCAD's Addon Manager (search for "MCP Bridge").
**Recommended:** Install via FreeCAD's Addon Manager (search for "Robust MCP Bridge").
**Manual:** Download and extract to your FreeCAD Mod directory.
### What's Included
- MCP Bridge workbench for GUI and headless FreeCAD
- Robust MCP Bridge workbench for GUI and headless FreeCAD
- XML-RPC and JSON-RPC server support
- Toolbar commands for bridge control
@@ -198,7 +198,7 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "MCP Bridge Workbench v${{ steps.version.outputs.version }}"
name: "Robust MCP Bridge Workbench v${{ steps.version.outputs.version }}"
tag_name: ${{ github.ref_name }}
prerelease: ${{ steps.version.outputs.is_prerelease == 'true' }}
generate_release_notes: true
@@ -214,7 +214,7 @@ jobs:
VERSION="${{ steps.version.outputs.version }}"
{
echo "## MCP Bridge Workbench Release"
echo "## Robust MCP Bridge Workbench Release"
echo ""
echo "**Version:** ${VERSION}"
echo ""
+2
View File
@@ -47,6 +47,8 @@ jobs:
# Skip hooks that don't work well in CI:
# - no-commit-to-branch: Always fails in CI (we're on main/master)
# - trufflehog: Has wasm/go-re2 panic bug in GitHub Actions environment
# Note: shellcheck, hadolint, trivy use mise-managed binaries which ARE
# installed by mise-action above, so they should work in CI.
SKIP: no-commit-to-branch,trufflehog
# Safety CLI API key for dependency vulnerability scanning
SAFETY_API_KEY: ${{ secrets.SAFETY_API_KEY }}
+4 -2
View File
@@ -55,5 +55,7 @@ jobs:
run: uv run mypy src/
# Note: FreeCAD integration tests are handled by the "Integration Tests" workflow
# (macro-test.yaml) which runs on every PR. That workflow sets up FreeCAD
# AppImage and runs tests/integration/ tests with proper headless configuration.
# (macro-test.yaml) which runs on every PR. That workflow provides:
# - Headless tests: test_headless_mode.py, test_cut_object_for_magnets.py, test_multi_export.py
# - GUI tests: test_gui_mode.py (uses Xvfb virtual display)
# See .github/workflows/macro-test.yaml for details.
+2
View File
@@ -100,6 +100,7 @@ cython_debug/
# Trivy (pre-commit and CI cache)
.pre-commit-trivy-cache/
.trivy-cache/
trivy-results.sarif
# UV
# Note: uv.lock is committed for reproducible CI builds
@@ -116,6 +117,7 @@ Thumbs.db
*.FCStd
*.FCStd1
*.FCBak
freecad-headless.log
# Local configuration
.mcp.json
+2 -2
View File
@@ -15,9 +15,9 @@ github-cli = "2.74" # GitHub CLI for PR/issue management
# Security and code quality tools
trivy = "0.62" # container vulnerability scanner
gitleaks = "8.30" # secrets scanner
hadolint = "2.14" # Dockerfile linter
shellcheck = "0.11" # shell script linter
actionlint = "1.7" # GitHub Actions linter
# Note: hadolint and shellcheck are managed by their pre-commit repos
# (hadolint-py and shellcheck-py auto-download binaries)
# Markdown tools
markdownlint-cli2 = "0.20" # markdown linter
+30 -23
View File
@@ -9,7 +9,7 @@ repos:
# General File Hygiene
# ==========================================================================
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: \.md$ # Allow trailing spaces in markdown for line breaks
@@ -48,7 +48,7 @@ repos:
# Python - Linting and Formatting
# ==========================================================================
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
rev: v0.14.11
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
@@ -62,7 +62,7 @@ repos:
# Python - Type Checking
# ==========================================================================
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0
rev: v1.19.1
hooks:
- id: mypy
additional_dependencies:
@@ -77,7 +77,7 @@ repos:
# Python - Security Scanning
# ==========================================================================
- repo: https://github.com/PyCQA/bandit
rev: 1.8.0
rev: 1.9.2
hooks:
- id: bandit
args: [-c, pyproject.toml, -r, src, macros]
@@ -107,7 +107,7 @@ repos:
# Scans git history and current files using regex patterns
# Config: .gitleaks.toml
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
rev: v8.30.0
hooks:
- id: gitleaks
name: gitleaks (secrets scanner)
@@ -141,7 +141,7 @@ repos:
# It's skipped in CI (via SKIP env var) but runs locally.
# See: https://github.com/trufflesecurity/trufflehog/issues/3321
- repo: https://github.com/trufflesecurity/trufflehog
rev: v3.88.7
rev: v3.92.4
hooks:
- id: trufflehog
name: trufflehog (verified secrets scan)
@@ -156,7 +156,7 @@ repos:
# markdownlint-cli2 - Comprehensive markdown linter with auto-fix
# Config: .markdownlint.yaml
- repo: https://github.com/DavidAnson/markdownlint-cli2
rev: v0.17.1
rev: v0.20.0
hooks:
- id: markdownlint-cli2
name: markdownlint (linter)
@@ -176,7 +176,7 @@ repos:
# Spell Checking
# ==========================================================================
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.4.1
hooks:
- id: codespell
additional_dependencies:
@@ -191,12 +191,12 @@ repos:
# Configuration Validation
# ==========================================================================
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.23
rev: v0.24.1
hooks:
- id: validate-pyproject
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.30.0
rev: 0.36.0
hooks:
- id: check-github-workflows
name: validate GitHub workflows
@@ -207,7 +207,7 @@ repos:
# GitHub Actions Linting
# ==========================================================================
- repo: https://github.com/rhysd/actionlint
rev: v1.7.4
rev: v1.7.10
hooks:
- id: actionlint
name: actionlint (GitHub Actions linter)
@@ -216,7 +216,7 @@ repos:
# Shell Script Linting
# ==========================================================================
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.10.0.1
rev: v0.11.0.1
hooks:
- id: shellcheck
name: shellcheck (shell linter)
@@ -225,26 +225,34 @@ repos:
# ==========================================================================
# Dockerfile Linting
# ==========================================================================
- repo: https://github.com/hadolint/hadolint
rev: v2.13.1-beta
# hadolint-py: Python wrapper that auto-downloads hadolint binary
# No Docker or system installation required
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.14.0
hooks:
- id: hadolint-docker
name: hadolint (Dockerfile linter)
- id: hadolint
# ==========================================================================
# Dockerfile Security Scanning (Misconfigurations)
# ==========================================================================
- repo: https://github.com/mxab/pre-commit-trivy.git
rev: v0.16.0
# Uses mise-managed trivy binary instead of pre-commit repo.
# This avoids case-conflicting git refs in pre-commit-trivy repo that break
# `pre-commit autoupdate` on case-insensitive filesystems (macOS).
# Version is managed in .mise.toml - update with `mise upgrade trivy`
- repo: local
hooks:
- id: trivyconfig-docker
- id: trivy
name: trivy (Dockerfile misconfig)
entry: trivy
args:
- config
- --severity
- HIGH,CRITICAL
- --exit-code
- "1"
- .
language: system
files: (Dockerfile|\.dockerfile)$
pass_filenames: true
# ==========================================================================
# Documentation Build Validation
@@ -262,7 +270,7 @@ repos:
# Commit Message Linting
# ==========================================================================
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.1.0
rev: v4.11.1
hooks:
- id: commitizen
name: commitizen (commit format)
@@ -303,7 +311,6 @@ ci:
autoupdate_commit_msg: "chore(deps): update pre-commit hooks"
skip:
- mypy # Needs dependencies installed
- hadolint-docker # Needs Docker
- trivyconfig-docker # Needs Docker
- trivy # Uses mise-managed binary (local repo)
- trufflehog # Can be slow in CI
- coderabbit # GitHub App handles PR reviews; CLI is for local use
+4
View File
@@ -0,0 +1,4 @@
# Empty config file for pytest-watch
# This exists to prevent pytest-watch from parsing pyproject.toml as INI
# (pytest-watch incorrectly uses configparser which can't handle TOML arrays)
[pytest-watch]
+1 -1
View File
@@ -127,5 +127,5 @@
}
],
"results": {},
"generated_at": "2026-01-07T11:33:09Z"
"generated_at": "2026-01-10T20:47:49Z"
}
+11 -7
View File
@@ -9,8 +9,8 @@ This is a multi-component project. Each component has its own versioning and rel
| Component | Tag Format | Distribution |
| ------------------------------ | ------------------------------------- | ------------------------ |
| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI, Docker Hub, GitHub |
| MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release |
| Robust MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI, Docker Hub, GitHub |
| Robust MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release |
| Cut Object for Magnets Macro | `macro-cut-object-for-magnets-vX.Y.Z` | GitHub Release |
| Multi Export Macro | `macro-multi-export-vX.Y.Z` | GitHub Release |
@@ -18,9 +18,13 @@ This is a multi-component project. Each component has its own versioning and rel
## [Unreleased]
### MCP Server
### Robust MCP Server
### MCP Bridge Workbench
#### Removed
- Docker detection fields (`in_docker`, `docker_container_id`) from `get_mcp_server_environment()` tool
### Robust MCP Bridge Workbench
### Cut Object for Magnets Macro
@@ -30,7 +34,7 @@ This is a multi-component project. Each component has its own versioning and rel
## Initial Public Beta - 2026-01-05
### MCP Server v0.5.0-beta
### Robust MCP Server v0.5.0-beta
Initial public beta release.
@@ -59,14 +63,14 @@ Initial public beta release.
---
### MCP Bridge Workbench v0.5.0-beta
### Robust MCP Bridge Workbench v0.5.0-beta
Initial public beta release.
#### Added
- **FreeCAD Workbench**: Installable via FreeCAD Addon Manager
- **XML-RPC server**: Runs on port 9875 for MCP server communication
- **XML-RPC server**: Runs on port 9875 for Robust MCP Server communication
- **Socket server**: Alternative JSON-RPC on port 9876
- **Auto-start option**: Configure bridge to start with FreeCAD
- **GUI mode support**: Full 3D view integration with screenshots
+146 -25
View File
@@ -39,7 +39,7 @@ FreeCAD's `FreeCAD.so` library links to `@rpath/libpython3.11.dylib` (FreeCAD's
1. Start FreeCAD and start the MCP bridge:
- Install the MCP Bridge workbench via Addon Manager, or
- Install the Robust MCP Bridge workbench via Addon Manager, or
- Use `just freecad::run-gui` from the source repository
1. The MCP server will then connect to FreeCAD over the network
@@ -197,7 +197,7 @@ just testing::unit # Run unit tests
just testing::cov # Run tests with coverage
just testing::fast # Run tests without slow markers
just testing::integration # Run integration tests
just testing::integration-freecad # Integration tests with auto FreeCAD startup
just testing::integration-freecad-auto # Integration tests with auto FreeCAD startup
just testing::watch # Run tests in watch mode
just testing::all # Run all tests including integration
@@ -228,7 +228,7 @@ just coderabbit::review-fix # Review with auto-fix suggestions
# Release commands (component-specific tagging)
just release::status # Show unreleased changes across all components
just release::tag-mcp-server 1.0.0 # Release MCP server (PyPI + Docker)
just release::tag-workbench 1.0.0 # Release MCP Bridge workbench
just release::tag-workbench 1.0.0 # Release Robust MCP Bridge workbench
just release::tag-macro-magnets 1.0.0 # Release Cut Object for Magnets macro
just release::tag-macro-export 1.0.0 # Release Multi Export macro
just release::list-tags # List all release tags
@@ -237,9 +237,8 @@ just release::delete-tag <tag> # Delete a release tag (local and remote)
# Combined workflows
just setup # Full dev setup (install deps + hooks)
just all # Run all quality checks and unit tests
just all-with-integration # Run all checks + integration tests
just ci # Full CI pipeline (checks + coverage)
just all # Run all quality checks and unit/coverage tests
just all-with-integration # Run all checks and integration tests
```
#### Just Module Structure
@@ -250,7 +249,7 @@ just ci # Full CI pipeline (checks + coverage)
| `freecad` | FreeCAD running commands | `run-gui`, `run-headless`, `run-gui-custom` |
| `install` | User installation commands | `mcp-server`, `mcp-bridge-workbench`, `macro-all` |
| `quality` | Code quality and linting | `check`, `lint`, `format`, `scan` |
| `testing` | Test execution | `unit`, `cov`, `integration-freecad`, `watch` |
| `testing` | Test execution | `unit`, `cov`, `integration-freecad-auto`, `watch` |
| `docker` | Docker build and run commands | `build`, `build-multi`, `run`, `clean-all` |
| `documentation` | Documentation building | `build`, `serve`, `open` |
| `dev` | Development utilities | `install-deps`, `update-deps`, `clean` |
@@ -320,6 +319,32 @@ Pre-commit runs these checks:
- Maximum line length: 88 characters (ruff/black default)
- Use modern Python syntax (3.10+ features encouraged)
### Accessible Language
> This isn't about politics—it's about clarity. Literal terms translate better, search better, and are understood by more people regardless of cultural background. Good communication is good engineering.
Use clear, literal language in code, comments, documentation, and commit messages. Avoid idioms, metaphors, and jargon that may be unclear, exclusionary, or carry unintended connotations:
| Avoid | Prefer |
| ------------------------------------------ | ------------------------------------------------ |
| sanity check, sanity test | validation, verification, smoke test, quick test |
| sane defaults, insane behavior | sensible defaults, unexpected behavior |
| whitelist, blacklist | allowlist, blocklist |
| master, slave | main, primary, replica, secondary |
| kill, abort, nuke (as metaphors) | stop, terminate, cancel, remove |
| war room, battle-tested | operations center, production-tested |
| cripple, blind to | disable, unaware of, ignore |
| dummy, handicapped | placeholder, stub, limited |
**Note:** Actual command names (e.g., `kill -9`, `kill_port()`, `git rebase --abort`) are fine when discussing or documenting those specific commands. The guidance above applies to metaphorical usage in prose, comments, and naming.
**Why this matters:**
- Literal terms are clearer to non-native speakers and those unfamiliar with idioms
- Avoids unintentionally alienating contributors
- Makes code more accessible and professional
- Many organizations and open-source projects have adopted similar guidelines
### Security Scanning
- Bandit scans for common security issues
@@ -491,6 +516,10 @@ tests/
├── integration/ # Integration tests
│ ├── __init__.py
│ └── test_*.py
├── just_commands/ # Just command tests
│ ├── __init__.py
│ ├── conftest.py # Just test fixtures
│ └── test_*.py # Tests for each just module
└── fixtures/ # Test data files
```
@@ -534,6 +563,69 @@ uv run pytest tests/unit/ # Run specific test directory
uv run pytest -k "test_name" # Run specific test by name
```
### Just Command Testing
The project includes a comprehensive test suite for all `just` commands in `tests/just_commands/`. This ensures that justfile syntax errors, missing dependencies, and runtime failures are caught early.
**Test Categories:**
| Marker | Description | Command |
| ---------------- | ---------------------------------------------------- | --------------------------------- |
| `just_syntax` | Validates just can parse commands (--dry-run) | `just testing::just-syntax` |
| `just_runtime` | Actually executes commands and verifies behavior | `just testing::just-runtime` |
| `just_release` | Release command tests with cleanup | `just testing::just-release` |
| (all) | Run all just command tests | `just testing::just-all` |
**Running Just Command Tests:**
```bash
just testing::just-syntax # Fast syntax validation (recommended before commits)
just testing::just-runtime # Runtime tests (slower, actually runs commands)
just testing::just-release # Release command tests with cleanup
just testing::just-all # All just command tests
```
#### Updating Tests When Changing Just Commands
**MANDATORY**: When you add, modify, or remove a just command, you MUST update the corresponding test file:
| Module | Test File |
| -------------- | ------------------------------------------- |
| Main justfile | `tests/just_commands/test_main.py` |
| coderabbit | `tests/just_commands/test_coderabbit.py` |
| dev | `tests/just_commands/test_dev.py` |
| docker | `tests/just_commands/test_docker.py` |
| documentation | `tests/just_commands/test_documentation.py` |
| freecad | `tests/just_commands/test_freecad.py` |
| install | `tests/just_commands/test_install.py` |
| mcp | `tests/just_commands/test_mcp.py` |
| quality | `tests/just_commands/test_quality.py` |
| release | `tests/just_commands/test_release.py` |
| testing | `tests/just_commands/test_testing.py` |
**What to Update:**
1. **New command**: Add to `COMMANDS` list in syntax tests, add runtime test if applicable
2. **Modified command**: Update any tests that depend on command behavior/output
3. **Removed command**: Remove from `COMMANDS` list and delete related tests
4. **Changed arguments**: Update parametrized tests with correct arguments
**Release Command Testing Strategy:**
Release commands are tested carefully to avoid accidental releases:
- **Syntax tests**: Use `--dry-run` for all commands
- **Read-only tests**: Safe commands like `status`, `list-tags`, `latest-versions`
- **Version bump tests**: Test bump commands (modify local files, restored after test)
- **Tag validation**: Test version format validation, dirty tree detection
- **Skip push tests**: Commands that push to remote are only syntax-tested
For actual release testing with cleanup, tests use:
- Test versions like `99.99.99-test` that are clearly non-production
- Backup and restore of modified files
- Tag prefixes like `test-release-XXXXXX` with random suffixes
---
## Workflow for Code Changes
@@ -625,7 +717,7 @@ project-root/
│ │ └── test.yaml # Unit/integration tests
│ └── dependabot.yaml # Dependency updates
├── addon/ # FreeCAD addon (workbench)
│ └── FreecadRobustMCP/ # MCP Bridge workbench
│ └── FreecadRobustMCP/ # Robust MCP Bridge workbench
│ ├── freecad_mcp_bridge/ # Bridge Python package
│ ├── Init.py # FreeCAD workbench init
│ ├── InitGui.py # FreeCAD GUI init
@@ -720,8 +812,8 @@ This section describes the purpose and key settings in each configuration file.
### Tool Management
| File | Purpose |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `.mise.toml` | Pins versions for Python, uv, just, pre-commit, and security tools (trivy, gitleaks, hadolint, shellcheck, actionlint, markdownlint-cli2). Also sets environment variables for FreeCAD connection settings. |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.mise.toml` | Pins versions for Python, uv, just, pre-commit, and security tools (trivy, gitleaks, actionlint, markdownlint-cli2). Also sets environment variables for FreeCAD connection settings. |
| `pyproject.toml` | Python project configuration: dependencies, build system, tool configs (ruff, mypy, pytest, bandit, codespell, commitizen). |
| `uv.lock` | Exact locked versions of all Python dependencies for reproducible builds. |
@@ -744,20 +836,20 @@ This section describes the purpose and key settings in each configuration file.
### Documentation
| File | Purpose |
| --------------------- | ------------------------------------------------------------------------------------------------------------ |
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
| `mkdocs.yaml` | MkDocs configuration: Material theme, plugins (macros, mkdocstrings, git-revision-date), navigation structure. |
| `docs/variables.yaml` | Variables for MkDocs macros plugin (project name, ports, paths). Use `{{@ variable @}}` syntax in docs. |
### FreeCAD Addon
| File | Purpose |
| ------------- | ------------------------------------------------------------------------------------------ |
| ------------- | ------------------------------------------------------------------------------------------------- |
| `package.xml` | FreeCAD addon metadata with per-component versioning. Updated automatically by release workflows. |
### GitHub
| File | Purpose |
| ---------------------------- | -------------------------------------------------------------------------- |
| -------------------------- | ------------------------------------------------------------------------------- |
| `.github/dependabot.yaml` | Dependabot configuration for automated dependency updates. |
| `.github/workflows/*.yaml` | CI/CD workflows. See [GitHub Workflows](#github-actions-workflows) for details. |
@@ -1009,7 +1101,7 @@ just freecad::run-headless
**CRITICAL**: Code running inside FreeCAD's Python environment cannot import packages that aren't available in FreeCAD's bundled Python (like `mcp`, `pydantic`, etc.).
The `headless_server.py` script in the workbench addon imports the plugin directly from the module file to avoid triggering the `mcp` import:
The `blocking_bridge.py` script in the workbench addon imports the plugin directly from the module file to avoid triggering the `mcp` import:
```python
# CORRECT - import directly from the module file in the same directory
@@ -1084,6 +1176,33 @@ This project uses relaxed mypy settings because FastMCP lacks proper type stubs.
- **Horizontal rules**: Must use `---` format (3 dashes)
### Markdownlint (MD060) - Table Formatting
- **Table column style**: All Markdown tables must use the "padded/aligned" style
- Every row in a table must have the same total character width
- Column separators (`|`) must align vertically across all rows
- The separator row dashes must match the column width set by the widest content
**Example - Correct (aligned):**
```markdown
| File | Purpose |
| ---------------- | -------------------------------------------- |
| `.mise.toml` | Tool version management configuration. |
| `pyproject.toml` | Python project configuration and deps. |
```
**Example - Incorrect (misaligned):**
```markdown
| File | Purpose |
|------|---------|
| `.mise.toml` | Tool version management configuration. |
| `pyproject.toml` | Python project configuration and deps. |
```
When editing tables, ensure all columns align by adding padding spaces before the closing `|`.
### Bandit Security
These checks are intentionally skipped in `pyproject.toml`:
@@ -1128,7 +1247,7 @@ This project uses component-specific release workflows along with CI/CD pipeline
### Release Workflows
| Workflow | Trigger | Purpose |
| --------------------------------- | ------------------------------------ | -------------------------------------------------------- |
| --------------------------------- | ---------------------------------------- | ------------------------------------------------------ |
| `mcp-server-release.yaml` | Tag: `robust-mcp-server-v*` | Builds and publishes MCP server to PyPI and Docker Hub |
| `mcp-workbench-release.yaml` | Tag: `robust-mcp-workbench-v*` | Creates GitHub Release with workbench addon archive |
| `macro-cut-magnets-release.yaml` | Tag: `macro-cut-object-for-magnets-v*` | Creates GitHub Release with macro archive |
@@ -1142,7 +1261,7 @@ This project uses component-specific release workflows along with CI/CD pipeline
- Validates SemVer tag format
- Builds Python wheel and sdist
- Tests installation on Ubuntu and macOS
- Publishes to PyPI (stable) or TestPyPI (alpha)
- Publishes to PyPI (stable) or TestPyPI (alpha, beta, rc)
- Builds multi-arch Docker image (amd64 + arm64)
- Pushes to Docker Hub with version tags
- Creates GitHub Release with artifacts and changelog
@@ -1163,12 +1282,14 @@ This project uses component-specific release workflows along with CI/CD pipeline
This project uses **component-specific versioning**. Each component has its own git tag and release workflow:
| Component | Tag Format | Releases To |
| --------------------------- | ------------------------------------- | ------------------------------------ |
| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI, Docker Hub, GitHub Release |
| MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) |
| ---------------------------- | ------------------------------------- | ------------------------------------------ |
| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI/TestPyPI*, Docker Hub, GitHub Release |
| Robust MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) |
| Cut Object for Magnets Macro | `macro-cut-object-for-magnets-vX.Y.Z` | GitHub Release (archive) |
| Multi Export Macro | `macro-multi-export-vX.Y.Z` | GitHub Release (archive) |
*Stable releases (`X.Y.Z`) publish to PyPI; non-stable releases (alpha, beta, rc) publish to TestPyPI only.
### Changelog Management
The project uses a single `CHANGELOG.md` with sections for each component. Before releasing:
@@ -1194,7 +1315,7 @@ The project uses a single `CHANGELOG.md` with sections for each component. Befor
---
### MCP Bridge Workbench vX.Y.Z
### Robust MCP Bridge Workbench vX.Y.Z
...
```
@@ -1215,7 +1336,7 @@ just release::changes-since workbench
# Release the MCP server (triggers PyPI, Docker, GitHub release)
just release::tag-mcp-server 1.0.0
# Release the MCP Bridge workbench
# Release the Robust MCP Bridge workbench
just release::tag-workbench 1.0.0
# Release macros
@@ -1231,10 +1352,10 @@ just release::latest-versions
All versions follow SemVer 2.0:
- `X.Y.Z` - Stable release
- `X.Y.Z` - Stable release (PyPI only)
- `X.Y.Z-alpha` or `X.Y.Z-alpha.N` - Alpha (TestPyPI only)
- `X.Y.Z-beta` or `X.Y.Z-beta.N` - Beta (PyPI)
- `X.Y.Z-rc.N` - Release candidate (PyPI)
- `X.Y.Z-beta` or `X.Y.Z-beta.N` - Beta (TestPyPI only)
- `X.Y.Z-rc.N` - Release candidate (TestPyPI only)
### What Happens on Release
@@ -1243,7 +1364,7 @@ All versions follow SemVer 2.0:
1. Validates tag format
2. Builds Python wheel and sdist
3. Tests installation on Ubuntu and macOS
4. Publishes to PyPI (or TestPyPI for alpha)
4. Publishes to PyPI (or TestPyPI for alpha, beta, rc)
5. Builds multi-arch Docker image
6. Pushes to Docker Hub
7. Creates GitHub release with artifacts
+3 -3
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
# FreeCAD MCP Server Dockerfile
# FreeCAD Robust MCP Server Dockerfile
# Multi-stage build with BuildKit optimizations for multi-arch support
#
# Uses Alpine Linux for minimal image size and reduced CVE surface.
@@ -58,8 +58,8 @@ FROM python:3.11-alpine AS runtime
# Labels for container metadata (OCI Image Spec)
# Note: version, revision, and created are set dynamically in CI/CD workflows
LABEL org.opencontainers.image.title="FreeCAD MCP Server" \
org.opencontainers.image.description="MCP (Model Context Protocol) server for FreeCAD integration with AI assistants" \
LABEL org.opencontainers.image.title="FreeCAD Robust MCP Server" \
org.opencontainers.image.description="Robust MCP Server for FreeCAD integration with AI assistants" \
org.opencontainers.image.url="https://github.com/spkane/freecad-robust-mcp-and-more" \
org.opencontainers.image.source="https://github.com/spkane/freecad-robust-mcp-and-more" \
org.opencontainers.image.documentation="https://github.com/spkane/freecad-robust-mcp-and-more#readme" \
+24 -24
View File
@@ -1,4 +1,4 @@
# FreeCAD Tools and MCP Server
# FreeCAD Tools and Robust MCP Server
[![CI Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml)
[![Integration Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml)
@@ -19,13 +19,13 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
<!--TOC-->
- [FreeCAD Tools and MCP Server](#freecad-tools-and-mcp-server)
- [FreeCAD Tools and Robust MCP Server](#freecad-tools-and-robust-mcp-server)
- [Table of Contents](#table-of-contents)
- [Features](#features)
- [Requirements](#requirements)
- [For Users](#for-users)
- [Quick Links](#quick-links)
- [MCP Server](#mcp-server)
- [Robust MCP Server](#robust-mcp-server)
- [Installation](#installation)
- [Using pip (recommended)](#using-pip-recommended)
- [Using mise and just (from source)](#using-mise-and-just-from-source)
@@ -63,7 +63,7 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
- [CutObjectForMagnets](#cutobjectformagnets)
- [MultiExport](#multiexport)
- [For Developers](#for-developers)
- [MCP Server Development](#mcp-server-development)
- [Robust MCP Server Development](#robust-mcp-server-development)
- [Prerequisites](#prerequisites)
- [Initial Setup](#initial-setup)
- [MCP Client Configuration (Development)](#mcp-client-configuration-development)
@@ -89,7 +89,7 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
- **Multiple Connection Modes**: XML-RPC (recommended), JSON-RPC socket, or embedded
- **GUI & Headless Support**: Full modeling in headless mode, plus screenshots/colors in GUI mode
- **Macro Development**: Create, edit, run, and template FreeCAD macros via MCP
- **Standalone Macros**: Useful FreeCAD macros that work independently of the MCP server
- **Standalone Macros**: Useful FreeCAD macros that work independently of the Robust MCP Server
## Requirements
@@ -100,7 +100,7 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
## For Users
This section covers installation and usage for end users who want to use the MCP server with AI assistants or the standalone FreeCAD macros.
This section covers installation and usage for end users who want to use the Robust MCP Server with AI assistants or the standalone FreeCAD macros.
### Quick Links
@@ -110,9 +110,9 @@ This section covers installation and usage for end users who want to use the MCP
| [PyPI](https://pypi.org/project/freecad-robust-mcp/) | Python package for pip installation |
| [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases) | Release archives, changelogs, and standalone macro downloads |
## MCP Server
## Robust MCP Server
> **Note**: Since this repository has more than just the MCP server in it, the Linux container and PyPi projects releases are both simply named `freecad-robust-mcp` which differs from the name of this git repository.
> **Note**: Since this repository has more than just the Robust MCP Server in it, the Linux container and PyPi projects releases are both simply named `freecad-robust-mcp` which differs from the name of this git repository.
### Installation
@@ -138,7 +138,7 @@ just setup
#### Using Docker
Run the MCP server in a container. This is useful for isolated environments or when you don't want to install Python dependencies on your host.
Run the Robust MCP Server in a container. This is useful for isolated environments or when you don't want to install Python dependencies on your host.
```bash
# Pull from Docker Hub (when published)
@@ -154,7 +154,7 @@ just docker::build # Build for local architecture
just docker::build-multi # Build multi-arch (amd64 + arm64)
```
**Note:** The containerized MCP server only supports `xmlrpc` and `socket` modes since FreeCAD runs on your host machine (not in the container). The container connects to FreeCAD via `host.docker.internal`.
**Note:** The containerized Robust MCP Server only supports `xmlrpc` and `socket` modes since FreeCAD runs on your host machine (not in the container). The container connects to FreeCAD via `host.docker.internal`.
### Configuration
@@ -224,7 +224,7 @@ If using Docker:
"--add-host=host.docker.internal:host-gateway",
"-e", "FREECAD_MODE=xmlrpc",
"-e", "FREECAD_SOCKET_HOST=host.docker.internal",
"freecad-mcp"
"spkane/freecad-robust-mcp"
]
}
}
@@ -236,7 +236,7 @@ If using Docker:
- `--rm` removes the container after it exits
- `-i` keeps stdin open for MCP communication
- `--add-host=host.docker.internal:host-gateway` allows the container to connect to FreeCAD on your host (Linux only; macOS/Windows have this built-in)
- `FREECAD_SOCKET_HOST=host.docker.internal` tells the MCP server to connect to FreeCAD on your host machine
- `FREECAD_SOCKET_HOST=host.docker.internal` tells the Robust MCP Server to connect to FreeCAD on your host machine
### Usage
@@ -246,15 +246,15 @@ Before your AI assistant can connect, you need to start the MCP bridge inside Fr
##### Option A: Using the Workbench (Recommended)
1. Install the MCP Bridge workbench via FreeCAD's Addon Manager:
1. Install the Robust MCP Bridge workbench via FreeCAD's Addon Manager:
- **Edit -> Preferences -> Addon Manager**
- Search for "MCP Bridge"
- Search for "Robust MCP Bridge"
- Install and restart FreeCAD
1. Start the bridge:
- Switch to the MCP Bridge workbench
- Switch to the Robust MCP Bridge workbench
- Click the **Start MCP Bridge** button in the toolbar
- Or use the menu: **MCP Bridge -> Start Bridge**
@@ -280,11 +280,11 @@ After starting the bridge, start/restart your MCP client (Claude Code, etc.) - i
#### Uninstalling the MCP Bridge
To uninstall the MCP Bridge workbench:
To uninstall the Robust MCP Bridge workbench:
1. Open FreeCAD
1. Go to **Edit -> Preferences -> Addon Manager**
1. Find "MCP Bridge" in the list
1. Find "Robust MCP Bridge" in the list
1. Click **Uninstall**
1. Restart FreeCAD
@@ -349,17 +349,17 @@ FREECAD_MODE=embedded freecad-mcp
### Available Tools
The MCP server provides **83 tools** organized into categories. Tools marked with **GUI** require FreeCAD to be running in GUI mode; they will return an error in headless mode.
The Robust MCP Server provides **83 tools** organized into categories. Tools marked with **GUI** require FreeCAD to be running in GUI mode; they will return an error in headless mode.
#### Execution & Debugging (5 tools)
| Tool | Description | Mode |
| ---------------------------- | ------------------------------------------------------ | ---- |
| ---------------------------- | ------------------------------------------------------------- | ---- |
| `execute_python` | Execute arbitrary Python code in FreeCAD's context | All |
| `get_freecad_version` | Get FreeCAD version, build date, and Python version | All |
| `get_connection_status` | Check MCP bridge connection status and latency | All |
| `get_console_output` | Get recent FreeCAD console output (up to N lines) | All |
| `get_mcp_server_environment` | Get MCP server environment (OS, hostname, Docker info) | All |
| `get_mcp_server_environment` | Get Robust MCP Server environment (OS, hostname, instance_id) | All |
#### Document Management (7 tools)
@@ -491,7 +491,7 @@ The MCP server provides **83 tools** organized into categories. Tools marked wit
## FreeCAD Macros
This project includes standalone FreeCAD macros that can be used independently of the MCP server. These are useful for FreeCAD users who want the macros without setting up the full MCP integration.
This project includes standalone FreeCAD macros that can be used independently of the Robust MCP Server. These are useful for FreeCAD users who want the macros without setting up the full MCP integration.
### Downloading Macros
@@ -619,7 +619,7 @@ just freecad::uninstall-export-macro
This section covers development setup, contributing, and working with the codebase.
## MCP Server Development
## Robust MCP Server Development
### Prerequisites
@@ -696,7 +696,7 @@ Commands are organized into modules. Use `just` to see top-level commands, or `j
just
# Show commands in a specific module
just list-mcp # MCP server commands
just list-mcp # Robust MCP Server commands
just list-freecad # FreeCAD plugin/macro commands
just list-install # Installation commands
just list-quality # Code quality commands
@@ -725,7 +725,7 @@ just testing::unit # Run unit tests
just testing::cov # Run tests with coverage
just testing::integration # Run integration tests
# Run the MCP server (or with debug logging)
# Run the Robust MCP Server (or with debug logging)
just mcp::run
just mcp::run-debug
+111 -4
View File
@@ -1,12 +1,119 @@
"""FreeCAD Robust MCP Workbench - Initialization.
"""Robust MCP Bridge Workbench - Initialization.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module is executed when FreeCAD starts up. It handles non-GUI
initialization tasks for the MCP Bridge workbench.
This module is executed when FreeCAD starts up. It handles initialization
tasks for the Robust MCP Bridge workbench, including auto-start of the
MCP bridge if configured. Works in both GUI and headless modes.
Note: Status bar updates are handled by InitGui.py since Qt operations
must run on the main thread.
"""
import FreeCAD
FreeCAD.Console.PrintMessage("FreeCAD Robust MCP: Init loaded\n")
FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init loaded\n")
# Global reference to timer to prevent garbage collection
_auto_start_timer = None
def _auto_start_bridge() -> None:
"""Auto-start the MCP bridge if configured in preferences.
This function is called via a deferred timer (GUI mode) or directly
(headless mode) after FreeCAD finishes loading. It starts the bridge
without requiring the workbench to be selected.
"""
try:
from preferences import get_auto_start
if not get_auto_start():
return
# Check if bridge is already running
from commands import _mcp_plugin
if _mcp_plugin is not None and _mcp_plugin.is_running:
return
FreeCAD.Console.PrintMessage(
"Auto-starting MCP Bridge (configured in preferences)...\n"
)
# Import and start the bridge directly
import commands
from freecad_mcp_bridge.server import FreecadMCPPlugin
from preferences import get_socket_port, get_xmlrpc_port
xmlrpc_port = get_xmlrpc_port()
socket_port = get_socket_port()
commands._mcp_plugin = FreecadMCPPlugin(
host="localhost",
port=socket_port,
xmlrpc_port=xmlrpc_port,
enable_xmlrpc=True,
)
commands._mcp_plugin.start()
# Track running configuration for restart detection
commands._running_config = {
"xmlrpc_port": xmlrpc_port,
"socket_port": socket_port,
}
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(f" - XML-RPC: localhost:{xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" - Socket: localhost:{socket_port}\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage(
"\nYou can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to auto-start MCP Bridge: {e}\n")
# Schedule auto-start after FreeCAD finishes loading
# Strategy:
# - If FreeCAD.GuiUp is True: Qt event loop is running, use timer for deferred start
# - If FreeCAD.GuiUp is False but Qt is available: Start bridge directly
# (GUI mode starting up - InitGui.py will handle status bar later)
# - If Qt is not available: Pure headless mode, start bridge directly
try:
from preferences import get_auto_start
if get_auto_start():
# Try to import Qt
import contextlib
QtCore = None
try:
from PySide2 import QtCore # type: ignore[assignment, no-redef]
except ImportError:
with contextlib.suppress(ImportError):
from PySide6 import QtCore # type: ignore[assignment, no-redef]
if FreeCAD.GuiUp:
# GUI is already up - use timer for deferred start
if QtCore is not None:
_auto_start_timer = QtCore.QTimer()
_auto_start_timer.setSingleShot(True)
_auto_start_timer.timeout.connect(_auto_start_bridge)
_auto_start_timer.start(1000)
else:
# GUI is up but Qt import failed - start directly
_auto_start_bridge()
elif QtCore is not None:
# GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode)
# Start bridge directly - InitGui.py will handle status bar update
_auto_start_bridge()
else:
# True headless mode - no Qt, no GUI
_auto_start_bridge()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not set up auto-start: {e}\n")
+122 -170
View File
@@ -1,180 +1,40 @@
"""FreeCAD Robust MCP Workbench - GUI Initialization.
"""Robust MCP Bridge Workbench - GUI Initialization.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module defines the workbench class and GUI commands for the
MCP Bridge. It provides toolbar buttons and menu items to start
and stop the MCP bridge server.
This module defines the workbench class for the Robust MCP Bridge.
It provides toolbar buttons and menu items to start and stop the
MCP bridge server. Commands are defined in the commands module.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import FreeCAD
import FreeCADGui
# Global reference to the plugin instance
_mcp_plugin: Any = None
def get_addon_path() -> str:
"""Get the path to this addon's directory."""
return str(Path(__file__).resolve().parent)
def get_icon_path(icon_name: str) -> str:
"""Get the full path to an icon file.
Args:
icon_name: Name of the icon file (e.g., "FreecadRobustMCP.svg")
Returns:
Full path to the icon file.
"""
return str(Path(get_addon_path()) / icon_name)
class StartMCPBridgeCommand:
"""Command to start the MCP bridge server."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("FreecadRobustMCP.svg"),
"MenuText": "Start MCP Bridge",
"ToolTip": (
"Start the MCP bridge server for AI assistant integration.\n"
"Listens on XML-RPC (port 9875) and Socket (port 9876)."
),
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
global _mcp_plugin # noqa: PLW0602
# Can only start if not already running
return _mcp_plugin is None or not _mcp_plugin.is_running
def Activated(self) -> None:
"""Execute the command to start the MCP bridge."""
global _mcp_plugin
if _mcp_plugin is not None and _mcp_plugin.is_running:
FreeCAD.Console.PrintWarning("MCP Bridge is already running.\n")
return
# Register icons path for preferences page icon
# This must be done at module level, before the preferences page is registered
try:
# Import the server module from the bundled code
from freecad_mcp_bridge.server import FreecadMCPPlugin
from path_utils import get_icons_dir
# Create and start the plugin
_mcp_plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port
enable_xmlrpc=True,
)
_mcp_plugin.start()
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage(
"\nYou can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
except ImportError as e:
FreeCAD.Console.PrintError(f"Failed to import MCP Bridge module: {e}\n")
FreeCAD.Console.PrintError(
"Ensure the FreecadRobustMCP addon is properly installed.\n"
)
_icons_dir = get_icons_dir()
if _icons_dir:
FreeCADGui.addIconPath(_icons_dir)
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
class StopMCPBridgeCommand:
"""Command to stop the MCP bridge server."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("FreecadRobustMCP.svg"),
"MenuText": "Stop MCP Bridge",
"ToolTip": "Stop the running MCP bridge server.",
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
global _mcp_plugin # noqa: PLW0602
# Can only stop if currently running
return _mcp_plugin is not None and _mcp_plugin.is_running
def Activated(self) -> None:
"""Execute the command to stop the MCP bridge."""
global _mcp_plugin
if _mcp_plugin is None or not _mcp_plugin.is_running:
FreeCAD.Console.PrintWarning("MCP Bridge is not running.\n")
return
FreeCAD.Console.PrintWarning(f"Could not register icon path: {e}\n")
# Register preferences page with FreeCAD's Preferences dialog
# This must be done at module level, before the workbench is registered
try:
_mcp_plugin.stop()
_mcp_plugin = None
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge stopped.\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
from preferences_page import MCPBridgePreferencesPage
FreeCADGui.addPreferencePage(MCPBridgePreferencesPage, "Robust MCP Bridge")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to stop MCP Bridge: {e}\n")
class MCPBridgeStatusCommand:
"""Command to show MCP bridge status."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("FreecadRobustMCP.svg"),
"MenuText": "MCP Bridge Status",
"ToolTip": "Show the current status of the MCP bridge server.",
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
# Always active - can always show status
return True
def Activated(self) -> None:
"""Execute the command to show MCP bridge status."""
global _mcp_plugin # noqa: PLW0602
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge Status\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
if _mcp_plugin is None:
FreeCAD.Console.PrintMessage("Status: Not initialized\n")
elif not _mcp_plugin.is_running:
FreeCAD.Console.PrintMessage("Status: Stopped\n")
else:
FreeCAD.Console.PrintMessage("Status: Running\n")
FreeCAD.Console.PrintMessage(f" Instance ID: {_mcp_plugin.instance_id}\n")
FreeCAD.Console.PrintMessage(f" XML-RPC Port: {_mcp_plugin.xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" Socket Port: {_mcp_plugin.socket_port}\n")
FreeCAD.Console.PrintMessage(
f" Requests processed: {_mcp_plugin.request_count}\n"
FreeCAD.Console.PrintWarning(
f"Could not register MCP Bridge preferences page: {e}\n"
)
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
class FreecadRobustMCPWorkbench(FreeCADGui.Workbench):
"""FreeCAD Robust MCP Workbench.
@@ -183,36 +43,93 @@ class FreecadRobustMCPWorkbench(FreeCADGui.Workbench):
the MCP bridge server for AI assistant integration.
"""
MenuText = "MCP Bridge"
ToolTip = "MCP Bridge for AI assistant integration with FreeCAD"
Icon = get_icon_path("FreecadRobustMCP.svg")
MenuText = "Robust MCP Bridge"
ToolTip = "Robust MCP Bridge for AI assistant integration with FreeCAD"
def __init__(self) -> None:
"""Initialize workbench with icon path."""
from path_utils import get_workbench_icon
self.Icon = get_workbench_icon()
def Initialize(self) -> None:
"""Initialize the workbench - called once when first activated."""
# Import commands module here (not at top level) to ensure
# it's available during FreeCAD's module loading process
from commands import (
MCPBridgePreferencesCommand,
MCPBridgeStatusCommand,
StartMCPBridgeCommand,
StopMCPBridgeCommand,
)
# Register commands
FreeCADGui.addCommand("Start_MCP_Bridge", StartMCPBridgeCommand())
FreeCADGui.addCommand("Stop_MCP_Bridge", StopMCPBridgeCommand())
FreeCADGui.addCommand("MCP_Bridge_Status", MCPBridgeStatusCommand())
FreeCADGui.addCommand("MCP_Bridge_Preferences", MCPBridgePreferencesCommand())
# Create toolbar and menu
commands = ["Start_MCP_Bridge", "Stop_MCP_Bridge", "MCP_Bridge_Status"]
self.appendToolbar("MCP Bridge", commands)
self.appendMenu("MCP Bridge", commands)
# Create toolbar with main commands
toolbar_commands = [
"Start_MCP_Bridge",
"Stop_MCP_Bridge",
"MCP_Bridge_Status",
]
self.appendToolbar("Robust MCP Bridge", toolbar_commands)
FreeCAD.Console.PrintMessage("FreeCAD Robust MCP workbench initialized\n")
# Create menu with all commands including preferences
menu_commands = [
"Start_MCP_Bridge",
"Stop_MCP_Bridge",
"MCP_Bridge_Status",
"Separator",
"MCP_Bridge_Preferences",
]
self.appendMenu("Robust MCP Bridge", menu_commands)
FreeCAD.Console.PrintMessage("Robust MCP Bridge workbench initialized\n")
# Auto-start bridge if preference is enabled
# This is a fallback if the module-level timer didn't fire
# (which can happen if the module isn't loaded until workbench selection)
try:
from preferences import get_auto_start
if get_auto_start():
# Check if already running (timer might have started it)
from commands import is_bridge_running
if not is_bridge_running():
FreeCAD.Console.PrintMessage(
"Auto-starting MCP Bridge (configured in preferences)...\n"
)
FreeCADGui.runCommand("Start_MCP_Bridge")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not auto-start MCP Bridge: {e}\n")
# Sync status bar widget with current bridge state
# (bridge may have been started by Init.py before workbench was selected)
try:
from status_widget import sync_status_with_bridge
sync_status_with_bridge()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not sync status bar: {e}\n")
def Activated(self) -> None:
"""Called when the workbench is activated."""
pass
# Sync status bar widget with current bridge state
try:
from status_widget import sync_status_with_bridge
sync_status_with_bridge()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not sync status bar: {e}\n")
def Deactivated(self) -> None:
"""Called when the workbench is deactivated."""
pass
def ContextMenu(self, recipient: Any) -> None:
"""Called when right-clicking in the view or object tree."""
pass
def GetClassName(self) -> str:
"""Return the C++ class name for this workbench."""
return "Gui::PythonWorkbench"
@@ -220,3 +137,38 @@ class FreecadRobustMCPWorkbench(FreeCADGui.Workbench):
# Register the workbench
FreeCADGui.addWorkbench(FreecadRobustMCPWorkbench())
# Schedule status bar sync after a short delay to allow GUI to finish initializing
# This runs on the main thread (InitGui.py is executed on main thread)
try:
try:
from PySide2 import QtCore
except ImportError:
from PySide6 import QtCore
def _deferred_status_bar_sync() -> None:
"""Sync status bar with bridge state after GUI is ready."""
try:
from commands import is_bridge_running
from preferences import get_status_bar_enabled
from status_widget import sync_status_with_bridge
if get_status_bar_enabled() and is_bridge_running():
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: Syncing status bar from InitGui...\n"
)
sync_status_with_bridge()
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Robust MCP Bridge: Deferred status bar sync failed: {e}\n"
)
# Use QTimer.singleShot on the main thread - this should work
QtCore.QTimer.singleShot(2000, _deferred_status_bar_sync)
FreeCAD.Console.PrintMessage(
"Robust MCP Bridge: Status bar sync scheduled from InitGui (2s)\n"
)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Robust MCP Bridge: Could not schedule status bar sync: {e}\n"
)
+465
View File
@@ -0,0 +1,465 @@
"""MCP Bridge commands for the FreeCAD workbench.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module defines the GUI commands for starting, stopping, and
checking the status of the MCP bridge server.
"""
from __future__ import annotations
from typing import Any
import FreeCAD
from path_utils import get_addon_path, get_icon_path
# FreeCADGui is imported lazily in methods that need it, as this module
# may be imported during headless operation where FreeCADGui is not available
# Re-export for any modules that might import from commands
__all__ = ["get_addon_path", "get_icon_path"]
# Global reference to the plugin instance
_mcp_plugin: Any = None
# Track current running configuration for restart detection
_running_config: dict[str, int] | None = None
def is_bridge_running() -> bool:
"""Check if the MCP bridge is currently running.
This is a public helper to encapsulate access to the private _mcp_plugin state.
Returns:
True if the bridge is running, False otherwise.
"""
return _mcp_plugin is not None and _mcp_plugin.is_running
class StartMCPBridgeCommand:
"""Command to start the MCP bridge server."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
# Get configured ports for tooltip (fall back to defaults if import fails)
try:
from preferences import get_socket_port, get_xmlrpc_port
xmlrpc_port = get_xmlrpc_port()
socket_port = get_socket_port()
except Exception:
xmlrpc_port = 9875
socket_port = 9876
return {
"Pixmap": get_icon_path("icons/mcp_start.svg"),
"MenuText": "Start MCP Bridge",
"ToolTip": (
"Start the MCP bridge server for AI assistant integration.\n"
f"Listens on XML-RPC (port {xmlrpc_port}) and Socket (port {socket_port})."
),
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
return _mcp_plugin is None or not _mcp_plugin.is_running
def Activated(self) -> None:
"""Execute the command to start the MCP bridge."""
global _mcp_plugin, _running_config
if _mcp_plugin is not None and _mcp_plugin.is_running:
FreeCAD.Console.PrintWarning("MCP Bridge is already running.\n")
return
try:
from freecad_mcp_bridge.server import FreecadMCPPlugin
from preferences import (
get_socket_port,
get_status_bar_enabled,
get_xmlrpc_port,
)
from status_widget import (
update_status_error,
update_status_running,
update_status_starting,
)
# Update status bar widget if enabled
if get_status_bar_enabled():
update_status_starting()
xmlrpc_port = get_xmlrpc_port()
socket_port = get_socket_port()
# Create plugin in a local variable first to avoid leaving
# a partially initialized instance in _mcp_plugin if start() fails
plugin = FreecadMCPPlugin(
host="localhost",
port=socket_port,
xmlrpc_port=xmlrpc_port,
enable_xmlrpc=True,
)
plugin.start()
# Only assign to globals after start() succeeds
_mcp_plugin = plugin
_running_config = {
"xmlrpc_port": xmlrpc_port,
"socket_port": socket_port,
}
# Update status bar widget
if get_status_bar_enabled():
update_status_running(
xmlrpc_port, socket_port, _mcp_plugin.request_count
)
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge started!\n")
FreeCAD.Console.PrintMessage(f" - XML-RPC: localhost:{xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" - Socket: localhost:{socket_port}\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage(
"\nYou can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n"
)
except ImportError as e:
# Clear any stale state to ensure clean retry
_mcp_plugin = None
_running_config = None
FreeCAD.Console.PrintError(f"Failed to import MCP Bridge module: {e}\n")
FreeCAD.Console.PrintError(
"Ensure the FreecadRobustMCP addon is properly installed.\n"
)
try:
from preferences import get_status_bar_enabled
from status_widget import update_status_error
if get_status_bar_enabled():
update_status_error(str(e))
except Exception:
pass
except Exception as e:
# Clear any stale state to ensure clean retry
_mcp_plugin = None
_running_config = None
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
try:
from preferences import get_status_bar_enabled
from status_widget import update_status_error
if get_status_bar_enabled():
update_status_error(str(e))
except Exception:
pass
class StopMCPBridgeCommand:
"""Command to stop the MCP bridge server."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("icons/mcp_stop.svg"),
"MenuText": "Stop MCP Bridge",
"ToolTip": "Stop the running MCP bridge server.",
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
return _mcp_plugin is not None and _mcp_plugin.is_running
def Activated(self) -> None:
"""Execute the command to stop the MCP bridge."""
global _mcp_plugin, _running_config
if _mcp_plugin is None or not _mcp_plugin.is_running:
FreeCAD.Console.PrintWarning("MCP Bridge is not running.\n")
return
try:
_mcp_plugin.stop()
_mcp_plugin = None
_running_config = None
# Update status bar widget
try:
from preferences import get_status_bar_enabled
from status_widget import update_status_stopped
if get_status_bar_enabled():
update_status_stopped()
except Exception:
pass
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge stopped.\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to stop MCP Bridge: {e}\n")
class MCPBridgeStatusCommand:
"""Command to show MCP bridge status."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("icons/mcp_status.svg"),
"MenuText": "MCP Bridge Status",
"ToolTip": "Show the current status of the MCP bridge server.",
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
return True
def Activated(self) -> None:
"""Execute the command to show MCP bridge status."""
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge Status\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
if _mcp_plugin is None:
FreeCAD.Console.PrintMessage("Status: Not initialized\n")
elif not _mcp_plugin.is_running:
FreeCAD.Console.PrintMessage("Status: Stopped\n")
else:
FreeCAD.Console.PrintMessage("Status: Running\n")
FreeCAD.Console.PrintMessage(f" Instance ID: {_mcp_plugin.instance_id}\n")
FreeCAD.Console.PrintMessage(f" XML-RPC Port: {_mcp_plugin.xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" Socket Port: {_mcp_plugin.socket_port}\n")
FreeCAD.Console.PrintMessage(
f" Requests processed: {_mcp_plugin.request_count}\n"
)
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
def restart_bridge_if_running() -> bool:
"""Restart the bridge if it's currently running.
Returns:
True if bridge was restarted, False if it wasn't running.
"""
global _mcp_plugin, _running_config
if _mcp_plugin is None or not _mcp_plugin.is_running:
return False
FreeCAD.Console.PrintMessage("Restarting MCP Bridge with new configuration...\n")
# Update status bar widget
try:
from preferences import get_status_bar_enabled
from status_widget import update_status_starting
if get_status_bar_enabled():
update_status_starting()
except Exception:
pass
# Stop the current bridge
try:
_mcp_plugin.stop()
_mcp_plugin = None
_running_config = None
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to stop MCP Bridge: {e}\n")
try:
from preferences import get_status_bar_enabled
from status_widget import update_status_error
if get_status_bar_enabled():
update_status_error(str(e))
except Exception:
pass
return False
# Start with new configuration
try:
from freecad_mcp_bridge.server import FreecadMCPPlugin
from preferences import get_socket_port, get_status_bar_enabled, get_xmlrpc_port
from status_widget import update_status_running
xmlrpc_port = get_xmlrpc_port()
socket_port = get_socket_port()
_mcp_plugin = FreecadMCPPlugin(
host="localhost",
port=socket_port,
xmlrpc_port=xmlrpc_port,
enable_xmlrpc=True,
)
_mcp_plugin.start()
_running_config = {
"xmlrpc_port": xmlrpc_port,
"socket_port": socket_port,
}
# Update status bar widget
if get_status_bar_enabled():
update_status_running(xmlrpc_port, socket_port, _mcp_plugin.request_count)
FreeCAD.Console.PrintMessage("MCP Bridge restarted successfully.\n")
FreeCAD.Console.PrintMessage(f" - XML-RPC: localhost:{xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" - Socket: localhost:{socket_port}\n")
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to restart MCP Bridge: {e}\n")
try:
from preferences import get_status_bar_enabled
from status_widget import update_status_error
if get_status_bar_enabled():
update_status_error(str(e))
except Exception:
pass
return False
class MCPBridgePreferencesCommand:
"""Command to open MCP bridge preferences dialog."""
def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip)."""
return {
"Pixmap": get_icon_path("icons/mcp_preferences.svg"),
"MenuText": "MCP Bridge Preferences...",
"ToolTip": "Configure MCP Bridge settings (ports, auto-start, etc.)",
}
def IsActive(self) -> bool:
"""Return True if the command can be executed."""
return True
def Activated(self) -> None:
"""Execute the command to show preferences dialog."""
# Import here to avoid issues during module loading
import FreeCADGui
from preferences import (
get_auto_start,
get_socket_port,
get_status_bar_enabled,
get_xmlrpc_port,
set_auto_start,
set_socket_port,
set_status_bar_enabled,
set_xmlrpc_port,
)
# Import QtWidgets with fallback for different PySide versions
try:
from PySide6 import QtWidgets
except ImportError:
try:
from PySide2 import QtWidgets
except ImportError:
from PySide import QtWidgets # type: ignore[import-not-found]
# Create the dialog
dialog = QtWidgets.QDialog(FreeCADGui.getMainWindow())
dialog.setWindowTitle("MCP Bridge Preferences")
dialog.setMinimumWidth(400)
layout = QtWidgets.QVBoxLayout(dialog)
# Startup group
startup_group = QtWidgets.QGroupBox("Startup")
startup_layout = QtWidgets.QVBoxLayout(startup_group)
auto_start_cb = QtWidgets.QCheckBox("Auto-start bridge when FreeCAD launches")
auto_start_cb.setChecked(get_auto_start())
startup_layout.addWidget(auto_start_cb)
layout.addWidget(startup_group)
# Display group
display_group = QtWidgets.QGroupBox("Display")
display_layout = QtWidgets.QVBoxLayout(display_group)
status_bar_cb = QtWidgets.QCheckBox("Show status indicator in status bar")
status_bar_cb.setChecked(get_status_bar_enabled())
display_layout.addWidget(status_bar_cb)
layout.addWidget(display_group)
# Ports group
ports_group = QtWidgets.QGroupBox("Network Ports")
ports_layout = QtWidgets.QFormLayout(ports_group)
xmlrpc_spin = QtWidgets.QSpinBox()
xmlrpc_spin.setRange(1024, 65535)
xmlrpc_spin.setValue(get_xmlrpc_port())
xmlrpc_spin.setToolTip("Port for XML-RPC connections (default: 9875)")
ports_layout.addRow("XML-RPC Port:", xmlrpc_spin)
socket_spin = QtWidgets.QSpinBox()
socket_spin.setRange(1024, 65535)
socket_spin.setValue(get_socket_port())
socket_spin.setToolTip("Port for JSON-RPC socket connections (default: 9876)")
ports_layout.addRow("Socket Port:", socket_spin)
# Warning label for ports
port_warning = QtWidgets.QLabel(
"<i>Note: If the bridge is running, changing ports will restart it.</i>"
)
port_warning.setWordWrap(True)
ports_layout.addRow(port_warning)
layout.addWidget(ports_group)
# Current status info
status_group = QtWidgets.QGroupBox("Current Status")
status_layout = QtWidgets.QVBoxLayout(status_group)
if _mcp_plugin is not None and _mcp_plugin.is_running:
status_label = QtWidgets.QLabel(
f"<b>Bridge is running</b><br>"
f"XML-RPC: localhost:{_mcp_plugin.xmlrpc_port}<br>"
f"Socket: localhost:{_mcp_plugin.socket_port}"
)
else:
status_label = QtWidgets.QLabel("<b>Bridge is not running</b>")
status_layout.addWidget(status_label)
layout.addWidget(status_group)
# Buttons
button_box = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
)
button_box.accepted.connect(dialog.accept)
button_box.rejected.connect(dialog.reject)
layout.addWidget(button_box)
# Show dialog (use exec() not exec_() which is deprecated in PySide6)
if dialog.exec() == QtWidgets.QDialog.Accepted:
# Save preferences
old_xmlrpc = get_xmlrpc_port()
old_socket = get_socket_port()
set_auto_start(auto_start_cb.isChecked())
set_status_bar_enabled(status_bar_cb.isChecked())
set_xmlrpc_port(xmlrpc_spin.value())
set_socket_port(socket_spin.value())
FreeCAD.Console.PrintMessage("MCP Bridge preferences saved.\n")
# Check if ports changed and bridge is running
new_xmlrpc = xmlrpc_spin.value()
new_socket = socket_spin.value()
ports_changed = old_xmlrpc != new_xmlrpc or old_socket != new_socket
bridge_running = _mcp_plugin is not None and _mcp_plugin.is_running
if ports_changed and bridge_running:
restart_bridge_if_running()
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
r"""Blocking FreeCAD MCP Bridge Server.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This script starts the MCP bridge server and blocks with run_forever().
It works with both FreeCAD GUI and FreeCADCmd (headless) modes.
Use this script when you need FreeCAD to keep running (CI, background servers).
For interactive GUI sessions, use startup_bridge.py instead (non-blocking).
Usage:
# Headless mode (no GUI features):
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
# GUI mode (full features including screenshots):
freecad ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
# On macOS:
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
Note: In headless mode (FreeCADCmd), GUI features like screenshots are not available.
For full functionality, run with FreeCAD GUI executable.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Check if we're running inside FreeCAD
try:
import FreeCAD
print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}")
except ImportError:
print("ERROR: This script must be run with FreeCAD or FreeCADCmd.")
print("")
print("Usage:")
print(
" FreeCADCmd /path/to/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py"
)
print("")
print("On macOS (if workbench installed):")
print(" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \\")
print(
" ~/Library/Application\\ Support/FreeCAD/Mod/FreecadRobustMCP/"
"freecad_mcp_bridge/blocking_bridge.py"
)
print("")
print("On Linux (if workbench installed):")
print(
" freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/"
"freecad_mcp_bridge/blocking_bridge.py"
)
sys.exit(1)
# Import the plugin server directly from the module file in the same directory
script_dir = str(Path(__file__).resolve().parent)
sys.path.insert(0, script_dir)
from bridge_utils import get_running_plugin # noqa: E402
from server import FreecadMCPPlugin # noqa: E402
# Check if bridge is already running (from auto-start in Init.py)
plugin = get_running_plugin()
if plugin is None:
# Get configuration from environment variables (with defaults)
try:
socket_port = int(os.environ.get("FREECAD_SOCKET_PORT", "9876"))
xmlrpc_port = int(os.environ.get("FREECAD_XMLRPC_PORT", "9875"))
except ValueError as e:
print(f"ERROR: Invalid port configuration: {e}")
print("FREECAD_SOCKET_PORT and FREECAD_XMLRPC_PORT must be integers.")
sys.exit(1)
# Create and run the plugin
plugin = FreecadMCPPlugin(
host="localhost",
port=socket_port, # JSON-RPC socket port
xmlrpc_port=xmlrpc_port, # XML-RPC port
enable_xmlrpc=True,
)
# Start the plugin
plugin.start()
# Print status messages with flush to ensure they appear immediately
# (FreeCAD's Python may have buffered stdout)
# Plugin is guaranteed non-None at this point (either from get_running_plugin or created above)
actual_xmlrpc_port = plugin.xmlrpc_port
actual_socket_port = plugin.socket_port
print("", flush=True)
print("=" * 60, flush=True)
gui_mode = "GUI" if FreeCAD.GuiUp else "headless"
print(f"MCP Bridge started in {gui_mode} mode!", flush=True)
print(f" - XML-RPC: localhost:{actual_xmlrpc_port}", flush=True)
print(f" - Socket: localhost:{actual_socket_port}", flush=True)
print("", flush=True)
if not FreeCAD.GuiUp:
print(
"Note: Screenshot and view features are not available in headless mode.",
flush=True,
)
print("Press Ctrl+C to stop.", flush=True)
print("=" * 60, flush=True)
print("", flush=True)
# Run forever (blocks until Ctrl+C)
# Plugin is guaranteed non-None at this point
plugin.run_forever()
@@ -0,0 +1,56 @@
"""Shared utilities for the FreeCAD MCP Bridge.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module provides common functionality used by both blocking_bridge.py
and startup_bridge.py to avoid code duplication.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from server import FreecadMCPPlugin
def get_running_plugin() -> FreecadMCPPlugin | None:
"""Check if an MCP bridge plugin is already running.
This function checks if the workbench commands module has an active
plugin instance (typically started via auto-start in Init.py).
Returns:
The running FreecadMCPPlugin instance if one exists and is running,
None otherwise.
Note:
This function requires FreeCAD to be available in the environment.
It will print status messages to FreeCAD.Console when a running
plugin is found.
"""
try:
import FreeCAD
except ImportError:
return None
try:
# Check if the workbench commands module has a running plugin
from commands import _mcp_plugin
if _mcp_plugin is not None and _mcp_plugin.is_running:
FreeCAD.Console.PrintMessage(
"\nMCP Bridge already running (from auto-start).\n"
)
FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n")
FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n\n")
return _mcp_plugin
except ImportError:
# Workbench commands module not available
pass
except AttributeError as e:
# _mcp_plugin exists but is malformed (missing is_running, etc.)
FreeCAD.Console.PrintWarning(f"MCP plugin state check failed: {e}\n")
return None
@@ -1,86 +0,0 @@
#!/usr/bin/env python3
r"""Headless FreeCAD MCP Bridge Server.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This script starts the MCP bridge server in FreeCAD's headless mode.
It should be run with FreeCADCmd (the headless FreeCAD executable).
Usage:
# If workbench is installed via FreeCAD Addon Manager:
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
# On macOS:
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
Note: In headless mode, GUI features like screenshots are not available.
For full functionality, use the workbench in FreeCAD's GUI.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Check if we're running inside FreeCAD
try:
import FreeCAD
print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}")
except ImportError:
print("ERROR: This script must be run with FreeCADCmd or inside FreeCAD.")
print("")
print("Usage:")
print(
" FreeCADCmd /path/to/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py"
)
print("")
print("On macOS (if workbench installed):")
print(" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \\")
print(
" ~/Library/Application\\ Support/FreeCAD/Mod/FreecadRobustMCP/"
"freecad_mcp_bridge/headless_server.py"
)
print("")
print("On Linux (if workbench installed):")
print(
" freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/"
"freecad_mcp_bridge/headless_server.py"
)
sys.exit(1)
# Import the plugin server directly from the module file in the same directory
script_dir = str(Path(__file__).resolve().parent)
sys.path.insert(0, script_dir)
from server import FreecadMCPPlugin # noqa: E402
# Create and run the plugin
plugin = FreecadMCPPlugin(
host="localhost",
port=9876, # JSON-RPC socket port
xmlrpc_port=9875, # XML-RPC port
enable_xmlrpc=True,
)
# Start the plugin
plugin.start()
# Print status messages with flush to ensure they appear immediately
# (FreeCAD's Python may have buffered stdout)
print("", flush=True)
print("=" * 60, flush=True)
print("MCP Bridge started in headless mode!", flush=True)
print(" - XML-RPC: localhost:9875", flush=True)
print(" - Socket: localhost:9876", flush=True)
print("", flush=True)
print(
"Note: Screenshot and view features are not available in headless mode.", flush=True
)
print("Press Ctrl+C to stop.", flush=True)
print("=" * 60, flush=True)
print("", flush=True)
# Run forever (blocks until Ctrl+C)
plugin.run_forever()
@@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio
import contextlib
import errno
import io
import json
import queue
@@ -45,6 +46,33 @@ DEFAULT_SOCKET_PORT = 9876
DEFAULT_XMLRPC_PORT = 9875
QUEUE_POLL_INTERVAL_MS = 50
STATUS_UPDATE_INTERVAL_MS = 5000 # Update status bar every 5 seconds
HEADLESS_POLL_INTERVAL_S = 0.1 # Headless mode poll interval in seconds
def _get_qt_core() -> Any:
"""Get the QtCore module if GUI mode is available.
This helper checks if FreeCAD is available with GUI enabled and
attempts to import QtCore from PySide2 or PySide6.
Returns:
The QtCore module if available in GUI mode, None otherwise.
"""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return None
# Try PySide2 first, then PySide6
with contextlib.suppress(ImportError):
from PySide2 import QtCore
return QtCore
with contextlib.suppress(ImportError):
from PySide6 import QtCore
return QtCore
return None
class ExecutionRequest:
@@ -262,66 +290,96 @@ class FreecadMCPPlugin:
self._timer.stop()
self._timer = None
# Stop queue processor thread (headless mode)
if self._queue_thread:
self._queue_thread.join(timeout=2.0)
self._queue_thread = None
# Stop XML-RPC server by closing its socket directly
# This will cause handle_request() to raise an exception and exit
# Keep server reference until thread exits to avoid race condition
if self._xmlrpc_server:
with contextlib.suppress(Exception):
self._xmlrpc_server.socket.close()
# Stop socket server
# Stop socket server - close the server and stop the event loop
if self._socket_loop and self._socket_server:
self._socket_loop.call_soon_threadsafe(self._socket_server.close)
self._socket_loop.call_soon_threadsafe(self._socket_loop.stop)
# Stop XML-RPC server
if self._xmlrpc_server:
self._xmlrpc_server.shutdown()
# Wait briefly for threads - they're daemon threads so they'll
# be killed when the main thread exits anyway
if self._queue_thread and self._queue_thread.is_alive():
self._queue_thread.join(timeout=0.5)
self._queue_thread = None
# Wait for threads
if self._socket_thread:
self._socket_thread.join(timeout=5.0)
if self._socket_thread and self._socket_thread.is_alive():
self._socket_thread.join(timeout=0.5)
self._socket_thread = None
if self._xmlrpc_thread:
self._xmlrpc_thread.join(timeout=5.0)
# Wait for XML-RPC thread to exit before clearing server reference
if self._xmlrpc_thread and self._xmlrpc_thread.is_alive():
self._xmlrpc_thread.join(timeout=0.5)
self._xmlrpc_thread = None
# Now safe to clear the server reference
self._xmlrpc_server = None
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("MCP Bridge stopped\n")
def run_forever(self) -> None:
"""Run the server indefinitely (for headless mode).
"""Run the server indefinitely.
This method blocks until interrupted (Ctrl+C) or stop() is called.
Use this when running FreeCAD in headless/console mode.
Works in both GUI and headless modes:
- GUI mode: Uses Qt event loop to allow timers to fire
- Headless mode: Uses short sleep intervals for responsive shutdown
"""
self.start()
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("Server running. Press Ctrl+C to stop.\n")
# Check if we're in GUI mode and have Qt available
QtCore = _get_qt_core()
try:
if QtCore is not None:
# GUI mode: use Qt's processEvents to keep the event loop running
# This allows QTimers to fire for queue processing
app = QtCore.QCoreApplication.instance()
if app is not None:
while self._running:
time.sleep(0.5)
# Process Qt events (including our QTimer callbacks)
app.processEvents()
# Small sleep to prevent busy-waiting
time.sleep(0.01)
else:
# No QApplication - fall back to headless behavior
self._run_forever_headless()
else:
# Headless mode
self._run_forever_headless()
except KeyboardInterrupt:
pass # Normal exit via Ctrl+C
finally:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage("\nShutting down...\n")
finally:
self.stop()
def _run_forever_headless(self) -> None:
"""Run forever in headless mode using short sleep intervals.
Uses a short sleep interval to allow responsive shutdown when
stop() sets _running to False.
"""
while self._running:
# Use short sleep to allow responsive shutdown
# This is more portable than signal.pause() and responds
# quickly when stop() sets _running = False
time.sleep(HEADLESS_POLL_INTERVAL_S)
# =========================================================================
# Status Bar Updates (GUI mode only)
# =========================================================================
def _start_status_updates(self) -> None:
"""Start periodic status bar updates in GUI mode."""
if not (FREECAD_AVAILABLE and FreeCAD.GuiUp):
return
# Try to import Qt - need to check both PySide2 and PySide6
QtCore = None
with contextlib.suppress(ImportError):
from PySide2 import QtCore # type: ignore[no-redef]
if QtCore is None:
with contextlib.suppress(ImportError):
from PySide6 import QtCore # type: ignore[no-redef]
QtCore = _get_qt_core()
if QtCore is None:
return
@@ -562,6 +620,19 @@ class FreecadMCPPlugin:
try:
self._socket_loop.run_until_complete(self._start_socket_server())
self._socket_loop.run_forever()
except OSError as e:
# Server failed to start - mark as not running
self._running = False
if e.errno == errno.EADDRINUSE:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintWarning(
f"MCP Bridge: JSON-RPC port {self._port} already in use. "
f"Another instance may be running.\n"
)
elif FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(
f"MCP Bridge: Failed to start JSON-RPC server: {e}\n"
)
finally:
self._socket_loop.close()
@@ -584,10 +655,6 @@ class FreecadMCPPlugin:
reader: Stream reader for incoming data.
writer: Stream writer for outgoing data.
"""
peer = writer.get_extra_info("peername")
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(f"MCP client connected (socket): {peer}\n")
try:
while self._running:
data = await reader.readline()
@@ -616,8 +683,6 @@ class FreecadMCPPlugin:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(f"MCP socket error: {e}\n")
finally:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintMessage(f"MCP client disconnected: {peer}\n")
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
@@ -693,11 +758,28 @@ class FreecadMCPPlugin:
def _run_xmlrpc_server(self) -> None:
"""Run the XML-RPC server."""
try:
self._xmlrpc_server = xmlrpc.server.SimpleXMLRPCServer(
(self._host, self._xmlrpc_port),
allow_none=True,
logRequests=False,
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
if FREECAD_AVAILABLE:
FreeCAD.Console.PrintWarning(
f"MCP Bridge: XML-RPC port {self._xmlrpc_port} already in use. "
f"Another instance may be running.\n"
)
elif FREECAD_AVAILABLE:
FreeCAD.Console.PrintError(
f"MCP Bridge: Failed to start XML-RPC server: {e}\n"
)
return
# Set a timeout so handle_request() doesn't block forever
# This allows the server to check self._running periodically
self._xmlrpc_server.timeout = 0.5
# Register methods (type: ignore needed - xmlrpc types are overly restrictive)
self._xmlrpc_server.register_function(self._xmlrpc_execute, "execute") # type: ignore[arg-type]
@@ -709,7 +791,11 @@ class FreecadMCPPlugin:
self._xmlrpc_server.register_introspection_functions()
while self._running:
try:
self._xmlrpc_server.handle_request()
except OSError:
# Socket was closed during shutdown - this is expected
break
def _xmlrpc_ping(self) -> dict[str, Any]:
"""XML-RPC ping handler."""
@@ -738,6 +824,11 @@ class FreecadMCPPlugin:
"""
return self._execute_via_queue(code, 30000)
# Valid view types for screenshot capture
_VALID_VIEW_TYPES = frozenset(
{"FitAll", "Isometric", "Front", "Back", "Top", "Bottom", "Left", "Right"}
)
def _xmlrpc_get_view(
self,
width: int = 800,
@@ -754,6 +845,21 @@ class FreecadMCPPlugin:
Returns:
Dictionary with base64 image data or error.
"""
# Validate inputs to prevent code injection
# Type hints don't enforce at runtime, so explicit conversion is needed
try:
width = int(width)
height = int(height)
except (ValueError, TypeError) as e:
return {"success": False, "error": f"Invalid dimensions: {e}"}
if view_type not in self._VALID_VIEW_TYPES:
return {
"success": False,
"error": f"Invalid view_type: {view_type}. "
f"Must be one of: {', '.join(sorted(self._VALID_VIEW_TYPES))}",
}
code = f"""
import base64
import tempfile
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""FreeCAD MCP Bridge Startup Script.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This script starts the MCP bridge in FreeCAD GUI mode. It checks if the bridge
is already running (e.g., from workbench auto-start) before starting a new
instance to avoid port conflicts.
Usage:
# Passed as argument to FreeCAD GUI on startup
freecad /path/to/startup_bridge.py
# Or on macOS:
open -a FreeCAD.app --args /path/to/startup_bridge.py
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Add the script's directory to sys.path so we can import the server module
script_dir = str(Path(__file__).resolve().parent)
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
# Check if we're running inside FreeCAD
try:
import FreeCAD
except ImportError:
print("ERROR: This script must be run inside FreeCAD.")
print("")
print("Usage:")
print(" freecad /path/to/startup_bridge.py")
print("")
print("Or on macOS:")
print(" open -a FreeCAD.app --args /path/to/startup_bridge.py")
sys.exit(1)
# Check if bridge is already running (from auto-start in Init.py)
from bridge_utils import get_running_plugin # noqa: E402
if get_running_plugin() is None:
try:
from server import FreecadMCPPlugin
# Get configuration from environment variables (with defaults)
try:
socket_port = int(os.environ.get("FREECAD_SOCKET_PORT", "9876"))
xmlrpc_port = int(os.environ.get("FREECAD_XMLRPC_PORT", "9875"))
except ValueError as e:
FreeCAD.Console.PrintError(f"Invalid port configuration: {e}\n")
FreeCAD.Console.PrintError(
"FREECAD_SOCKET_PORT and FREECAD_XMLRPC_PORT must be integers.\n"
)
raise
plugin = FreecadMCPPlugin(
host="localhost",
port=socket_port, # JSON-RPC socket port
xmlrpc_port=xmlrpc_port, # XML-RPC port
enable_xmlrpc=True,
)
plugin.start()
FreeCAD.Console.PrintMessage("\nMCP Bridge started!\n")
FreeCAD.Console.PrintMessage(f" - XML-RPC: localhost:{xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" - Socket: localhost:{socket_port}\n\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- Background -->
<rect width="64" height="64" fill="#2c3e50" rx="4"/>
<!-- Gear (settings icon) -->
<g transform="translate(16, 12)">
<!-- Outer gear body -->
<circle cx="16" cy="16" r="12" fill="#95a5a6"/>
<!-- Gear teeth -->
<rect x="13" y="0" width="6" height="4" fill="#95a5a6"/>
<rect x="13" y="28" width="6" height="4" fill="#95a5a6"/>
<rect x="0" y="13" width="4" height="6" fill="#95a5a6"/>
<rect x="28" y="13" width="4" height="6" fill="#95a5a6"/>
<!-- Diagonal teeth -->
<rect x="4" y="4" width="5" height="3" fill="#95a5a6" transform="rotate(45, 6.5, 5.5)"/>
<rect x="23" y="4" width="5" height="3" fill="#95a5a6" transform="rotate(-45, 25.5, 5.5)"/>
<rect x="4" y="25" width="5" height="3" fill="#95a5a6" transform="rotate(-45, 6.5, 26.5)"/>
<rect x="23" y="25" width="5" height="3" fill="#95a5a6" transform="rotate(45, 25.5, 26.5)"/>
<!-- Inner circle -->
<circle cx="16" cy="16" r="6" fill="#2c3e50"/>
</g>
<!-- Small MCP indicator in corner -->
<g transform="translate(40, 40)">
<circle cx="10" cy="10" r="10" fill="#3498db"/>
<text x="10" y="14" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="white" text-anchor="middle">M</text>
</g>
<!-- Label -->
<text x="32" y="58" font-family="Arial, sans-serif" font-size="6" font-weight="bold" fill="#ecf0f1" text-anchor="middle">PREFS</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- Background -->
<rect width="64" height="64" fill="#2c3e50" rx="4"/>
<!-- FreeCAD gear (left) -->
<g transform="translate(6, 10)">
<circle cx="12" cy="12" r="9" fill="#e74c3c"/>
<rect x="9" y="0" width="6" height="3" fill="#e74c3c"/>
<rect x="9" y="21" width="6" height="3" fill="#e74c3c"/>
<rect x="0" y="9" width="3" height="6" fill="#e74c3c"/>
<rect x="21" y="9" width="3" height="6" fill="#e74c3c"/>
<circle cx="12" cy="12" r="4" fill="#2c3e50"/>
</g>
<!-- Bridge (center) -->
<line x1="28" y1="22" x2="36" y2="22" stroke="#27ae60" stroke-width="3"/>
<rect x="28" y="19" width="2" height="6" fill="#27ae60"/>
<rect x="34" y="19" width="2" height="6" fill="#27ae60"/>
<!-- Robot head (right) -->
<g transform="translate(40, 10)">
<!-- Head -->
<rect x="2" y="6" width="14" height="12" fill="#d4a574" rx="2"/>
<!-- Antenna -->
<line x1="9" y1="6" x2="9" y2="2" stroke="#d4a574" stroke-width="2"/>
<circle cx="9" cy="1" r="2" fill="#d4a574"/>
<!-- Eyes -->
<circle cx="6" cy="11" r="2" fill="#2c3e50"/>
<circle cx="12" cy="11" r="2" fill="#2c3e50"/>
<!-- Mouth -->
<rect x="5" y="15" width="8" height="1" fill="#2c3e50"/>
</g>
<!-- Start indicator (green play button) -->
<g transform="translate(20, 38)">
<circle cx="12" cy="10" r="10" fill="#27ae60"/>
<polygon points="9,5 9,15 17,10" fill="white"/>
</g>
<!-- Label -->
<text x="32" y="60" font-family="Arial, sans-serif" font-size="7" font-weight="bold" fill="#ecf0f1" text-anchor="middle">START</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- Background -->
<rect width="64" height="64" fill="#2c3e50" rx="4"/>
<!-- FreeCAD gear (left) -->
<g transform="translate(6, 10)">
<circle cx="12" cy="12" r="9" fill="#e74c3c"/>
<rect x="9" y="0" width="6" height="3" fill="#e74c3c"/>
<rect x="9" y="21" width="6" height="3" fill="#e74c3c"/>
<rect x="0" y="9" width="3" height="6" fill="#e74c3c"/>
<rect x="21" y="9" width="3" height="6" fill="#e74c3c"/>
<circle cx="12" cy="12" r="4" fill="#2c3e50"/>
</g>
<!-- Bridge (center) -->
<line x1="28" y1="22" x2="36" y2="22" stroke="#3498db" stroke-width="3"/>
<rect x="28" y="19" width="2" height="6" fill="#3498db"/>
<rect x="34" y="19" width="2" height="6" fill="#3498db"/>
<!-- Robot head (right) -->
<g transform="translate(40, 10)">
<!-- Head -->
<rect x="2" y="6" width="14" height="12" fill="#d4a574" rx="2"/>
<!-- Antenna -->
<line x1="9" y1="6" x2="9" y2="2" stroke="#d4a574" stroke-width="2"/>
<circle cx="9" cy="1" r="2" fill="#d4a574"/>
<!-- Eyes -->
<circle cx="6" cy="11" r="2" fill="#2c3e50"/>
<circle cx="12" cy="11" r="2" fill="#2c3e50"/>
<!-- Mouth -->
<rect x="5" y="15" width="8" height="1" fill="#2c3e50"/>
</g>
<!-- Status indicator (blue info circle) -->
<g transform="translate(20, 38)">
<circle cx="12" cy="10" r="10" fill="#3498db"/>
<text x="12" y="14" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="white" text-anchor="middle">i</text>
</g>
<!-- Label -->
<text x="32" y="60" font-family="Arial, sans-serif" font-size="7" font-weight="bold" fill="#ecf0f1" text-anchor="middle">STATUS</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- Background -->
<rect width="64" height="64" fill="#2c3e50" rx="4"/>
<!-- FreeCAD gear (left) -->
<g transform="translate(6, 10)">
<circle cx="12" cy="12" r="9" fill="#e74c3c"/>
<rect x="9" y="0" width="6" height="3" fill="#e74c3c"/>
<rect x="9" y="21" width="6" height="3" fill="#e74c3c"/>
<rect x="0" y="9" width="3" height="6" fill="#e74c3c"/>
<rect x="21" y="9" width="3" height="6" fill="#e74c3c"/>
<circle cx="12" cy="12" r="4" fill="#2c3e50"/>
</g>
<!-- Bridge (center, grayed/broken) -->
<line x1="28" y1="22" x2="32" y2="22" stroke="#7f8c8d" stroke-width="3"/>
<line x1="32" y1="22" x2="36" y2="22" stroke="#7f8c8d" stroke-width="3" stroke-dasharray="2,2"/>
<rect x="28" y="19" width="2" height="6" fill="#7f8c8d"/>
<rect x="34" y="19" width="2" height="6" fill="#7f8c8d"/>
<!-- Robot head (right) -->
<g transform="translate(40, 10)">
<!-- Head -->
<rect x="2" y="6" width="14" height="12" fill="#d4a574" rx="2"/>
<!-- Antenna -->
<line x1="9" y1="6" x2="9" y2="2" stroke="#d4a574" stroke-width="2"/>
<circle cx="9" cy="1" r="2" fill="#d4a574"/>
<!-- Eyes -->
<circle cx="6" cy="11" r="2" fill="#2c3e50"/>
<circle cx="12" cy="11" r="2" fill="#2c3e50"/>
<!-- Mouth -->
<rect x="5" y="15" width="8" height="1" fill="#2c3e50"/>
</g>
<!-- Stop indicator (red square) -->
<g transform="translate(20, 38)">
<circle cx="12" cy="10" r="10" fill="#e74c3c"/>
<rect x="7" y="5" width="10" height="10" fill="white" rx="1"/>
</g>
<!-- Label -->
<text x="32" y="60" font-family="Arial, sans-serif" font-size="7" font-weight="bold" fill="#ecf0f1" text-anchor="middle">STOP</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- Background -->
<rect width="64" height="64" fill="#2c3e50" rx="4"/>
<!-- Gear (settings icon) -->
<g transform="translate(16, 12)">
<!-- Outer gear body -->
<circle cx="16" cy="16" r="12" fill="#95a5a6"/>
<!-- Gear teeth -->
<rect x="13" y="0" width="6" height="4" fill="#95a5a6"/>
<rect x="13" y="28" width="6" height="4" fill="#95a5a6"/>
<rect x="0" y="13" width="4" height="6" fill="#95a5a6"/>
<rect x="28" y="13" width="4" height="6" fill="#95a5a6"/>
<!-- Diagonal teeth -->
<rect x="4" y="4" width="5" height="3" fill="#95a5a6" transform="rotate(45, 6.5, 5.5)"/>
<rect x="23" y="4" width="5" height="3" fill="#95a5a6" transform="rotate(-45, 25.5, 5.5)"/>
<rect x="4" y="25" width="5" height="3" fill="#95a5a6" transform="rotate(-45, 6.5, 26.5)"/>
<rect x="23" y="25" width="5" height="3" fill="#95a5a6" transform="rotate(45, 25.5, 26.5)"/>
<!-- Inner circle -->
<circle cx="16" cy="16" r="6" fill="#2c3e50"/>
</g>
<!-- Small MCP indicator in corner -->
<g transform="translate(40, 40)">
<circle cx="10" cy="10" r="10" fill="#3498db"/>
<text x="10" y="14" font-family="Arial, sans-serif" font-size="10" font-weight="bold" fill="white" text-anchor="middle">M</text>
</g>
<!-- Label -->
<text x="32" y="58" font-family="Arial, sans-serif" font-size="6" font-weight="bold" fill="#ecf0f1" text-anchor="middle">PREFS</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+116
View File
@@ -0,0 +1,116 @@
"""Shared path utilities for the Robust MCP Bridge addon.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module provides centralized path-finding functions for locating
the addon directory, icons, and other resources. All path-related
logic is consolidated here to avoid duplication across modules.
NOTE: Using os.path instead of pathlib throughout this module due to
FreeCAD's module loading behavior which can have issues with some
Python features at load time.
"""
from __future__ import annotations
import os # noqa: PTH
import FreeCAD
# Cache for addon path to avoid repeated filesystem lookups
_addon_path_cache: str | None = None
def get_addon_path() -> str:
"""Get the path to this addon's directory.
Uses multiple fallback methods to locate the addon directory:
1. __file__ if available
2. FreeCAD's Mod path + addon name
3. Versioned FreeCAD directory (FreeCAD 1.x: v1-*)
Returns:
The absolute path to the addon directory, or empty string if not found.
Once found, the path is cached for subsequent calls.
"""
global _addon_path_cache
if _addon_path_cache is not None:
return _addon_path_cache
# Method 1: Try __file__
try:
_addon_path_cache = os.path.dirname(os.path.abspath(__file__)) # noqa: PTH100, PTH120
return _addon_path_cache
except NameError:
pass
# Method 2: Use FreeCAD's Mod path + our addon name
try:
mod_path = os.path.join( # noqa: PTH118
FreeCAD.getUserAppDataDir(), "Mod", "FreecadRobustMCP"
)
if os.path.exists(mod_path): # noqa: PTH110
_addon_path_cache = mod_path
return _addon_path_cache
except (OSError, PermissionError) as e:
FreeCAD.Console.PrintWarning(f"Could not access Mod directory: {e}\n")
# Method 3: Try versioned FreeCAD directory (FreeCAD 1.x)
try:
base_path = FreeCAD.getUserAppDataDir()
for item in os.listdir(base_path): # noqa: PTH208
if item.startswith("v1-"):
versioned_mod = os.path.join( # noqa: PTH118
base_path, item, "Mod", "FreecadRobustMCP"
)
if os.path.exists(versioned_mod): # noqa: PTH110
_addon_path_cache = versioned_mod
return _addon_path_cache
except (OSError, PermissionError) as e:
FreeCAD.Console.PrintWarning(f"Could not scan versioned directories: {e}\n")
return ""
def get_icon_path(icon_name: str) -> str:
"""Get the full path to an icon file.
Args:
icon_name: The icon filename or relative path (e.g., "icons/mcp_start.svg")
Returns:
The absolute path to the icon file, or empty string if addon path not found.
"""
addon_path = get_addon_path()
if not addon_path:
return ""
return os.path.join(addon_path, icon_name) # noqa: PTH118
def get_icons_dir() -> str:
"""Get the path to the addon's icons directory.
Returns:
The absolute path to the icons directory, or empty string if not found.
"""
addon_path = get_addon_path()
if addon_path:
icons_dir = os.path.join(addon_path, "icons") # noqa: PTH118
if os.path.isdir(icons_dir): # noqa: PTH112
return icons_dir
return ""
def get_workbench_icon() -> str:
"""Get the path to the workbench's main icon (FreecadRobustMCP.svg).
Returns:
The absolute path to the workbench icon, or empty string if not found.
"""
addon_path = get_addon_path()
if addon_path:
icon_path = os.path.join(addon_path, "FreecadRobustMCP.svg") # noqa: PTH118
if os.path.exists(icon_path): # noqa: PTH110
return icon_path
return ""
+150
View File
@@ -0,0 +1,150 @@
"""Preferences management for the Robust MCP Bridge workbench.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module handles reading and writing workbench preferences using
FreeCAD's parameter system.
NOTE: Using os.path instead of pathlib throughout this module due to
FreeCAD's module loading behavior which can have issues with some
Python features at load time.
"""
from __future__ import annotations
from typing import TypedDict
import FreeCAD
class PreferencesDict(TypedDict):
"""Type definition for the preferences dictionary."""
auto_start: bool
status_bar_enabled: bool
xmlrpc_port: int
socket_port: int
# Parameter path for our workbench preferences
PARAM_PATH = "User parameter:BaseApp/Preferences/Mod/RobustMCPBridge"
# Default values
DEFAULT_AUTO_START = False
DEFAULT_STATUS_BAR_ENABLED = True
DEFAULT_XMLRPC_PORT = 9875
DEFAULT_SOCKET_PORT = 9876
def get_param() -> FreeCAD.ParameterGrp:
"""Get the parameter group for our preferences."""
return FreeCAD.ParamGet(PARAM_PATH)
def get_auto_start() -> bool:
"""Get whether the bridge should auto-start when FreeCAD launches.
Returns:
True if bridge should auto-start, False otherwise.
Default: False
"""
return get_param().GetBool("AutoStart", DEFAULT_AUTO_START)
def set_auto_start(enabled: bool) -> None:
"""Set whether the bridge should auto-start when FreeCAD launches.
Args:
enabled: True to enable auto-start, False to disable.
"""
get_param().SetBool("AutoStart", enabled)
def get_status_bar_enabled() -> bool:
"""Get whether the status bar indicator is enabled.
Returns:
True if status bar indicator should be shown, False otherwise.
Default: True
"""
return get_param().GetBool("StatusBarEnabled", DEFAULT_STATUS_BAR_ENABLED)
def set_status_bar_enabled(enabled: bool) -> None:
"""Set whether the status bar indicator is enabled.
Args:
enabled: True to show status bar indicator, False to hide.
"""
get_param().SetBool("StatusBarEnabled", enabled)
def get_xmlrpc_port() -> int:
"""Get the XML-RPC port number.
Returns:
Port number for XML-RPC server.
Default: 9875
"""
return get_param().GetInt("XMLRPCPort", DEFAULT_XMLRPC_PORT)
def set_xmlrpc_port(port: int) -> None:
"""Set the XML-RPC port number.
Args:
port: Port number for XML-RPC server (1024-65535).
Raises:
ValueError: If port is out of valid range.
"""
if not 1024 <= port <= 65535:
raise ValueError(f"Port must be between 1024 and 65535, got {port}")
get_param().SetInt("XMLRPCPort", port)
def get_socket_port() -> int:
"""Get the JSON-RPC socket port number.
Returns:
Port number for JSON-RPC socket server.
Default: 9876
"""
return get_param().GetInt("SocketPort", DEFAULT_SOCKET_PORT)
def set_socket_port(port: int) -> None:
"""Set the JSON-RPC socket port number.
Args:
port: Port number for JSON-RPC socket server (1024-65535).
Raises:
ValueError: If port is out of valid range.
"""
if not 1024 <= port <= 65535:
raise ValueError(f"Port must be between 1024 and 65535, got {port}")
get_param().SetInt("SocketPort", port)
def get_all_preferences() -> PreferencesDict:
"""Get all preferences as a dictionary.
Returns:
Dictionary with all preference values.
"""
return {
"auto_start": get_auto_start(),
"status_bar_enabled": get_status_bar_enabled(),
"xmlrpc_port": get_xmlrpc_port(),
"socket_port": get_socket_port(),
}
def reset_to_defaults() -> None:
"""Reset all preferences to their default values."""
set_auto_start(DEFAULT_AUTO_START)
set_status_bar_enabled(DEFAULT_STATUS_BAR_ENABLED)
set_xmlrpc_port(DEFAULT_XMLRPC_PORT)
set_socket_port(DEFAULT_SOCKET_PORT)
+209
View File
@@ -0,0 +1,209 @@
"""Preferences page for FreeCAD Preferences dialog integration.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module provides a QWidget-based preferences page that integrates
with FreeCAD's main Preferences dialog (Edit → Preferences).
NOTE: This is separate from the preferences.py module which handles
the actual preference storage. This module only handles the UI.
"""
from __future__ import annotations
from PySide import QtCore, QtWidgets # type: ignore[import-not-found]
class MCPBridgePreferencesPage(QtWidgets.QWidget):
"""Preferences page for FreeCAD's Preferences dialog.
This widget appears in the FreeCAD Preferences dialog sidebar
when registered via FreeCADGui.addPreferencePage().
Required methods:
- loadSettings(): Load preferences into widgets
- saveSettings(): Save widget values to preferences
"""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
"""Initialize the preferences page widget."""
super().__init__(parent)
# Set window title - this appears in the preferences tree under the category
self.setWindowTitle("General")
self._setup_ui()
def _setup_ui(self) -> None:
"""Set up the user interface."""
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
# Title
title = QtWidgets.QLabel("<h2>Robust MCP Bridge</h2>")
layout.addWidget(title)
description = QtWidgets.QLabel(
"Configure the MCP Bridge for AI assistant integration with FreeCAD."
)
description.setWordWrap(True)
layout.addWidget(description)
layout.addSpacing(10)
# Startup group
startup_group = QtWidgets.QGroupBox("Startup")
startup_layout = QtWidgets.QVBoxLayout(startup_group)
self.auto_start_cb = QtWidgets.QCheckBox(
"Auto-start bridge when FreeCAD launches"
)
self.auto_start_cb.setToolTip(
"Automatically start the MCP bridge server when FreeCAD starts.\n"
"The bridge allows AI assistants like Claude to control FreeCAD."
)
startup_layout.addWidget(self.auto_start_cb)
layout.addWidget(startup_group)
# Display group
display_group = QtWidgets.QGroupBox("Display")
display_layout = QtWidgets.QVBoxLayout(display_group)
self.status_bar_cb = QtWidgets.QCheckBox("Show status indicator in status bar")
self.status_bar_cb.setToolTip(
"Display MCP bridge connection status in FreeCAD's status bar."
)
display_layout.addWidget(self.status_bar_cb)
layout.addWidget(display_group)
# Network Ports group
ports_group = QtWidgets.QGroupBox("Network Ports")
ports_layout = QtWidgets.QFormLayout(ports_group)
self.xmlrpc_spin = QtWidgets.QSpinBox()
self.xmlrpc_spin.setRange(1024, 65535)
self.xmlrpc_spin.setToolTip(
"Port for XML-RPC connections.\n"
"Default: 9875\n\n"
"The MCP server connects to this port to communicate with FreeCAD."
)
ports_layout.addRow("XML-RPC Port:", self.xmlrpc_spin)
self.socket_spin = QtWidgets.QSpinBox()
self.socket_spin.setRange(1024, 65535)
self.socket_spin.setToolTip(
"Port for JSON-RPC socket connections.\n"
"Default: 9876\n\n"
"Alternative connection method using raw sockets."
)
ports_layout.addRow("Socket Port:", self.socket_spin)
# Warning about restart
port_warning = QtWidgets.QLabel(
"<i>Note: Changing ports requires restarting the bridge.</i>"
)
port_warning.setWordWrap(True)
ports_layout.addRow(port_warning)
layout.addWidget(ports_group)
# Server Configuration info
server_group = QtWidgets.QGroupBox("MCP Server Configuration")
server_layout = QtWidgets.QVBoxLayout(server_group)
server_intro = QtWidgets.QLabel(
"The external MCP server (used by Claude Code, etc.) is configured "
"separately using environment variables:"
)
server_intro.setWordWrap(True)
server_layout.addWidget(server_intro)
server_layout.addSpacing(5)
# Environment variables as a form layout for better alignment
env_layout = QtWidgets.QFormLayout()
env_layout.setLabelAlignment(QtCore.Qt.AlignRight)
env_vars = [
("FREECAD_XMLRPC_PORT", "XML-RPC port (default: 9875)"),
("FREECAD_SOCKET_PORT", "JSON-RPC socket port (default: 9876)"),
("FREECAD_MODE", "Connection mode: xmlrpc, socket, or embedded"),
("FREECAD_SOCKET_HOST", "Server hostname (default: localhost)"),
]
for var_name, description in env_vars:
var_label = QtWidgets.QLabel(f"<code>{var_name}</code>")
var_label.setTextFormat(QtCore.Qt.RichText)
desc_label = QtWidgets.QLabel(description)
env_layout.addRow(var_label, desc_label)
server_layout.addLayout(env_layout)
server_layout.addSpacing(5)
server_note = QtWidgets.QLabel(
"<i>Ensure these match the ports configured above.</i>"
)
server_note.setTextFormat(QtCore.Qt.RichText)
server_layout.addWidget(server_note)
layout.addWidget(server_group)
# Add stretch to push everything to the top
layout.addStretch()
def loadSettings(self) -> None:
"""Load settings from FreeCAD preferences into widgets.
This method is called by FreeCAD when the Preferences dialog opens.
"""
# Import here to avoid circular imports and ensure module is available
from preferences import (
get_auto_start,
get_socket_port,
get_status_bar_enabled,
get_xmlrpc_port,
)
self.auto_start_cb.setChecked(get_auto_start())
self.status_bar_cb.setChecked(get_status_bar_enabled())
self.xmlrpc_spin.setValue(get_xmlrpc_port())
self.socket_spin.setValue(get_socket_port())
def saveSettings(self) -> None:
"""Save settings from widgets to FreeCAD preferences.
This method is called by FreeCAD when OK or Apply is clicked.
"""
from preferences import (
get_socket_port,
get_xmlrpc_port,
set_auto_start,
set_socket_port,
set_status_bar_enabled,
set_xmlrpc_port,
)
# Track if ports changed for potential restart
old_xmlrpc = get_xmlrpc_port()
old_socket = get_socket_port()
# Save all preferences
set_auto_start(self.auto_start_cb.isChecked())
set_status_bar_enabled(self.status_bar_cb.isChecked())
set_xmlrpc_port(self.xmlrpc_spin.value())
set_socket_port(self.socket_spin.value())
# Check if ports changed and notify about restart if needed
new_xmlrpc = self.xmlrpc_spin.value()
new_socket = self.socket_spin.value()
if old_xmlrpc != new_xmlrpc or old_socket != new_socket:
# Import FreeCAD here to avoid issues at module load time
import FreeCAD
FreeCAD.Console.PrintMessage(
"MCP Bridge ports changed. "
"If the bridge is running, restart it for changes to take effect.\n"
)
+358
View File
@@ -0,0 +1,358 @@
"""Status bar widget for MCP Bridge status display.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This module provides a permanent status widget for FreeCAD's status bar
that shows the current MCP bridge connection status without being
overwritten by other FreeCAD messages.
NOTE: All GUI operations in this module MUST be performed on the main Qt thread.
The functions in this module check for thread safety and will silently return
if called from a non-main thread to prevent crashes.
"""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from PySide import QtWidgets
# Global reference to the status widget (protected by _status_widget_lock)
_status_widget: MCPStatusWidget | None = None
_status_widget_lock = threading.Lock()
def _is_main_thread() -> bool:
"""Check if the current thread is the main Qt/GUI thread.
Uses Qt's QApplication.instance().thread() to reliably detect the main thread,
rather than relying on which thread first imports this module.
Returns:
True if on main thread, False otherwise.
"""
try:
# Try to import Qt (PySide6 first, then PySide2 as fallback)
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
from PySide2 import QtCore, QtWidgets
# Get the QApplication instance
app = QtWidgets.QApplication.instance()
if app is None:
# No QApplication - can't determine main thread, assume safe
return True
# Check if current thread is the application's main thread
return QtCore.QThread.currentThread() == app.thread()
except Exception:
# If Qt check fails, fall back to threading module check
# This is less reliable but better than nothing
current_thread = threading.current_thread()
return current_thread is threading.main_thread()
def _check_main_thread(operation: str) -> bool:
"""Check if we're on the main thread and log warning if not.
Args:
operation: Name of the operation being attempted.
Returns:
True if on main thread (safe to proceed), False otherwise.
"""
if not _is_main_thread():
try:
import FreeCAD
FreeCAD.Console.PrintWarning(
f"MCP status widget: {operation} called from non-main thread, "
"skipping to prevent crash\n"
)
except Exception:
pass
return False
return True
class MCPStatusWidget:
"""Manages the MCP Bridge status display in FreeCAD's status bar."""
def __init__(self) -> None:
"""Initialize the status widget."""
self._widget: QtWidgets.QLabel | None = None
self._installed = False
def install(self) -> bool:
"""Install the status widget into FreeCAD's status bar.
Returns:
True if successfully installed, False otherwise.
Note:
This method must be called from the main Qt thread.
If called from another thread, it will return False to prevent crashes.
"""
if self._installed:
return True
# Thread safety check - GUI operations must be on main thread
if not _check_main_thread("install"):
return False
try:
import FreeCADGui
from PySide import QtWidgets # type: ignore[import-not-found]
# Get the main window and status bar
main_window = FreeCADGui.getMainWindow()
if main_window is None:
return False
status_bar = main_window.statusBar()
if status_bar is None:
return False
# Create the status label widget
self._widget = QtWidgets.QLabel()
self._widget.setObjectName("mcp_bridge_status_widget")
self._widget.setToolTip("MCP Bridge Status")
# Style it to stand out slightly
self._widget.setStyleSheet(
"QLabel { padding: 2px 6px; border-radius: 3px; font-size: 11px; }"
)
# Add as a permanent widget (won't be hidden by temporary messages)
status_bar.addPermanentWidget(self._widget)
self._installed = True
self.set_stopped()
return True
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintWarning(f"Could not install MCP status widget: {e}\n")
return False
def remove(self) -> None:
"""Remove the status widget from the status bar.
Note:
This method must be called from the main Qt thread.
If called from another thread, it will silently return.
"""
if self._widget is None:
return
# Thread safety check - GUI operations must be on main thread
if not _check_main_thread("remove"):
return
try:
self._widget.setParent(None)
self._widget.deleteLater()
except Exception:
pass
self._widget = None
self._installed = False
def set_running(
self, xmlrpc_port: int, socket_port: int, request_count: int = 0
) -> None:
"""Update the widget to show running status.
Args:
xmlrpc_port: The XML-RPC port number.
socket_port: The socket port number.
request_count: Number of requests processed this session.
"""
if self._widget is None:
return
# Thread safety check
if not _check_main_thread("set_running"):
return
self._widget.setText(f"MCP: Running ({xmlrpc_port}/{socket_port})")
self._widget.setStyleSheet(
"QLabel { "
"background-color: #2e7d32; "
"color: white; "
"padding: 2px 6px; "
"border-radius: 3px; "
"font-size: 11px; "
"}"
)
self._widget.setToolTip(
f"MCP Bridge is running\n"
f"XML-RPC: localhost:{xmlrpc_port}\n"
f"Socket: localhost:{socket_port}\n"
f"Requests processed: {request_count}"
)
def set_stopped(self) -> None:
"""Update the widget to show stopped status."""
if self._widget is None:
return
# Thread safety check
if not _check_main_thread("set_stopped"):
return
self._widget.setText("MCP: Stopped")
self._widget.setStyleSheet(
"QLabel { "
"background-color: #757575; "
"color: white; "
"padding: 2px 6px; "
"border-radius: 3px; "
"font-size: 11px; "
"}"
)
self._widget.setToolTip("MCP Bridge is not running")
def set_starting(self) -> None:
"""Update the widget to show starting status."""
if self._widget is None:
return
# Thread safety check
if not _check_main_thread("set_starting"):
return
self._widget.setText("MCP: Starting...")
self._widget.setStyleSheet(
"QLabel { "
"background-color: #f57c00; "
"color: white; "
"padding: 2px 6px; "
"border-radius: 3px; "
"font-size: 11px; "
"}"
)
self._widget.setToolTip("MCP Bridge is starting...")
def set_error(self, message: str) -> None:
"""Update the widget to show error status.
Args:
message: Error message to display in tooltip.
"""
if self._widget is None:
return
# Thread safety check
if not _check_main_thread("set_error"):
return
self._widget.setText("MCP: Error")
self._widget.setStyleSheet(
"QLabel { "
"background-color: #c62828; "
"color: white; "
"padding: 2px 6px; "
"border-radius: 3px; "
"font-size: 11px; "
"}"
)
self._widget.setToolTip(f"MCP Bridge Error: {message}")
def get_status_widget() -> MCPStatusWidget:
"""Get the global status widget instance, creating if needed.
This function is thread-safe and uses double-checked locking.
Returns:
The MCPStatusWidget instance.
"""
global _status_widget
# Fast path: if already created, return it without acquiring lock
if _status_widget is not None:
return _status_widget
# Slow path: acquire lock and check again before creating
with _status_widget_lock:
if _status_widget is None:
_status_widget = MCPStatusWidget()
return _status_widget
def install_status_widget() -> bool:
"""Install the status widget into the status bar.
Returns:
True if successfully installed.
"""
return get_status_widget().install()
def update_status_running(
xmlrpc_port: int, socket_port: int, request_count: int = 0
) -> None:
"""Update status widget to show running state."""
widget = get_status_widget()
widget.install() # Ensure installed
widget.set_running(xmlrpc_port, socket_port, request_count)
def update_status_stopped() -> None:
"""Update status widget to show stopped state."""
widget = get_status_widget()
widget.install() # Ensure installed
widget.set_stopped()
def update_status_starting() -> None:
"""Update status widget to show starting state."""
widget = get_status_widget()
widget.install() # Ensure installed
widget.set_starting()
def update_status_error(message: str) -> None:
"""Update status widget to show error state."""
widget = get_status_widget()
widget.install() # Ensure installed
widget.set_error(message)
def sync_status_with_bridge() -> None:
"""Sync status widget with current bridge state.
This function checks the bridge status and updates the widget accordingly.
Must be called from the main Qt thread.
"""
try:
# Thread safety check
if not _check_main_thread("sync_status_with_bridge"):
return
from commands import _mcp_plugin
from preferences import get_status_bar_enabled
if not get_status_bar_enabled():
return
widget = get_status_widget()
if not widget.install():
return
if _mcp_plugin is not None and _mcp_plugin.is_running:
widget.set_running(
_mcp_plugin.xmlrpc_port,
_mcp_plugin.socket_port,
_mcp_plugin.request_count,
)
else:
widget.set_stopped()
except Exception:
pass
+299
View File
@@ -0,0 +1,299 @@
# FreeCAD GUI Binary Hangs in Headless CI Environments Without Window Manager
Bug Report: [github.com/FreeCAD/FreeCAD/issues/26817](https://github.com/FreeCAD/FreeCAD/issues/26817)
---
## TL;DR (GitHub Issue Version)
**Bug**: `freecad --version` hangs indefinitely when run with Xvfb but without a window manager. `freecadcmd --version` works fine.
**Environment**: FreeCAD 1.0.2 AppImage, Ubuntu 22.04, Xvfb
**Reproduce**:
```bash
Xvfb :99 -screen 0 1920x1080x24 &
export DISPLAY=:99
./FreeCAD.AppImage --appimage-extract
./squashfs-root/AppRun freecad --version # HANGS
./squashfs-root/AppRun freecadcmd --version # Works
```
**Cause**: FreeCAD GUI requires window manager events to display any window (including the `--version` dialog box). Without a WM, Qt waits indefinitely for `ConfigureNotify`/`Expose` events that never arrive.
**Note**: Unlike most CLI tools, `freecad --version` displays a **GUI dialog**, not console output.
**Workaround**: Run `openbox &` before FreeCAD, or use `freecadcmd` for headless operations.
---
## Detailed Report
## Summary
The FreeCAD GUI binary (`freecad`) hangs indefinitely during Qt initialization when run in a headless environment with Xvfb but **without a window manager**. This affects all command-line operations including `freecad --version` and `freecad --help`. The headless binary (`freecadcmd`) works correctly in the same environment.
## Environment
- **FreeCAD versions tested**: 1.0.2 (stable), 1.1.0 (weekly-2025.09.03)
- **Platform**: Linux (Ubuntu 22.04, both x86_64 and aarch64)
- **AppImage**: `FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage`
- **Display**: Xvfb virtual framebuffer (`:99`, 1920x1080x24)
- **Qt platform**: xcb
- **Context**: GitHub Actions CI, Docker containers
## Steps to Reproduce
### Easiest reproduction using Docker (hangs)
This one-liner reproduces the bug in an isolated container:
```bash
# Run this on any system with Docker installed (Linux, macOS, Windows)
# Automatically selects the correct AppImage for your architecture (x86_64 or aarch64)
docker run --rm -it ubuntu:22.04 bash -c '
apt-get update && apt-get install -y xvfb curl libfuse2 libgl1 libegl1 openbox >/dev/null 2>&1
cd /tmp
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ]; then
APPIMAGE="FreeCAD_1.0.2-conda-Linux-aarch64-py311.AppImage"
else
APPIMAGE="FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage"
fi
echo "Downloading FreeCAD AppImage for $ARCH..."
curl -sLO "https://github.com/FreeCAD/FreeCAD/releases/download/1.0.2/$APPIMAGE"
chmod +x "$APPIMAGE"
echo "Extracting AppImage..."
./"$APPIMAGE" --appimage-extract > /dev/null
ls -la /tmp/squashfs-root/AppRun
export XDG_RUNTIME_DIR=/tmp/runtime-root
echo "Starting freecadcmd with xvfb-run (should work fine)..."
xvfb-run -a /tmp/squashfs-root/AppRun freecadcmd --version || echo "This should have worked"
echo "Starting FreeCAD with xvfb-run (will hang without window manager)..."
timeout 10 xvfb-run -a /tmp/squashfs-root/AppRun freecad --version || echo "HUNG as expected (timeout after 10s)"
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 2
export DISPLAY=:99
openbox &
xvfb-run -a /tmp/squashfs-root/AppRun freecad --version
'
```
### Simplest reproduction using xvfb-run (hangs)
```bash
# Download FreeCAD AppImage
curl -LO "https://github.com/FreeCAD/FreeCAD/releases/download/1.0.2/FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage"
chmod +x FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage
# Extract AppImage (required because xvfb-run doesn't work well with FUSE)
./FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage --appimage-extract
# This hangs indefinitely - xvfb-run provides Xvfb but no window manager
xvfb-run --auto-servernum ./squashfs-root/AppRun freecad --version
```
The `xvfb-run` wrapper is commonly used in CI environments to run GUI applications headlessly. It starts Xvfb automatically, but does **not** start a window manager, causing FreeCAD to hang.
### Manual Xvfb reproduction (hangs)
```bash
# Start Xvfb without a window manager
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 2
export DISPLAY=:99
export QT_QPA_PLATFORM=xcb
# Extract and run FreeCAD AppImage
./FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage --appimage-extract
./squashfs-root/AppRun freecad --version # HANGS INDEFINITELY
```
### Variants that also hang
```bash
# All of these hang:
./squashfs-root/AppRun freecad --help
./squashfs-root/AppRun freecad --version
./squashfs-root/AppRun freecad -c "print('hello')"
QT_QPA_PLATFORM=offscreen ./squashfs-root/AppRun freecad --version
QT_QPA_PLATFORM=minimal ./squashfs-root/AppRun freecad --version
```
### What works correctly
```bash
# Headless binary works fine:
./squashfs-root/AppRun freecadcmd --version # Works immediately
./squashfs-root/AppRun freecadcmd -c "import FreeCAD; print(FreeCAD.Version())" # Works
```
## Expected Behavior
FreeCAD GUI should work in headless CI environments with just Xvfb, allowing:
1. Display of version/help dialogs (FreeCAD uses GUI dialogs, not console output)
2. Execution of Python scripts
3. Basic GUI operations for automated testing
**Note**: Unlike most CLI tools, `freecad --version` and `freecad --help` display **GUI dialog boxes** rather than printing to the console. This is by design, but it means they require a functioning GUI environment.
## Actual Behavior
The `freecad` binary:
1. Connects to X11 display successfully
2. Initializes Qt's QApplication
3. Attempts to create/display a window (version dialog, main window, etc.)
4. Waits indefinitely for X11 window manager events that never arrive
5. Hangs before the dialog/window can be displayed
## Technical Analysis
### Process State During Hang
Using `strace` and `/proc` inspection, we found:
```text
Process state: S (sleeping)
Threads: 2
Thread 1 (main): waiting in do_sys_poll on eventfd
Thread 2 (X11 reader): waiting in do_sys_poll on X11 socket
```
### Strace Output
The final syscalls before the hang show both threads blocked on `ppoll()`:
```text
# Thread 21 (main Qt event loop) - waiting on eventfd with 30s timeout
[pid 21] ppoll([{fd=6, events=POLLIN}], 1, {tv_sec=29, tv_nsec=541000000}, NULL, 8
# Thread 22 (X11 reader) - waiting on X11 socket indefinitely
[pid 22] ppoll([{fd=4, events=POLLIN}], 1, NULL, NULL, 0 <unfinished ...>
```
The file descriptors are:
- fd 4: X11 socket connection (successfully established)
- fd 6: eventfd for Qt thread synchronization
### Root Cause
FreeCAD's GUI requires window manager events to display any window or dialog (including the `--version` dialog). In a bare Xvfb environment without a window manager:
1. FreeCAD creates a window and waits for it to be mapped/configured
2. No window manager means no `ConfigureNotify`, `Expose`, or `MapNotify` events
3. Qt's event loop waits for these events before the window can be displayed
4. The main thread blocks waiting for signals from the X11 reader thread
5. The X11 reader thread blocks waiting for X11 events that never arrive
6. Deadlock: both threads wait indefinitely for events that will never come
### Why `freecadcmd` Works
The `freecadcmd` binary does not initialize Qt's GUI components, so it never enters this blocking state.
## Workaround
Running a lightweight window manager (like `openbox`) alongside Xvfb resolves the issue:
```bash
# Start Xvfb
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 2
export DISPLAY=:99
# Start a window manager (this is the key fix)
openbox &
sleep 1
# Now FreeCAD GUI works
./squashfs-root/AppRun freecad --version # Works!
```
Additionally, sending synthetic X11 events with `xdotool` can help:
```bash
# In a loop while FreeCAD starts:
xdotool mousemove 500 500 click 1 key Escape
```
## Suggested Fixes
Several approaches could improve FreeCAD's headless CI compatibility:
### Option 1: Add timeout/fallback for window manager events
FreeCAD could detect when no window manager responds within a reasonable timeout (e.g., 5 seconds) and either:
- Fall back to a minimal mode
- Exit with a clear error message instead of hanging indefinitely
- Use `QT_QPA_PLATFORM=offscreen` automatically when no WM is detected
### Option 2: Console output for `--version`/`--help` (CI-friendly)
For CI environments, having `--version` and `--help` output to console (like most CLI tools) would be helpful. This could be:
- A separate flag like `--version-console`
- Automatic when `DISPLAY` is not set or in a detected CI environment
- Controlled by an environment variable
### Option 3: Document the window manager requirement
At minimum, clearly document that the FreeCAD GUI binary requires a window manager (not just Xvfb) for any operation, including `--version`.
## Impact
This bug affects:
- **CI/CD pipelines** using FreeCAD in headless environments
- **Docker containers** running FreeCAD without a display manager
- **Automated testing** of FreeCAD-based applications
- **Server-side rendering** or batch processing with GUI features
The workaround (adding `openbox`) increases container size and complexity for CI environments.
## Additional Context
### Test Script
Here's a complete test script that demonstrates both the bug and the workaround:
```bash
#!/bin/bash
set -e
# Setup
apt-get update && apt-get install -y xvfb openbox xdotool curl
curl -LO "https://github.com/FreeCAD/FreeCAD/releases/download/1.0.2/FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage"
chmod +x FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage
./FreeCAD_1.0.2-conda-Linux-x86_64-py311.AppImage --appimage-extract
export DISPLAY=:99
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 2
echo "=== Test 1: Without window manager (will hang) ==="
timeout 10 ./squashfs-root/AppRun freecad --version || echo "HUNG as expected"
echo "=== Test 2: With window manager (works) ==="
openbox &
sleep 1
timeout 10 ./squashfs-root/AppRun freecad --version && echo "SUCCESS"
```
### Related
- This may be related to how FreeCAD integrates with PySide6/Qt6
- The `freecadcmd` binary correctly avoids this issue by not initializing GUI
- Other Qt applications (like `qmlscene --help`) typically handle this correctly
## System Information
```text
FreeCAD 1.0.2, Libs: 1.0.2R39319 (Git)
OS: Ubuntu 22.04 (Docker/GitHub Actions)
Python: 3.11.13 (conda-forge)
Qt: 6.x (bundled in AppImage)
```
+1 -1
View File
@@ -1,4 +1,4 @@
# FreeCAD MCP Server Comparison Analysis
# FreeCAD Robust MCP Server Comparison Analysis
This document analyzes existing FreeCAD MCP server implementations to identify best practices and improvements for our architecture.
+7 -7
View File
@@ -1,12 +1,12 @@
# FreeCAD MCP Tools Reference
# FreeCAD Robust MCP Server Tools Reference
This document provides a comprehensive reference for all MCP tools available in the FreeCAD MCP server.
This document provides a comprehensive reference for all MCP tools available in the FreeCAD Robust MCP Server.
---
## Overview
The FreeCAD MCP server exposes tools organized into the following categories:
The FreeCAD Robust MCP Server exposes tools organized into the following categories:
| Category | Description | Tool Count |
| ------------------------------- | -------------------------------- | ---------- |
@@ -83,7 +83,7 @@ get_console_output(lines: int = 100) -> list[str]
### get_mcp_server_environment
Get environment information about the MCP server process. Useful for identifying whether the MCP server is running in a Docker container or on the host system.
Get environment information about the Robust MCP Server process. Useful for identifying the Robust MCP Server instance via the unique `instance_id`.
```python
get_mcp_server_environment() -> dict
@@ -91,14 +91,14 @@ get_mcp_server_environment() -> dict
**Returns:** Dictionary containing:
- `instance_id`: Unique UUID for this server instance (generated at startup)
- `hostname`: Server hostname
- `os_name`: Operating system name (e.g., "Linux", "Darwin", "Windows")
- `os_version`: OS version/release
- `platform`: Full platform string
- `python_version`: Python version
- `in_docker`: Boolean indicating if running in Docker
- `docker_container_id`: Container ID (first 12 chars) if in Docker
- `env_vars`: Relevant environment variables (FREECAD_MODE, etc.)
- `freecad`: FreeCAD connection information (connected, mode, version, gui_available, is_headless)
- `env_vars`: Selected environment variables (FREECAD_MODE, ports, host)
---
+1 -1
View File
@@ -1,6 +1,6 @@
# Bridge API Reference
The bridge module provides the communication layer between the MCP server and FreeCAD.
The bridge module provides the communication layer between the Robust MCP Server and FreeCAD.
## Base Classes
+3 -2
View File
@@ -277,7 +277,8 @@ freecad_mcp/
│ ├── __init__.py
│ ├── server.py # XML-RPC/JSON-RPC server
│ ├── handlers.py # Request handlers
── headless_server.py # Headless mode launcher
── blocking_bridge.py # Blocking server (keeps FreeCAD running)
│ └── startup_bridge.py # Non-blocking startup (for interactive GUI)
└── utils/ # Utility modules
└── __init__.py
@@ -1515,7 +1516,7 @@ FREECAD_XMLRPC_PORT = "9875"
**Setup:**
1. Install the MCP Bridge workbench via FreeCAD Addon Manager, or run `just run-gui` from source
1. Install the Robust MCP Bridge workbench via FreeCAD Addon Manager, or run `just freecad::run-gui` from source
1. Start the bridge using the workbench toolbar button or menu
1. The bridge starts both XML-RPC (port 9875) and JSON-RPC (port 9876) servers
+2 -1
View File
@@ -132,7 +132,8 @@ addon/FreecadRobustMCP/
└── freecad_mcp_bridge/ # Bridge plugin
├── __init__.py
├── server.py # XML-RPC/JSON-RPC server
── headless_server.py # Headless mode launcher
── blocking_bridge.py # Blocking server (keeps FreeCAD running)
└── startup_bridge.py # Non-blocking startup (for interactive GUI)
package.xml # FreeCAD addon metadata (in project root)
```
+1 -1
View File
@@ -1,6 +1,6 @@
# Contributing
Thank you for your interest in contributing to FreeCAD MCP Server!
Thank you for your interest in contributing to FreeCAD Robust MCP Server!
---
+5 -5
View File
@@ -269,7 +269,7 @@ Create diagrams using Mermaid syntax:
````markdown
```mermaid
graph LR
A[MCP Client] --> B[MCP Server]
A[MCP Client] --> B[Robust MCP Server]
B --> C[FreeCAD Bridge]
C --> D[FreeCAD]
```
@@ -279,7 +279,7 @@ graph LR
```mermaid
graph LR
A[MCP Client] --> B[MCP Server]
A[MCP Client] --> B[Robust MCP Server]
B --> C[FreeCAD Bridge]
C --> D[FreeCAD]
```
@@ -338,7 +338,7 @@ Variables are defined in `docs/variables.yaml`:
```yaml
# docs/variables.yaml
project_name: FreeCAD MCP Server
project_name: FreeCAD Robust MCP Server
package_name: freecad-robust-mcp
xmlrpc_port: 9875
socket_port: 9876
@@ -354,8 +354,8 @@ Install with: `pip install {{@ package_name @}}`
### Available Variables
| Variable | Value | Description |
|----------|-------|-------------|
| `project_name` | FreeCAD MCP Server | Display name |
| ---------------------- | ------------------------- | ------------------- |
| `project_name` | FreeCAD Robust MCP Server | Display name |
| `package_name` | freecad-robust-mcp | PyPI package name |
| `docker_image` | spkane/freecad-robust-mcp | Docker image |
| `xmlrpc_port` | 9875 | XML-RPC server port |
+109 -14
View File
@@ -2,23 +2,64 @@
This project uses **component-specific versioning**. Each component (MCP Server, Workbench, Macros) has its own version and release cycle, allowing independent updates without affecting other components.
## Quick Start
The complete release workflow in order:
```bash
# 1. Pre-release checks
just release::status # Check which components have unreleased changes
just release::changes-since mcp-server # View specific changes (or workbench, macro-magnets, macro-export)
just all # Run all quality checks (must pass)
# 2. Update changelog
just release::draft-notes mcp-server # Generate draft notes from commits
# Then manually edit CHANGELOG.md with appropriate section header
# 3. Version bump (workbench & macros only - MCP Server uses setuptools-scm)
just release::bump-workbench 1.0.0 # or bump-macro-magnets, bump-macro-export
# 4. Commit changes
git add -A
git commit -m "chore: bump workbench to 1.0.0" # or appropriate component/message
# 5. Create & push tag (triggers CI/CD automatically)
just release::tag-workbench 1.0.0 # or tag-mcp-server, tag-macro-magnets, tag-macro-export
# 6. Monitor release at GitHub Actions, then verify
just release::list-tags
just release::latest-versions
# 7. Update FreeCAD wiki (macros only)
just release::wiki-update macro-magnets # Copies content to clipboard & opens wiki edit page
```
| Component | Bump Command | Tag Command |
| ------------- | ---------------------------------------- | --------------------------------------- |
| MCP Server | *(none - uses setuptools-scm)* | `just release::tag-mcp-server X.Y.Z` |
| Workbench | `just release::bump-workbench X.Y.Z` | `just release::tag-workbench X.Y.Z` |
| Magnets Macro | `just release::bump-macro-magnets X.Y.Z` | `just release::tag-macro-magnets X.Y.Z` |
| Export Macro | `just release::bump-macro-export X.Y.Z` | `just release::tag-macro-export X.Y.Z` |
## Components and Their Release Targets
| Component | Tag Format | Releases To |
| --------- | ---------- | ----------- |
| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI, Docker Hub, GitHub Release |
| MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) |
| ---------------------------- | ------------------------------------- | ------------------------------------------ |
| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI/TestPyPI*, Docker Hub, GitHub Release |
| Robust MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) |
| Cut Object for Magnets Macro | `macro-cut-object-for-magnets-vX.Y.Z` | GitHub Release (archive) |
| Multi Export Macro | `macro-multi-export-vX.Y.Z` | GitHub Release (archive) |
*Stable releases (`X.Y.Z`) publish to PyPI; non-stable releases (alpha, beta, rc) publish to TestPyPI only.
## Version Format
All versions follow [Semantic Versioning 2.0](https://semver.org/):
- `X.Y.Z` - Stable release (published to PyPI for MCP Server)
- `X.Y.Z-alpha` or `X.Y.Z-alpha.N` - Alpha pre-release (TestPyPI only)
- `X.Y.Z-beta` or `X.Y.Z-beta.N` - Beta pre-release (PyPI)
- `X.Y.Z-rc.N` - Release candidate (PyPI)
- `X.Y.Z` - Stable release (published to PyPI only)
- `X.Y.Z-alpha` or `X.Y.Z-alpha.N` - Alpha pre-release (published to TestPyPI only)
- `X.Y.Z-beta` or `X.Y.Z-beta.N` - Beta pre-release (published to TestPyPI only)
- `X.Y.Z-rc.N` - Release candidate (published to TestPyPI only)
## Release Workflow Overview
@@ -129,7 +170,7 @@ Brief description of the release.
---
### MCP Bridge Workbench vX.Y.Z
### Robust MCP Bridge Workbench vX.Y.Z
...
```
@@ -137,7 +178,7 @@ Brief description of the release.
**Important:** The changelog section header format must match exactly for the GitHub Release workflow to extract it:
- MCP Server: `### MCP Server vX.Y.Z`
- Workbench: `### MCP Bridge Workbench vX.Y.Z`
- Workbench: `### Robust MCP Bridge Workbench vX.Y.Z`
- Magnets Macro: `### Cut Object for Magnets Macro vX.Y.Z`
- Export Macro: `### Multi Export Macro vX.Y.Z`
@@ -166,7 +207,7 @@ just release::tag-mcp-server 1.0.0
1. GitHub Actions validates the tag format
2. Builds Python wheel and source distribution (version from tag)
3. Tests installation on Ubuntu and macOS
4. Publishes to PyPI (or TestPyPI for alpha versions)
4. Publishes to PyPI (or TestPyPI for alpha, beta, and rc versions)
5. Builds multi-architecture Docker image (amd64 + arm64)
6. Pushes to Docker Hub as `spkane/freecad-robust-mcp:1.0.0`
7. Creates GitHub Release with wheel and tar.gz artifacts
@@ -177,16 +218,16 @@ just release::tag-mcp-server 1.0.0
# Alpha (goes to TestPyPI only)
just release::tag-mcp-server 1.0.0-alpha.1
# Beta (goes to PyPI)
# Beta (goes to TestPyPI only)
just release::tag-mcp-server 1.0.0-beta.1
# Release candidate (goes to PyPI)
# Release candidate (goes to TestPyPI only)
just release::tag-mcp-server 1.0.0-rc.1
```
### MCP Bridge Workbench Release
### Robust MCP Bridge Workbench Release
The workbench is a FreeCAD addon that provides the MCP bridge GUI.
The workbench is a FreeCAD addon that provides the Robust MCP Bridge GUI.
```bash
# 1. Bump version in source files
@@ -375,6 +416,55 @@ just release::tag-mcp-server 1.0.0
- **Workbench**: Release in sync with server changes that affect the bridge protocol
- **Macros**: Release independently when macro functionality changes
## Updating the FreeCAD Wiki
After releasing a macro, you should update its FreeCAD wiki page. The `wiki-source.txt` files are automatically updated by the `bump-macro-*` commands with the new version and date.
### Wiki Update Commands
```bash
# Check differences between local and live wiki
just release::wiki-diff macro-magnets
just release::wiki-diff macro-export
# View the wiki source content locally
just release::wiki-show macro-magnets
just release::wiki-show macro-export
# Update the wiki (copies to clipboard and opens edit page)
just release::wiki-update macro-magnets
just release::wiki-update macro-export
```
### Wiki Update Workflow
The `wiki-update` command provides a safe, assisted workflow:
1. Copies the updated wiki-source.txt content to your clipboard
2. Opens the FreeCAD wiki edit page in your browser
3. Displays step-by-step instructions
**Manual steps after running the command:**
1. Log in to your FreeCAD wiki account if prompted
2. Select all content in the edit box (Ctrl+A / Cmd+A)
3. Paste the new content (Ctrl+V / Cmd+V)
4. Add an edit summary like "Update to version X.Y.Z"
5. Click "Show preview" to verify changes
6. Click "Save changes" when satisfied
!!! note "Wiki Account Required"
You need a FreeCAD wiki account to edit pages. Register at [wiki.freecad.org](https://wiki.freecad.org) if you don't have one.
### Macro Shortcuts
The wiki commands accept multiple aliases for convenience:
| Macro | Aliases |
| ------------------------ | -------------------------------- |
| Cut Object for Magnets | `macro-magnets`, `magnets`, `cut`|
| Multi Export | `macro-export`, `export`, `multi`|
## Quick Reference
```bash
@@ -411,6 +501,11 @@ just release::latest-versions
# Extract changelog for a version (used by CI)
just release::extract-changelog mcp-server 1.0.0
# Update FreeCAD wiki for macros (after release)
just release::wiki-diff macro-magnets # Check differences
just release::wiki-update macro-magnets # Copy to clipboard & open edit page
just release::wiki-update macro-export
# Delete a tag if needed
just release::delete-tag <full-tag-name>
```
+7 -7
View File
@@ -1,6 +1,6 @@
# Configuration
Configure the FreeCAD MCP Server using environment variables and MCP client settings.
Configure the FreeCAD Robust MCP Server using environment variables and MCP client settings.
---
@@ -19,13 +19,13 @@ Configure the FreeCAD MCP Server using environment variables and MCP client sett
## Connection Modes
The MCP server supports three connection modes:
The Robust MCP Server supports three connection modes:
| Mode | Description | Platform Support |
| ---------- | ------------------------------------------- | --------------------------------- |
| ---------- | ------------------------------------------- | ----------------------------------------- |
| `xmlrpc` | Connects to FreeCAD via XML-RPC (port 9875) | **All platforms** (recommended) |
| `socket` | Connects via JSON-RPC socket (port 9876) | **All platforms** |
| `embedded` | Imports FreeCAD directly into process | **Linux only** (crashes on macOS) |
| `embedded` | Imports FreeCAD directly into process | **Linux only** (crashes on macOS/Windows) |
### XML-RPC Mode (Recommended)
@@ -50,7 +50,7 @@ freecad-mcp
!!! warning "Linux Only"
Embedded mode only works on Linux. On macOS and Windows, it will crash because FreeCAD's `FreeCAD.so` library links to its bundled Python, which conflicts with external Python interpreters.
Embedded mode imports FreeCAD directly into the MCP server process for fastest execution.
Embedded mode imports FreeCAD directly into the Robust MCP Server process for fastest execution.
```bash
export FREECAD_MODE=embedded
@@ -119,7 +119,7 @@ If installed from source with mise/uv:
## GUI vs Headless Mode
FreeCAD can run in two modes, and the MCP server works with both:
FreeCAD can run in two modes, and the Robust MCP Server works with both:
| Feature | Headless Mode | GUI Mode |
| ------------------------ | ------------- | -------- |
@@ -151,7 +151,7 @@ just freecad::run-gui
just freecad::run-headless
# Or run directly with FreeCADCmd
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
```
---
+12 -12
View File
@@ -1,6 +1,6 @@
# Installation
This guide covers installing the FreeCAD MCP Server and connecting it to your AI assistant.
This guide covers installing the FreeCAD Robust MCP Server and connecting it to your AI assistant.
---
@@ -16,7 +16,7 @@ This guide covers installing the FreeCAD MCP Server and connecting it to your AI
### Method 1: pip (Recommended)
The simplest way to install the MCP server:
The simplest way to install the Robust MCP Server:
```bash
pip install freecad-robust-mcp
@@ -38,7 +38,7 @@ just setup
### Method 3: Docker
Run the MCP server in a container:
Run the Robust MCP Server in a container:
```bash
# Pull from Docker Hub
@@ -48,21 +48,21 @@ docker pull spkane/freecad-robust-mcp
docker build -t freecad-robust-mcp .
```
**Note:** The Docker container runs the MCP server only—it does not include FreeCAD itself. You must run FreeCAD with the MCP Bridge workbench on your host machine (or in a separate container) and configure the MCP server to connect via `xmlrpc` or `socket` mode.
**Note:** The Docker container runs the Robust MCP Server only—it does not include FreeCAD itself. You must run FreeCAD with the Robust MCP Bridge workbench on your host machine (or in a separate container) and configure the Robust MCP Server to connect via `xmlrpc` or `socket` mode.
**Why embedded mode doesn't work with Docker:** Embedded mode requires FreeCAD and the MCP server to run in the same process, which is impossible when FreeCAD runs on the host and the MCP server runs inside a Docker container. Additionally, embedded mode fails on macOS due to ABI incompatibility with FreeCAD's bundled Python libraries (`libpython3.11.dylib`). Always use `xmlrpc` or `socket` mode for Docker deployments.
**Why embedded mode doesn't work with Docker:** Embedded mode requires FreeCAD and the Robust MCP Server to run in the same process, which is impossible when FreeCAD runs on the host and the Robust MCP Server runs inside a Docker container. Additionally, embedded mode fails on macOS due to ABI incompatibility with FreeCAD's bundled Python libraries (`libpython3.11.dylib`). Always use `xmlrpc` or `socket` mode for Docker deployments.
---
## Installing the MCP Bridge Workbench
## Installing the Robust MCP Bridge Workbench
The MCP Bridge Workbench runs inside FreeCAD and provides the connection point for the MCP server.
The Robust MCP Bridge Workbench runs inside FreeCAD and provides the connection point for the Robust MCP Server.
### Via FreeCAD Addon Manager (Recommended)
1. Open FreeCAD
1. Go to **Tools > Addon Manager**
1. Search for "FreeCAD MCP and More" or "MCP Bridge"
1. Search for "FreeCAD MCP and More" or "Robust MCP Bridge"
1. Click **Install**
1. Restart FreeCAD
@@ -81,9 +81,9 @@ The MCP Bridge Workbench runs inside FreeCAD and provides the connection point f
After installation, verify everything is working:
### Step 1: Start FreeCAD with the MCP Bridge
### Step 1: Start FreeCAD with the Robust MCP Bridge
1. **Start FreeCAD** and select the **MCP Bridge** workbench from the workbench selector dropdown
1. **Start FreeCAD** and select the **Robust MCP Bridge** workbench from the workbench selector dropdown
1. **Click "Start MCP Bridge"** in the toolbar (or use the MCP Bridge menu)
1. Check the FreeCAD console for confirmation messages:
@@ -93,9 +93,9 @@ MCP Bridge started!
- Socket: localhost:9876
```
### Step 2: Verify the MCP Server
### Step 2: Verify the Robust MCP Server
Test that the MCP server command is available:
Test that the Robust MCP Server command is available:
```bash
# With pip installation
+3 -3
View File
@@ -8,7 +8,7 @@ Get up and running with AI-assisted FreeCAD modeling in minutes.
Before starting, ensure you have:
1. FreeCAD installed with the MCP Bridge workbench
1. FreeCAD installed with the Robust MCP Bridge workbench
1. The MCP server installed (`pip install freecad-robust-mcp`)
1. Your MCP client configured (see [Configuration](configuration.md))
@@ -19,7 +19,7 @@ Before starting, ensure you have:
### Option A: GUI Mode (Recommended for getting started)
1. Open FreeCAD
1. Switch to the **MCP Bridge** workbench
1. Switch to the **Robust MCP Bridge** workbench
1. Click **Start Bridge** in the toolbar
1. You should see: "MCP Bridge started! XML-RPC: localhost:9875, Socket: localhost:9876"
@@ -27,7 +27,7 @@ Before starting, ensure you have:
```bash
# If installed via Addon Manager (Linux)
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
# If working from source
just freecad::run-headless
+10 -10
View File
@@ -1,6 +1,6 @@
# Connection Modes
The FreeCAD MCP Server supports multiple ways to connect to FreeCAD. Choose the mode that best fits your workflow.
The FreeCAD Robust MCP Server supports multiple ways to connect to FreeCAD. Choose the mode that best fits your workflow.
---
@@ -21,16 +21,16 @@ XML-RPC mode is the **default and recommended** connection method. It works on a
### How It Works
```text
MCP Client <--stdio--> MCP Server <--XML-RPC:9875--> FreeCAD
MCP Client <--stdio--> Robust MCP Server <--XML-RPC:9875--> FreeCAD
```
The MCP server communicates with FreeCAD via XML-RPC protocol on port 9875.
The Robust MCP Server communicates with FreeCAD via XML-RPC protocol on port 9875.
### Setup
1. Start FreeCAD with the MCP Bridge workbench
1. Start FreeCAD with the Robust MCP Bridge workbench
1. Click **Start Bridge** (or it auto-starts if configured)
1. Configure the MCP server:
1. Configure the Robust MCP Server:
```bash
export FREECAD_MODE=xmlrpc
@@ -41,7 +41,7 @@ freecad-mcp
### Advantages
- Works on all platforms (macOS, Linux, Windows)
- Process isolation (FreeCAD crash doesn't affect MCP server)
- Process isolation (FreeCAD crash doesn't affect Robust MCP Server)
- Supports both GUI and headless FreeCAD
---
@@ -53,7 +53,7 @@ Socket mode uses JSON-RPC over TCP sockets instead of XML-RPC.
### How It Works
```text
MCP Client <--stdio--> MCP Server <--JSON-RPC:9876--> FreeCAD
MCP Client <--stdio--> Robust MCP Server <--JSON-RPC:9876--> FreeCAD
```
### Setup
@@ -78,7 +78,7 @@ freecad-mcp
!!! danger "Platform Limitation"
Embedded mode **only works on Linux**. On macOS and Windows, it causes crashes due to Python ABI incompatibility.
Embedded mode imports FreeCAD directly into the MCP server process, providing the fastest execution.
Embedded mode imports FreeCAD directly into the Robust MCP Server process, providing the fastest execution.
### Why It Crashes on macOS/Windows
@@ -162,7 +162,7 @@ just freecad::run-gui # From source
**Headless Mode:**
```bash
FreeCADCmd /path/to/headless_server.py
FreeCADCmd /path/to/blocking_bridge.py
just freecad::run-headless # From source
```
@@ -176,7 +176,7 @@ just freecad::run-headless # From source
Error: Connection refused on localhost:9875
```
**Solution:** Ensure FreeCAD is running with the MCP Bridge started. Check the bridge status in FreeCAD's toolbar.
**Solution:** Ensure FreeCAD is running with the Robust MCP Bridge started. Check the bridge status in FreeCAD's toolbar.
### Embedded Mode Crash on macOS
+1 -1
View File
@@ -215,4 +215,4 @@ The MCP server excels at helping develop FreeCAD macros. Example workflows:
## Next Steps
- [Tools Reference](tools.md) - Complete API for all MCP tools
- [Workbench](workbench.md) - MCP Bridge Workbench details
- [Workbench](workbench.md) - Robust MCP Bridge Workbench details
+4 -4
View File
@@ -1,6 +1,6 @@
# MCP Resources
The FreeCAD MCP server exposes several resources that allow AI assistants to query FreeCAD's state without executing code.
The FreeCAD Robust MCP Server exposes several resources that allow AI assistants to query FreeCAD's state without executing code.
---
@@ -16,13 +16,13 @@ MCP Resources are read-only endpoints that provide context about FreeCAD's curre
## Available Resources
The MCP server provides 12 resources for querying FreeCAD state:
The Robust MCP Server provides 12 resources for querying FreeCAD state:
### freecad://capabilities
Returns a comprehensive JSON catalog of all available tools, resources, and prompts.
**Use case:** Understanding what the MCP server can do.
**Use case:** Understanding what the Robust MCP Server can do.
**Example response:**
@@ -316,7 +316,7 @@ AI: [Reads freecad://documents resource]
## Implementing Custom Resources
If you're extending the MCP server, you can add custom resources:
If you're extending the Robust MCP Server, you can add custom resources:
```python
@mcp.resource("freecad://custom/{param}")
+3 -3
View File
@@ -1,6 +1,6 @@
# Tools Reference
The FreeCAD MCP server provides 82+ tools for CAD operations. This page provides a quick reference organized by category.
The FreeCAD Robust MCP Server provides 82+ tools for CAD operations. This page provides a quick reference organized by category.
For detailed documentation including parameters and examples, see [MCP Tools Reference](../MCP_TOOLS_REFERENCE.md).
@@ -25,12 +25,12 @@ For detailed documentation including parameters and examples, see [MCP Tools Ref
## Execution Tools
| Tool | Description |
| ---------------------------- | ----------------------------------- |
| ---------------------------- | ------------------------------------------ |
| `execute_python` | Execute arbitrary Python in FreeCAD |
| `get_freecad_version` | Get FreeCAD version and build info |
| `get_connection_status` | Check MCP bridge connection |
| `get_console_output` | Get recent console output |
| `get_mcp_server_environment` | Get MCP server environment info |
| `get_mcp_server_environment` | Get Robust MCP Server environment info |
---
+62 -17
View File
@@ -1,6 +1,6 @@
# MCP Bridge Workbench
# Robust MCP Bridge Workbench
The MCP Bridge Workbench is a FreeCAD addon that provides the server-side connection point for the MCP server. It runs inside FreeCAD and exposes XML-RPC and JSON-RPC interfaces.
The Robust MCP Bridge Workbench is a FreeCAD addon that provides the server-side connection point for the Robust MCP Server. It runs inside FreeCAD and exposes XML-RPC and JSON-RPC interfaces.
---
@@ -22,7 +22,7 @@ The workbench provides:
1. Open FreeCAD
1. Go to **Tools > Addon Manager**
1. Search for "FreeCAD MCP and More" or "MCP Bridge"
1. Search for "FreeCAD MCP and More" or "Robust MCP Bridge"
1. Click **Install**
1. Restart FreeCAD
@@ -68,21 +68,21 @@ Click **Stop Bridge** in the toolbar. The status indicator turns red.
## Headless Mode Usage
The workbench includes a headless server script for running without the FreeCAD GUI.
The workbench includes a blocking bridge script for running in server mode (keeps FreeCAD running).
### Starting Headless Mode
**Linux:**
```bash
freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
```
**macOS:**
```bash
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/headless_server.py
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
```
**Using just commands (from source):**
@@ -123,18 +123,63 @@ Press Ctrl+C to stop.
| Interactive selection | Yes | **No** |
!!! info "GUI-Only Features"
When a GUI-only feature is requested in headless mode, the MCP server returns a structured error response instead of crashing: `{"success": false, "error": "GUI not available - screenshots cannot be captured in headless mode"}`
When a GUI-only feature is requested in headless mode, the Robust MCP Server returns a structured error response instead of crashing: `{"success": false, "error": "GUI not available - screenshots cannot be captured in headless mode"}`
---
## Configuration
The workbench uses default ports that can be customized in the MCP server configuration:
### Workbench Preferences (FreeCAD Side)
| Server | Default Port | Environment Variable |
| ------- | ------------ | --------------------- |
| XML-RPC | 9875 | `FREECAD_XMLRPC_PORT` |
| Socket | 9876 | `FREECAD_SOCKET_PORT` |
The workbench has its own preferences that control how the bridge runs inside FreeCAD. Access them via:
- **Edit → Preferences → Robust MCP Bridge** (in FreeCAD's main Preferences dialog)
- **Robust MCP Bridge → MCP Bridge Preferences...** (from the workbench menu)
| Setting | Description | Default |
| --------------------- | -------------------------------------------- | -------- |
| Auto-start bridge | Start bridge automatically on FreeCAD launch | Disabled |
| Show status indicator | Display status in FreeCAD's status bar | Enabled |
| XML-RPC Port | Port for XML-RPC connections | 9875 |
| Socket Port | Port for JSON-RPC socket connections | 9876 |
!!! note "Port Configuration"
If you change the ports in the workbench preferences while the bridge is running, it will automatically restart with the new configuration.
### MCP Server Configuration (Client Side)
The external Robust MCP Server (used by Claude Code, etc.) is configured separately using environment variables. **These must match the workbench ports:**
| Environment Variable | Description | Default |
| --------------------- | ---------------------------------------------- | ----------- |
| `FREECAD_MODE` | Connection mode: `xmlrpc`, `socket`, `embedded`| `xmlrpc` |
| `FREECAD_XMLRPC_PORT` | XML-RPC server port | 9875 |
| `FREECAD_SOCKET_PORT` | JSON-RPC socket server port | 9876 |
| `FREECAD_SOCKET_HOST` | Socket/XML-RPC server hostname | `localhost` |
Example MCP client configuration with custom ports:
```json
{
"mcpServers": {
"freecad": {
"command": "freecad-mcp",
"env": {
"FREECAD_MODE": "xmlrpc",
"FREECAD_XMLRPC_PORT": "9877"
}
}
}
}
```
!!! info "Choosing a Connection Mode"
- **`xmlrpc`** (recommended): Most reliable, works on all platforms. Connects to FreeCAD via XML-RPC protocol.
- **`socket`**: Alternative protocol using JSON-RPC over TCP sockets. Also works on all platforms.
- **`embedded`**: Direct Python import of FreeCAD (Linux only). Does not require the workbench but crashes on macOS due to library linking issues. Not recommended for production use.
!!! warning "Port Matching Required"
The ports configured in the MCP Server (via environment variables) **must match** the ports configured in the FreeCAD workbench preferences. If they don't match, the server won't be able to connect to FreeCAD.
---
@@ -144,7 +189,7 @@ The workbench uses default ports that can be customized in the MCP server config
┌─────────────────────────────────────────────────────────────┐
│ FreeCAD (GUI or Headless) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ MCP Bridge Workbench/Plugin │ │
│ │ Robust MCP Bridge Workbench/Plugin │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ XML-RPC Server │ │ Socket Server │ │ │
│ │ │ (port 9875) │ │ (port 9876) │ │ │
@@ -168,7 +213,7 @@ The workbench uses default ports that can be customized in the MCP server config
│ Network (localhost)
┌─────────────────────────────────────────────────────────────┐
FreeCAD MCP Server (External Process)
│ FreeCAD Robust MCP Server (External Process) │
└─────────────────────────────────────────────────────────────┘
```
@@ -188,14 +233,14 @@ The workbench uses a **queue-based thread safety system** to ensure FreeCAD oper
1. Ensure no other process is using ports 9875/9876
1. Try restarting FreeCAD
### Connection Refused from MCP Server
### Connection Refused from Robust MCP Server
**Problem:** MCP server reports "Connection refused"
**Problem:** Robust MCP Server reports "Connection refused"
**Solution:**
1. Verify the bridge is running (green status indicator)
1. Check that ports match between workbench and MCP server config
1. Check that ports match between workbench and Robust MCP Server config
1. If using Docker, ensure you're using `host.docker.internal` as the host
### Headless Mode Hangs
+6 -6
View File
@@ -1,6 +1,6 @@
# FreeCAD MCP Server
# FreeCAD Robust MCP Server
Welcome to the FreeCAD MCP Server documentation.
Welcome to the FreeCAD Robust MCP Server documentation.
This project provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that enables integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and [FreeCAD](https://www.freecadweb.org/), allowing AI-assisted development and debugging of 3D models, macros, and workbenches.
@@ -12,20 +12,20 @@ This project provides an [MCP (Model Context Protocol)](https://modelcontextprot
- **Multiple Connection Modes** - XML-RPC (recommended), JSON-RPC socket, or embedded (Linux only)
- **GUI & Headless Support** - Full modeling in headless mode, plus screenshots/colors in GUI mode
- **Macro Development** - Create, edit, run, and template FreeCAD macros via MCP
- **Standalone Macros** - Useful FreeCAD macros that work independently of the MCP server
- **Standalone Macros** - Useful FreeCAD macros that work independently of the Robust MCP Server
---
## Quick Start
```bash
# Install the MCP server
# Install the Robust MCP Server
pip install freecad-robust-mcp
# Install the workbench via FreeCAD Addon Manager
# (search for "FreeCAD MCP and More")
# Start FreeCAD and click "Start Bridge" in the MCP Bridge workbench
# Start FreeCAD and click "Start Bridge" in the Robust MCP Bridge workbench
# Configure your MCP client and start building!
```
@@ -48,7 +48,7 @@ See [Connection Modes](guide/connection-modes.md) for details on choosing the ri
## GUI vs Headless Mode
The MCP server works with FreeCAD in both GUI and headless mode:
The Robust MCP Server works with FreeCAD in both GUI and headless mode:
| Feature | Headless | GUI |
| ------------------------ | -------- | --- |
+2 -2
View File
@@ -1,8 +1,8 @@
# Variables for mkdocs-macros-plugin
# Use in docs with {{ variable_name }}
# Use in docs with {{@ variable_name @}} (custom delimiters to avoid Python dict conflicts)
# Project info
project_name: FreeCAD MCP Server
project_name: FreeCAD Robust MCP Server
package_name: freecad-robust-mcp
docker_image: spkane/freecad-robust-mcp
+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
+123 -62
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
{{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
exit 1
}
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"
# 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 "✗ Error: Response contained an error"
echo " $CONTAINER_OUTPUT" | tail -5
TEST_PASSED=false
fi
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."
+40 -44
View File
@@ -3,18 +3,18 @@
#
# Commands are organized into modules. Run `just` to see top-level commands,
# or `just list-<module>` to see commands in a specific module:
# just list-mcp - MCP server commands
# just list-freecad - FreeCAD plugin/macro commands
# just list-install - Installation commands
# just list-quality - Code quality commands
# just list-testing - Test commands
# just list-coderabbit - AI code review commands
# just list-dev - Development utilities
# just list-docker - Docker build/run commands
# just list-documentation - Documentation commands
# just list-dev - Development utilities
# just list-freecad - FreeCAD plugin/macro commands
# just list-install - Installation commands
# just list-mcp - MCP server commands
# just list-quality - Code quality commands
# just list-release - Release and tagging commands
# just list-coderabbit - AI code review commands
# just list-testing - Test commands
#
# Or use `just --list --list-submodules` to see everything at once.
# Or use `just list-all` to see all commands from all modules at once.
# Import modules
mod coderabbit 'just/coderabbit.just'
@@ -30,7 +30,7 @@ mod testing 'just/testing.just'
# Default recipe - show top-level commands and available modules
default:
@just --list
@just --list --unsorted
# =============================================================================
# Setup & Installation
@@ -44,41 +44,29 @@ setup: (dev::install-deps) (dev::install-pre-commit)
# Combined Workflows
# =============================================================================
# Run all quality checks and unit tests (use before committing)
all: (quality::check) (testing::unit)
@echo "All checks passed!"
# Run all quality checks and unit/coverage tests (use before committing)
all: (quality::check) (testing::cov)
@echo "All checks (minus integration) passed!"
# Run all quality checks and ALL tests including integration
all-with-integration: (quality::check) (testing::unit) (testing::integration-freecad)
all-with-integration: (quality::check) (testing::cov) (testing::integration-freecad-auto)
@echo "All checks and integration tests passed!"
# Full CI pipeline (pre-commit checks + unit tests with coverage)
ci: (quality::check) (testing::cov)
@echo "CI pipeline complete!"
# =============================================================================
# Module Listings (use these to explore available commands)
# =============================================================================
# List MCP server commands
list-mcp:
@just --list mcp
# List ALL commands from all modules
list-all:
@just --list --list-submodules
# List FreeCAD plugin and macro commands
list-freecad:
@just --list freecad
# List AI code review commands
list-coderabbit:
@just --list coderabbit
# List installation commands
list-install:
@just --list install
# List code quality commands
list-quality:
@just --list quality
# List testing commands
list-testing:
@just --list testing
# List development utility commands
list-dev:
@just --list dev
# List Docker build/run commands
list-docker:
@@ -88,18 +76,26 @@ list-docker:
list-documentation:
@just --list documentation
# List development utility commands
list-dev:
@just --list dev
# List FreeCAD plugin and macro commands
list-freecad:
@just --list freecad
# List installation commands
list-install:
@just --list install
# List MCP server commands
list-mcp:
@just --list mcp
# List code quality commands
list-quality:
@just --list quality
# List release and tagging commands
list-release:
@just --list release
# List AI code review commands
list-coderabbit:
@just --list coderabbit
# List ALL commands from all modules
list-all:
@just --list --list-submodules
# List testing commands
list-testing:
@just --list testing
+3 -3
View File
@@ -1,5 +1,5 @@
site_name: FreeCAD MCP Server
site_description: MCP server for FreeCAD integration with AI assistants
site_name: FreeCAD Robust MCP Server
site_description: Robust MCP Server for FreeCAD integration with AI assistants
site_url: https://github.com/spkane/freecad-robust-mcp-and-more
repo_url: https://github.com/spkane/freecad-robust-mcp-and-more
repo_name: spkane/freecad-robust-mcp-and-more
@@ -122,7 +122,7 @@ nav:
- Quick Start: getting-started/quickstart.md
- User Guide:
- Connection Modes: guide/connection-modes.md
- MCP Bridge Workbench: guide/workbench.md
- Robust MCP Bridge Workbench: guide/workbench.md
- FreeCAD Macros: guide/macros.md
- Tools Overview: guide/tools.md
- MCP Resources: guide/resources.md
+2 -2
View File
@@ -36,10 +36,10 @@
<content>
<workbench>
<name>MCP Bridge</name>
<name>Robust MCP Bridge</name>
<version>0.5.0-beta</version>
<date>2026-01-07</date>
<description>MCP (Model Context Protocol) bridge workbench for AI assistant integration with FreeCAD. Works in both GUI mode (with full visual features including screenshots, colors, and camera control) and headless mode (for automation and CI/CD). Provides toolbar commands to start, stop, and monitor the MCP bridge server. Supports XML-RPC and JSON-RPC protocols. Connect Claude Code, Cursor, or other MCP-compatible AI assistants to control FreeCAD programmatically with 82+ CAD tools.</description>
<description>Robust MCP Bridge workbench for AI assistant integration with FreeCAD. Uses the Model Context Protocol (MCP) to connect AI assistants like Claude Code and Cursor to FreeCAD. Works in both GUI mode (with full visual features including screenshots, colors, and camera control) and headless mode (for automation and CI/CD). Provides toolbar commands to start, stop, and monitor the MCP bridge server. Supports XML-RPC and JSON-RPC protocols with 82+ CAD tools.</description>
<classname>FreecadRobustMCPWorkbench</classname>
<subdirectory>./addon/FreecadRobustMCP/</subdirectory>
<icon>FreecadRobustMCP.svg</icon>
+12 -1
View File
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "freecad-robust-mcp"
dynamic = ["version"]
description = "MCP (Model Context Protocol) server for FreeCAD integration with Claude Code and other AI assistants"
description = "Robust MCP Server for FreeCAD integration with Claude Code and other AI assistants"
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
@@ -125,6 +125,8 @@ target-version = "py311"
line-length = 88
src = ["src", "tests", "macros"]
extend-include = ["*.FCMacro"]
# Exclude auto-generated files (hatch-vcs version file)
exclude = ["src/freecad_mcp/_version.py"]
[tool.ruff.lint]
select = [
@@ -287,6 +289,15 @@ markers = [
source = ["src/freecad_mcp"]
branch = true
parallel = true
# Exclude bridge modules from unit test coverage - they require a running
# FreeCAD instance. These modules are exercised by integration tests which
# run separately (just testing::integration). To verify bridge coverage:
# uv run pytest tests/integration --cov=freecad_mcp.bridge --cov-report=term-missing
omit = [
"src/freecad_mcp/bridge/embedded.py",
"src/freecad_mcp/bridge/socket.py",
"src/freecad_mcp/bridge/xmlrpc.py",
]
[tool.coverage.report]
exclude_lines = [
+2 -2
View File
@@ -1,4 +1,4 @@
"""FreeCAD MCP Server - AI assistant integration for FreeCAD.
"""FreeCAD Robust MCP Server - AI assistant integration for FreeCAD.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
@@ -8,7 +8,7 @@ integration between AI assistants (Claude, GPT, etc.) and FreeCAD, allowing
AI-assisted development and debugging of 3D models, macros, and workbenches.
Example:
Run the MCP server::
Run the Robust MCP Server::
$ freecad-mcp
+2 -2
View File
@@ -2,7 +2,7 @@
This module defines the abstract base class and data types for all FreeCAD
bridge implementations. Bridges provide the communication layer between
the MCP server and FreeCAD instances.
the Robust MCP Server and FreeCAD instances.
Based on learnings from existing implementations:
- neka-nat: Queue-based thread safety for GUI operations
@@ -227,7 +227,7 @@ class ConnectionStatus:
class FreecadBridge(ABC):
"""Abstract base class for FreeCAD bridges.
A bridge provides communication between the MCP server and a FreeCAD
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.
+2 -2
View File
@@ -1,6 +1,6 @@
"""Embedded bridge - runs FreeCAD in-process.
This bridge imports FreeCAD directly into the MCP server 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:
@@ -33,7 +33,7 @@ from freecad_mcp.bridge.base import (
class EmbeddedBridge(FreecadBridge):
"""Bridge that runs FreeCAD embedded in the MCP server process.
"""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).
+3 -3
View File
@@ -120,10 +120,10 @@ The FreeCAD MCP bridge server is not running. To fix this:
2. Start the MCP bridge using one of these methods:
Option A: Using the MCP Bridge Workbench (recommended)
Option A: Using the Robust MCP Bridge Workbench (recommended)
- Install via FreeCAD Addon Manager: Tools Addon Manager
- Search for "MCP Bridge" and install
- Switch to the MCP Bridge workbench
- 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)
+3 -3
View File
@@ -1,6 +1,6 @@
"""Configuration management for FreeCAD MCP Server.
"""Configuration management for FreeCAD Robust MCP Server.
This module handles all configuration settings for the MCP server,
This module handles all configuration settings for the Robust MCP Server,
including FreeCAD connection settings, execution limits, and logging.
"""
@@ -28,7 +28,7 @@ class TransportType(str, Enum):
class ServerConfig(BaseSettings):
"""Configuration for the FreeCAD MCP server.
"""Configuration for the FreeCAD Robust MCP Server.
Settings are loaded from environment variables with the FREECAD_ prefix.
For example, FREECAD_MODE sets the mode field.
+3 -9
View File
@@ -11,20 +11,14 @@ Prompt Categories:
- Troubleshooting: Common issues and solutions
"""
from collections.abc import Callable, Coroutine
from typing import Any
from mcp.server.fastmcp import FastMCP
def register_prompts(
mcp: FastMCP,
get_bridge: Callable[[], Coroutine[Any, Any, Any]], # noqa: ARG001
) -> None:
"""Register FreeCAD prompts with the MCP server.
def register_prompts(mcp: Any, get_bridge: Any) -> None: # noqa: ARG001
"""Register FreeCAD prompts with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge (unused but kept
for interface consistency with other register functions).
"""
+6 -6
View File
@@ -23,11 +23,11 @@ import json
from typing import Any
def register_resources(mcp, get_bridge) -> None:
"""Register FreeCAD resources with the MCP server.
def register_resources(mcp: Any, get_bridge: Any) -> None:
"""Register FreeCAD resources with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge.
"""
@@ -316,7 +316,7 @@ def register_resources(mcp, get_bridge) -> None:
This resource provides a complete catalog of all available tools,
resources, and prompts. Use this to discover what functionality
is available when working with the FreeCAD MCP server.
is available when working with the FreeCAD Robust MCP Server.
Returns:
JSON string containing:
@@ -326,7 +326,7 @@ def register_resources(mcp, get_bridge) -> None:
- examples: Common usage patterns
"""
capabilities = {
"description": "FreeCAD MCP Server - Control FreeCAD via Model Context Protocol",
"description": "FreeCAD Robust MCP Server - Control FreeCAD via Model Context Protocol",
"tools": {
"execution": {
"description": "Execute Python code and access console",
@@ -358,7 +358,7 @@ def register_resources(mcp, get_bridge) -> None:
},
{
"name": "get_mcp_server_environment",
"description": "Get MCP server environment info (instance_id, OS, hostname, Docker detection)",
"description": "Get Robust MCP Server environment info (instance_id, OS, hostname, FreeCAD connection)",
"key_params": [],
},
],
+238 -6
View File
@@ -1,6 +1,6 @@
"""FreeCAD MCP Server - Main entry point.
"""FreeCAD Robust MCP Server - Main entry point.
This module provides the main MCP server implementation for FreeCAD
This module provides the main Robust MCP Server implementation for FreeCAD
integration with AI assistants (Claude, GPT, and other MCP-compatible tools).
It exposes tools, resources, and prompts for interacting with FreeCAD.
@@ -25,8 +25,13 @@ Example:
With environment variables::
$ FREECAD_MODE=socket FREECAD_SOCKET_HOST=localhost freecad-mcp
Show help::
$ freecad-mcp --help
"""
import argparse
import logging
import sys
import uuid
@@ -54,7 +59,7 @@ _bridge: Any = None
def get_instance_id() -> str:
"""Get the unique instance ID for this MCP server process.
"""Get the unique instance ID for this Robust MCP Server process.
Returns:
The UUID string that uniquely identifies this server instance.
@@ -149,7 +154,7 @@ async def lifespan(_server: FastMCP) -> AsyncIterator[None]:
_bridge = None
# Create the MCP server instance with lifespan
# Create the Robust MCP Server instance with lifespan
mcp = FastMCP(
name="freecad-mcp",
lifespan=lifespan,
@@ -178,8 +183,229 @@ def register_all_components() -> None:
register_all_components()
async def check_freecad_connection(
mode: str | None = None, host: str | None = None, port: int | None = None
) -> bool:
"""Test FreeCAD bridge connection.
Args:
mode: Connection mode override (xmlrpc, socket, embedded).
host: Host override for connection.
port: Port override for connection.
Returns:
True if connection successful, False otherwise.
"""
import os
# Apply overrides to env
if mode:
os.environ["FREECAD_MODE"] = mode
if host:
os.environ["FREECAD_SOCKET_HOST"] = host
if port:
mode_val = mode or os.environ.get("FREECAD_MODE", "xmlrpc")
if mode_val == "xmlrpc":
os.environ["FREECAD_XMLRPC_PORT"] = str(port)
else:
os.environ["FREECAD_SOCKET_PORT"] = str(port)
config = get_config()
print(f"Testing connection to FreeCAD ({config.mode.value} mode)...")
try:
bridge: FreecadBridge
if config.mode == FreecadMode.EMBEDDED:
from freecad_mcp.bridge.embedded import EmbeddedBridge
bridge = EmbeddedBridge(
freecad_path=(
str(config.freecad_path) if config.freecad_path else None
),
)
elif config.mode == FreecadMode.XMLRPC:
from freecad_mcp.bridge.xmlrpc import XmlRpcBridge
bridge = XmlRpcBridge(
host=config.socket_host,
port=config.xmlrpc_port,
)
print(f" Host: {config.socket_host}:{config.xmlrpc_port}")
else:
from freecad_mcp.bridge.socket import SocketBridge
bridge = SocketBridge(
host=config.socket_host,
port=config.socket_port,
)
print(f" Host: {config.socket_host}:{config.socket_port}")
await bridge.connect()
version_info = await bridge.get_freecad_version()
await bridge.disconnect()
print("✓ Connection successful!")
print(f" FreeCAD version: {version_info.get('version', 'unknown')}")
print(f" GUI available: {version_info.get('gui_available', 'unknown')}")
return True
except Exception as e:
print(f"✗ Connection failed: {e}")
return False
def apply_cli_args_to_env(args: argparse.Namespace) -> None:
"""Apply CLI arguments as environment variables.
CLI arguments override existing environment variables.
Args:
args: Parsed command-line arguments.
"""
import os
if args.mode:
os.environ["FREECAD_MODE"] = args.mode
if args.transport:
os.environ["FREECAD_TRANSPORT"] = args.transport
if args.host:
os.environ["FREECAD_SOCKET_HOST"] = args.host
if args.port:
# Set appropriate port based on mode
mode = os.environ.get("FREECAD_MODE", "xmlrpc")
if mode == "xmlrpc":
os.environ["FREECAD_XMLRPC_PORT"] = str(args.port)
else:
os.environ["FREECAD_SOCKET_PORT"] = str(args.port)
if args.http_port:
os.environ["FREECAD_HTTP_PORT"] = str(args.http_port)
if args.log_level:
os.environ["FREECAD_LOG_LEVEL"] = args.log_level
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments.
Returns:
Parsed arguments namespace.
"""
parser = argparse.ArgumentParser(
prog="freecad-mcp",
description="FreeCAD Robust MCP Server - Connect AI assistants to FreeCAD",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Environment Variables:
FREECAD_MODE Connection mode: xmlrpc, socket, or embedded
(default: xmlrpc)
FREECAD_SOCKET_HOST Host for socket/XML-RPC connection (default: localhost)
FREECAD_SOCKET_PORT Port for socket connection (default: 9876)
FREECAD_XMLRPC_PORT Port for XML-RPC connection (default: 9875)
FREECAD_TRANSPORT Transport type: stdio or http (default: stdio)
FREECAD_HTTP_PORT Port for HTTP transport (default: 8000)
FREECAD_LOG_LEVEL Logging level: DEBUG, INFO, WARNING, ERROR
(default: INFO)
Examples:
# Start with default settings (XML-RPC mode, stdio transport)
freecad-mcp
# Use socket mode
FREECAD_MODE=socket freecad-mcp
# Use HTTP transport for remote access
FREECAD_TRANSPORT=http FREECAD_HTTP_PORT=8080 freecad-mcp
# Connect to remote FreeCAD instance
FREECAD_SOCKET_HOST=192.168.1.100 freecad-mcp
Prerequisites:
The FreeCAD MCP Bridge must be running before starting this server.
Start it via:
- FreeCAD GUI: Install Robust MCP Bridge workbench, enable auto-start
- Headless: just freecad::run-headless
- Development: just freecad::run-gui
""",
)
parser.add_argument(
"--version",
action="store_true",
help="Show version information and exit",
)
parser.add_argument(
"--check",
action="store_true",
help="Test FreeCAD connection and exit (doesn't start MCP server)",
)
parser.add_argument(
"--mode",
choices=["xmlrpc", "socket", "embedded"],
help="Connection mode (overrides FREECAD_MODE env var)",
)
parser.add_argument(
"--transport",
choices=["stdio", "http"],
help="Transport type (overrides FREECAD_TRANSPORT env var)",
)
parser.add_argument(
"--host",
help="Host for FreeCAD connection (overrides FREECAD_SOCKET_HOST)",
)
parser.add_argument(
"--port",
type=int,
help="Port for FreeCAD connection (mode-dependent)",
)
parser.add_argument(
"--http-port",
type=int,
help="Port for HTTP transport (overrides FREECAD_HTTP_PORT)",
)
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Logging level (overrides FREECAD_LOG_LEVEL)",
)
return parser.parse_args()
def main() -> None:
"""Run the FreeCAD MCP server."""
"""Run the FreeCAD Robust MCP Server."""
# Parse arguments first - this handles --help without connecting to FreeCAD
args = parse_args()
# Handle --version
if args.version:
try:
from importlib.metadata import version
ver = version("freecad-mcp")
except Exception:
ver = "unknown"
print(f"freecad-mcp {ver}")
print(f"Instance ID: {INSTANCE_ID}")
sys.exit(0)
# Handle --check (test connection without starting MCP server)
if args.check:
import asyncio
success = asyncio.run(
check_freecad_connection(mode=args.mode, host=args.host, port=args.port)
)
sys.exit(0 if success else 1)
# Apply CLI arguments as environment variables (they override existing ones)
apply_cli_args_to_env(args)
# Now get config (which reads from environment)
config = get_config()
# Set up logging
@@ -189,7 +415,7 @@ def main() -> None:
# 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("Starting FreeCAD Robust MCP Server")
logger.info("Instance ID: %s", INSTANCE_ID)
logger.info("Mode: %s", config.mode.value)
logger.info("Transport: %s", config.transport.value)
@@ -204,6 +430,12 @@ def main() -> None:
)
else:
logger.info("Starting stdio transport")
logger.info(
"Waiting for MCP client connection (FreeCAD connection tested on first request)..."
)
logger.info(
"Tip: Use 'freecad-mcp --check' to test FreeCAD connection directly"
)
mcp.run()
+7 -4
View File
@@ -12,6 +12,9 @@ Tools are organized by category:
- view: View and screenshot tools
"""
from collections.abc import Awaitable, Callable
from typing import Any
from freecad_mcp.tools.documents import register_document_tools
from freecad_mcp.tools.execution import register_execution_tools
from freecad_mcp.tools.export import register_export_tools
@@ -32,12 +35,12 @@ __all__ = [
]
def register_all_tools(mcp, get_bridge_func) -> None:
"""Register all FreeCAD tools with the MCP server.
def register_all_tools(mcp: Any, get_bridge_func: Callable[[], Awaitable[Any]]) -> None:
"""Register all FreeCAD tools with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
get_bridge_func: Async function to get the active bridge.
mcp: The FastMCP (Robust MCP Server) instance (Any due to lack of stubs).
get_bridge_func: Async function returning the active bridge connection.
"""
register_execution_tools(mcp, get_bridge_func)
register_document_tools(mcp, get_bridge_func)
+5 -4
View File
@@ -1,17 +1,18 @@
"""Document management tools for FreeCAD MCP server.
"""Document management tools for FreeCAD Robust MCP Server.
This module provides tools for managing FreeCAD documents:
creating, opening, saving, closing, and listing documents.
"""
from collections.abc import Awaitable, Callable
from typing import Any
def register_document_tools(mcp, get_bridge) -> None:
"""Register document-related tools with the MCP server.
def register_document_tools(mcp: Any, get_bridge: Callable[[], Awaitable[Any]]) -> None:
"""Register document-related tools with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge.
"""
+12 -61
View File
@@ -1,4 +1,4 @@
"""Execution tools for FreeCAD MCP server.
"""Execution tools for FreeCAD Robust MCP Server.
This module provides tools for executing Python code in FreeCAD's context,
getting version information, and accessing the console.
@@ -7,17 +7,19 @@ getting version information, and accessing the console.
import os
import platform
import socket
from pathlib import Path
from collections.abc import Awaitable, Callable
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.
def register_execution_tools(
mcp: Any, get_bridge: Callable[[], Awaitable[Any]]
) -> None:
"""Register execution-related tools with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge.
"""
@@ -132,12 +134,12 @@ 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 and FreeCAD connection.
"""Get environment info about the MCP Server and FreeCAD connection.
This tool returns information about the environment where the MCP server
This tool returns information about the environment where the MCP Server
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.
verifying which MCP Server instance you are connected to, and determining
if GUI features are available.
Returns:
Dictionary containing environment information:
@@ -148,9 +150,7 @@ def register_execution_tools(mcp, get_bridge) -> None:
- os_name: Operating system name (Linux, Darwin, Windows)
- os_version: Operating system version
- platform: Platform identifier string
- python_version: Python version running the MCP server
- in_docker: Whether running inside a Docker container
- docker_container_id: Container ID if in Docker (first 12 chars)
- python_version: Python version running the MCP Server
- freecad: FreeCAD connection information:
- connected: Whether bridge is connected to FreeCAD
- mode: Connection mode (embedded, xmlrpc, socket)
@@ -177,54 +177,7 @@ def register_execution_tools(mcp, get_bridge) -> None:
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()
if env["in_docker"]:
print(f"Connected to container: {env['docker_container_id']}")
else:
print("Connected to host MCP server")
"""
def _detect_docker() -> tuple[bool, str | None]:
"""Detect if running inside Docker and get container ID."""
# Check for .dockerenv file (most reliable)
dockerenv = Path("/.dockerenv")
if dockerenv.exists():
# Try to get container ID from cgroup
container_id = None
try:
cgroup_path = Path("/proc/self/cgroup")
with cgroup_path.open() as f:
for line in f:
if "docker" in line or "containerd" in line:
# Extract container ID from path
parts = line.strip().split("/")
if parts:
cid = parts[-1]
# Container IDs are 64 hex chars
if len(cid) >= 12:
container_id = cid[:12]
break
except (OSError, IndexError):
pass
return True, container_id
# Also check cgroup for containerized environments
try:
cgroup_init = Path("/proc/1/cgroup")
with cgroup_init.open() as f:
content = f.read()
if "docker" in content or "containerd" in content:
return True, None
except OSError:
pass
return False, None
in_docker, container_id = _detect_docker()
# Get FreeCAD connection status
bridge = await get_bridge()
status = await bridge.get_status()
@@ -236,8 +189,6 @@ def register_execution_tools(mcp, get_bridge) -> None:
"os_version": platform.release(),
"platform": platform.platform(),
"python_version": platform.python_version(),
"in_docker": in_docker,
"docker_container_id": container_id,
"freecad": {
"connected": status.connected,
"mode": status.mode,
+47 -71
View File
@@ -1,18 +1,52 @@
"""Export tools for FreeCAD MCP server.
"""Export tools for FreeCAD Robust MCP Server.
This module provides tools for exporting FreeCAD documents and objects
to various file formats: STEP, STL, 3MF, OBJ, IGES, and FreeCAD native.
"""
from collections.abc import Awaitable, Callable
from typing import Any
def register_export_tools(mcp, get_bridge) -> None:
"""Register export-related tools with the MCP server.
def _build_object_selection_code(object_names: list[str] | None) -> str:
"""Generate Python code for GUI-aware object selection.
This helper eliminates code duplication across export functions by
generating the common object selection logic that handles both GUI
and headless modes appropriately.
Args:
mcp: The FastMCP server instance.
get_bridge: Async function to get the active bridge.
object_names: Optional list of specific object names to select.
Returns:
Python code string for object selection logic.
"""
return f"""
# Get objects to export
if {object_names!r} is not None:
objects = [doc.getObject(n) for n in {object_names!r}]
elif FreeCAD.GuiUp:
# GUI mode: export visible objects with shapes
objects = [
obj for obj in doc.Objects
if hasattr(obj, 'Shape') and obj.ViewObject and obj.ViewObject.Visibility
]
else:
# Headless mode: export all objects with shapes
objects = [obj for obj in doc.Objects if hasattr(obj, 'Shape')]
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
"""
def register_export_tools(mcp: Any, get_bridge: Callable[[], Awaitable[Any]]) -> None:
"""Register export-related tools with the Robust MCP Server.
Args:
mcp: The FastMCP (Robust MCP Server) instance (Any due to lack of stubs).
get_bridge: Async function returning the active bridge connection.
"""
@mcp.tool()
@@ -39,25 +73,13 @@ def register_export_tools(mcp, get_bridge) -> None:
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
{_build_object_selection_code(object_names)}
# Combine shapes
if len(objects) == 1:
shape = objects[0].Shape
@@ -103,12 +125,6 @@ _result_ = {{
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Mesh
import Part
@@ -116,13 +132,7 @@ import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
{_build_object_selection_code(object_names)}
# Create mesh from shapes
meshes = []
for obj in objects:
@@ -178,12 +188,6 @@ _result_ = {{
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Mesh
import Part
@@ -191,13 +195,7 @@ import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
{_build_object_selection_code(object_names)}
# Create mesh from shapes
meshes = []
for obj in objects:
@@ -232,6 +230,7 @@ _result_ = {{
file_path: str,
object_names: list[str] | None = None,
doc_name: str | None = None,
mesh_tolerance: float = 0.1,
) -> dict[str, Any]:
"""Export objects to OBJ format.
@@ -242,6 +241,7 @@ _result_ = {{
file_path: Path for the output .obj file.
object_names: List of object names to export. Exports all visible if None.
doc_name: Document to export from. Uses active document if None.
mesh_tolerance: Mesh approximation tolerance. Lower = finer mesh.
Returns:
Dictionary with export result:
@@ -251,30 +251,18 @@ _result_ = {{
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Mesh
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
{_build_object_selection_code(object_names)}
# Create mesh from shapes
meshes = []
for obj in objects:
mesh = Mesh.Mesh()
mesh.addFacets(obj.Shape.tessellate(0.1)[0])
mesh.addFacets(obj.Shape.tessellate({mesh_tolerance})[0])
meshes.append(mesh)
# Combine meshes
@@ -322,25 +310,13 @@ _result_ = {{
"""
bridge = await get_bridge()
objects_filter = (
f"[doc.getObject(n) for n in {object_names!r}]"
if object_names
else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]"
)
code = f"""
import Part
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
if doc is None:
raise ValueError("No document found")
objects = {objects_filter}
objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')]
if not objects:
raise ValueError("No exportable objects found")
{_build_object_selection_code(object_names)}
# Combine shapes
if len(objects) == 1:
shape = objects[0].Shape
+5 -4
View File
@@ -1,4 +1,4 @@
"""Macro management tools for FreeCAD MCP server.
"""Macro management tools for FreeCAD Robust MCP Server.
This module provides tools for managing FreeCAD macros:
listing, running, creating, and editing macros.
@@ -7,14 +7,15 @@ Based on learnings from ATOI-Ming/FreeCAD-MCP which has a
macro-centric workflow with templates and validation.
"""
from collections.abc import Awaitable, Callable
from typing import Any
def register_macro_tools(mcp, get_bridge) -> None:
"""Register macro-related tools with the MCP server.
def register_macro_tools(mcp: Any, get_bridge: Callable[[], Awaitable[Any]]) -> None:
"""Register macro-related tools with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge.
"""
+5 -4
View File
@@ -1,17 +1,18 @@
"""Object management tools for FreeCAD MCP server.
"""Object management tools for FreeCAD Robust MCP Server.
This module provides tools for managing FreeCAD objects:
creating, editing, deleting, and inspecting objects.
"""
from collections.abc import Awaitable, Callable
from typing import Any
def register_object_tools(mcp, get_bridge) -> None:
"""Register object-related tools with the MCP server.
def register_object_tools(mcp: Any, get_bridge: Callable[[], Awaitable[Any]]) -> None:
"""Register object-related tools with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge.
"""
+7 -4
View File
@@ -1,4 +1,4 @@
"""PartDesign tools for FreeCAD MCP server.
"""PartDesign tools for FreeCAD Robust MCP Server.
This module provides tools for the PartDesign workbench, enabling
parametric solid modeling operations like Pad, Pocket, Fillet, etc.
@@ -7,14 +7,17 @@ Based on learnings from contextform/freecad-mcp which has the most
comprehensive PartDesign coverage.
"""
from collections.abc import Awaitable, Callable
from typing import Any
def register_partdesign_tools(mcp, get_bridge) -> None:
"""Register PartDesign-related tools with the MCP server.
def register_partdesign_tools(
mcp: Any, get_bridge: Callable[[], Awaitable[Any]]
) -> None:
"""Register PartDesign-related tools with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge.
"""
+5 -4
View File
@@ -1,18 +1,19 @@
"""View and screenshot tools for FreeCAD MCP server.
"""View and screenshot tools for FreeCAD Robust MCP Server.
This module provides tools for controlling the 3D view and
capturing screenshots. Based on learnings from neka-nat which
has excellent screenshot handling with view type detection.
"""
from collections.abc import Awaitable, Callable
from typing import Any
def register_view_tools(mcp, get_bridge) -> None:
"""Register view-related tools with the MCP server.
def register_view_tools(mcp: Any, get_bridge: Callable[[], Awaitable[Any]]) -> None:
"""Register view-related tools with the Robust MCP Server.
Args:
mcp: The FastMCP server instance.
mcp: The FastMCP (Robust MCP Server) instance.
get_bridge: Async function to get the active bridge.
"""
+1 -1
View File
@@ -1,4 +1,4 @@
"""Utility modules for FreeCAD MCP server.
"""Utility modules for FreeCAD Robust MCP Server.
This package contains shared utilities for serialization, validation, etc.
"""
+89
View File
@@ -0,0 +1,89 @@
# syntax=docker/dockerfile:1.7
#
# Dockerfile for testing FreeCAD GUI with Xvfb
# Replicates the GitHub Actions CI environment for local debugging
#
# Build:
# docker build -f tests/ci-test/Dockerfile.gui-test -t freecad-gui-test .
#
# Run interactively:
# docker run --rm -it freecad-gui-test
#
# Run with local code mounted:
# docker run --rm -it -v $(pwd):/workspace freecad-gui-test
FROM ubuntu:22.04
# Avoid interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install X11, Xvfb, and dependencies (matching CI workflow)
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
# Xvfb and X11
xvfb \
libxkbcommon-x11-0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-xinerama0 \
libxcb-xfixes0 \
libxcb-shape0 \
libxcb-cursor0 \
x11-utils \
libegl1 \
libgl1-mesa-dri \
libgl1 \
mesa-utils \
fontconfig \
fonts-dejavu-core \
# Window manager and X11 tools (required for FreeCAD GUI)
openbox \
xdotool \
# Tools for debugging
curl \
jq \
procps \
strace \
psmisc \
file \
# Python (for uv/pytest)
python3 \
python3-pip \
python3-venv \
# For downloading FreeCAD
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& fc-cache -f -v
# Install uv for Python package management
# hadolint ignore=DL3013
RUN pip3 install --no-cache-dir uv
# Set up working directory
WORKDIR /workspace
# Environment variables for GUI testing
ENV DISPLAY=:99 \
QT_QPA_PLATFORM=xcb \
LIBGL_ALWAYS_SOFTWARE=1 \
FONTCONFIG_FILE=/etc/fonts/fonts.conf \
FONTCONFIG_PATH=/etc/fonts \
FREECAD_TAG=1.0.2
# Script to setup FreeCAD AppImage
COPY tests/ci-test/setup-freecad.sh /usr/local/bin/setup-freecad.sh
RUN chmod +x /usr/local/bin/setup-freecad.sh
# Script to run GUI tests
COPY tests/ci-test/run-gui-test.sh /usr/local/bin/run-gui-test.sh
RUN chmod +x /usr/local/bin/run-gui-test.sh
# Download and setup FreeCAD during build (for faster iteration)
# hadolint ignore=DL3001,DL3059
RUN /usr/local/bin/setup-freecad.sh
# Default: interactive shell for debugging
CMD ["/bin/bash"]
+290
View File
@@ -0,0 +1,290 @@
#!/bin/bash
# Run GUI tests with Xvfb + openbox - for testing FreeCAD GUI functionality
#
# FreeCAD GUI requires a window manager to generate expose/configure events.
# Without a WM, the GUI binary hangs during Qt initialization.
#
# Solution: Xvfb + openbox + xdotool events
#
set -e
echo "========================================"
echo "FreeCAD GUI Test Runner"
echo "========================================"
# Setup XDG_RUNTIME_DIR to avoid Qt warnings
export XDG_RUNTIME_DIR=/tmp/runtime-root
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
# Start Xvfb if not already running
if ! pgrep -x Xvfb > /dev/null; then
echo "Starting Xvfb..."
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
XVFB_PID=$!
sleep 2
if ! ps -p $XVFB_PID > /dev/null; then
echo "ERROR: Xvfb failed to start"
exit 1
fi
echo "Xvfb started on display :99 (PID: $XVFB_PID)"
else
echo "Xvfb already running"
fi
export DISPLAY=:99
export QT_QPA_PLATFORM=xcb
export LIBGL_ALWAYS_SOFTWARE=1
# Start openbox window manager (required for FreeCAD GUI)
if ! pgrep -x openbox > /dev/null; then
echo "Starting openbox window manager..."
openbox &
sleep 1
echo "openbox started"
else
echo "openbox already running"
fi
echo ""
echo "=== Environment ==="
echo "DISPLAY=$DISPLAY"
echo "QT_QPA_PLATFORM=$QT_QPA_PLATFORM"
echo "LIBGL_ALWAYS_SOFTWARE=$LIBGL_ALWAYS_SOFTWARE"
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
echo ""
echo "=== Test 1: Verify X11 display ==="
xdpyinfo -display :99 | head -5 || echo "Cannot connect to display :99"
echo ""
echo "=== Test 2: freecadcmd --version (headless) ==="
timeout 30 freecadcmd --version 2>&1 || echo "freecadcmd version check failed"
echo ""
echo "=== Test 3: FreeCAD GUI mode with openbox ==="
# Helper function to run FreeCAD GUI with xdotool events
run_freecad_gui() {
local SCRIPT="$1"
local TIMEOUT="${2:-30}"
# Start FreeCAD
if [ -n "$SCRIPT" ]; then
freecad "$SCRIPT" > /tmp/fc_stdout.log 2>&1 &
else
freecad --version > /tmp/fc_stdout.log 2>&1 &
fi
local FC_PID
FC_PID=$!
# Send xdotool events to help initialization
local START
START=$(date +%s)
while ps -p "$FC_PID" > /dev/null 2>&1; do
local NOW
NOW=$(date +%s)
local ELAPSED=$((NOW - START))
if [ "$ELAPSED" -gt "$TIMEOUT" ]; then
echo "Timeout after ${TIMEOUT}s"
kill "$FC_PID" 2>/dev/null || true
return 124
fi
# Send synthetic events
xdotool mousemove $((400 + ELAPSED*5)) $((300 + ELAPSED*5)) click 1 key Escape 2>/dev/null || true
sleep 0.5
done
wait "$FC_PID" 2>/dev/null
return $?
}
# Test basic GUI initialization
cat > /tmp/gui_test.py << 'PYEOF'
import FreeCAD
import sys
with open("/tmp/gui_result.txt", "w") as f:
f.write(f"GuiUp: {FreeCAD.GuiUp}\n")
f.write(f"Version: {FreeCAD.Version()}\n")
if FreeCAD.GuiUp:
import FreeCADGui
f.write("GUI is available!\n")
f.write(f"Workbenches: {len(FreeCADGui.listWorkbenches())}\n")
else:
f.write("GUI is NOT available\n")
sys.exit(0)
PYEOF
echo "Running GUI initialization test..."
run_freecad_gui /tmp/gui_test.py 30 || true # FreeCAD may crash on exit, check result file
echo ""
echo "=== GUI Test Result ==="
if [ -f /tmp/gui_result.txt ]; then
cat /tmp/gui_result.txt
if grep -q "GUI is available" /tmp/gui_result.txt; then
echo "GUI initialization test PASSED"
else
echo "GUI initialization test FAILED - GUI not available"
fi
else
echo "No result file (test failed to run)"
fi
echo ""
echo "=== Test 4: Full GUI functionality (screenshot, objects, etc.) ==="
cat > /tmp/full_gui_test.py << 'PYEOF'
import FreeCAD
import sys
results = []
try:
results.append(f"GuiUp: {FreeCAD.GuiUp}")
if not FreeCAD.GuiUp:
results.append("ERROR: GUI not available")
with open("/tmp/full_gui_result.txt", "w") as f:
f.write("\n".join(results))
sys.exit(1)
import FreeCADGui
import Part
# Create a document with objects
doc = FreeCAD.newDocument("GUITest")
results.append(f"Document: {doc.Name}")
# Create objects
box = doc.addObject("Part::Box", "TestBox")
box.Length = 50
box.Width = 30
box.Height = 20
doc.recompute()
results.append(f"Created: {box.Name} ({box.Length}x{box.Width}x{box.Height})")
# Test view operations
view = FreeCADGui.ActiveDocument.ActiveView
results.append(f"ActiveView: {type(view).__name__}")
FreeCADGui.SendMsgToActiveView("ViewFit")
view.viewIsometric()
results.append("ViewFit + Isometric: OK")
# Screenshot
view.saveImage("/tmp/screenshot.png", 800, 600, "Current")
results.append("Screenshot: /tmp/screenshot.png")
# ViewObject manipulation
box_view = FreeCADGui.ActiveDocument.getObject("TestBox")
if box_view:
box_view.ShapeColor = (1.0, 0.0, 0.0)
results.append("ShapeColor set to red: OK")
# Save document
doc.saveAs("/tmp/gui_test.FCStd")
results.append("Document saved: /tmp/gui_test.FCStd")
results.append("ALL TESTS PASSED")
except Exception as e:
results.append(f"ERROR: {e}")
import traceback
results.append(traceback.format_exc())
with open("/tmp/full_gui_result.txt", "w") as f:
f.write("\n".join(results))
sys.exit(0)
PYEOF
echo "Running full GUI test..."
run_freecad_gui /tmp/full_gui_test.py 45 || true # FreeCAD may crash on exit, check result file
echo ""
echo "=== Full GUI Test Result ==="
if [ -f /tmp/full_gui_result.txt ]; then
cat /tmp/full_gui_result.txt
# Check if test passed
if grep -q "ALL TESTS PASSED" /tmp/full_gui_result.txt; then
echo "Full GUI test PASSED"
else
echo "Full GUI test FAILED"
fi
else
echo "No result file (test failed to run)"
fi
echo ""
echo "=== Files Created ==="
ls -la /tmp/screenshot.png /tmp/gui_test.FCStd 2>/dev/null || echo "Files not created"
echo ""
echo "=== Test 5: Start FreeCAD GUI with MCP bridge ==="
if [ -f "/workspace/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py" ]; then
echo "Starting FreeCAD with MCP bridge..."
freecad /workspace/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 &
FREECAD_PID=$!
echo "FreeCAD PID: $FREECAD_PID"
# Start xdotool events in background for this test
(
for j in {1..120}; do
xdotool mousemove $((400 + j*3)) $((300 + j*3)) click 1 key Escape 2>/dev/null
sleep 0.5
done
) &
XDOT_PID=$!
# Wait for bridge to start
BRIDGE_READY=0
for i in {1..60}; do
if ! ps -p "$FREECAD_PID" > /dev/null 2>&1; then
echo "FreeCAD process died after ${i}s"
break
fi
# Check for bridge
if curl -s --max-time 1 -X POST \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
http://localhost:9875 > /dev/null 2>&1; then
echo "MCP bridge is ready after ${i}s!"
BRIDGE_READY=1
break
fi
if [ $((i % 10)) -eq 0 ]; then
echo "Waiting... (${i}s)"
fi
sleep 1
done
if [ "$BRIDGE_READY" -eq 1 ]; then
echo "=== Bridge Test: Execute Python ==="
curl -s -X POST \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>execute</methodName><params><param><value><string>_result_ = {"version": str(FreeCAD.Version()), "gui_up": FreeCAD.GuiUp}</string></value></param></params></methodCall>' \
http://localhost:9875 | head -50
echo ""
fi
echo ""
echo "=== Bridge Log ==="
cat /tmp/freecad_bridge.log 2>/dev/null | tail -30
# Cleanup
kill "$XDOT_PID" 2>/dev/null || true
kill "$FREECAD_PID" 2>/dev/null || true
else
echo "Bridge script not found - mount workspace with: docker run -v \$(pwd):/workspace ..."
fi
echo ""
echo "========================================"
echo "Tests complete"
echo "========================================"
+292
View File
@@ -0,0 +1,292 @@
#!/bin/bash
# Setup FreeCAD AppImage - replicates .github/actions/setup-freecad/action.yaml
set -euo pipefail
# Validate required variables have safe defaults
FREECAD_TAG="${FREECAD_TAG:-1.0.2}"
APPIMAGE_DIR="${APPIMAGE_DIR:-$HOME/freecad-appimage}"
# Optional SHA256 checksum for verification (if provided, download will be verified)
APPIMAGE_SHA256="${APPIMAGE_SHA256:-}"
# Validate APPIMAGE_DIR is set and non-empty after defaults
if [[ -z "$APPIMAGE_DIR" ]]; then
echo "ERROR: APPIMAGE_DIR is empty - cannot determine installation path"
exit 1
fi
# Marker file to indicate complete installation
MARKER_FILE="$APPIMAGE_DIR/.freecad_installed"
# Lock file for atomic operations (prevents race conditions in parallel CI)
LOCK_FILE="$APPIMAGE_DIR/.freecad_install.lock"
# Derive APPIMAGE_PATH early for cleanup function
APPIMAGE_PATH="$APPIMAGE_DIR/FreeCAD.AppImage"
# Track whether installation completed successfully
INSTALL_SUCCESSFUL=false
# Cleanup function to remove partial artifacts on failure
cleanup_on_error() {
local exit_code=$?
if [[ $exit_code -ne 0 ]] && [[ "$INSTALL_SUCCESSFUL" != "true" ]]; then
echo "ERROR: Installation failed (exit code $exit_code), cleaning up partial artifacts..."
rm -f "$MARKER_FILE" 2>/dev/null || true
rm -f "$APPIMAGE_PATH" 2>/dev/null || true
rm -rf "$APPIMAGE_DIR/squashfs-root" 2>/dev/null || true
rm -f "$LOCK_FILE" 2>/dev/null || true
fi
}
trap cleanup_on_error EXIT
# Detect if running in CI environment
is_ci_environment() {
# Check common CI environment variables
[[ -n "${CI:-}" ]] || \
[[ -n "${GITHUB_ACTIONS:-}" ]] || \
[[ -n "${GITLAB_CI:-}" ]] || \
[[ -n "${TRAVIS:-}" ]] || \
[[ -n "${CIRCLECI:-}" ]] || \
[[ -n "${JENKINS_URL:-}" ]] || \
[[ -n "${BUILDKITE:-}" ]]
}
# In CI, require SHA256 checksum for security
if is_ci_environment && [[ -z "$APPIMAGE_SHA256" ]]; then
echo "ERROR: APPIMAGE_SHA256 is required in CI environments for security verification"
echo "Set the APPIMAGE_SHA256 environment variable to the expected checksum"
exit 1
fi
echo "=== Setting up FreeCAD $FREECAD_TAG ==="
# Create directory for lock file
mkdir -p "$APPIMAGE_DIR"
# Use flock for atomic marker file operations (prevents race conditions in parallel CI)
# The lock is held for the entire installation process
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "Another installation is in progress, waiting for lock..."
flock 200
# Re-check marker file after acquiring lock (another process may have completed)
if [[ -f "$MARKER_FILE" ]] && grep -q "^${FREECAD_TAG}$" "$MARKER_FILE" 2>/dev/null; then
echo "FreeCAD $FREECAD_TAG already set up by another process, skipping"
exit 0
fi
fi
# Check for complete setup using marker file
if [[ -f "$MARKER_FILE" ]]; then
# Verify marker file contains expected version
if grep -q "^${FREECAD_TAG}$" "$MARKER_FILE" 2>/dev/null; then
echo "FreeCAD $FREECAD_TAG already set up (marker file present), skipping"
exit 0
else
echo "Different FreeCAD version detected, will reinstall"
fi
fi
# Check for and clean up partial installations
PARTIAL_INSTALL=false
if [[ -f "/usr/local/bin/freecad" ]] && [[ ! -f "/usr/local/bin/freecadcmd" ]]; then
echo "Warning: Partial installation detected (freecad exists but freecadcmd missing)"
PARTIAL_INSTALL=true
elif [[ ! -f "/usr/local/bin/freecad" ]] && [[ -f "/usr/local/bin/freecadcmd" ]]; then
echo "Warning: Partial installation detected (freecadcmd exists but freecad missing)"
PARTIAL_INSTALL=true
fi
if [[ "$PARTIAL_INSTALL" == "true" ]]; then
echo "Cleaning up partial installation..."
# Remove wrapper scripts if they exist (use sudo if needed)
if [[ $EUID -ne 0 ]] && command -v sudo &>/dev/null; then
sudo rm -f /usr/local/bin/freecad /usr/local/bin/freecadcmd 2>/dev/null || true
else
rm -f /usr/local/bin/freecad /usr/local/bin/freecadcmd 2>/dev/null || true
fi
# Remove marker file to force full reinstall
rm -f "$MARKER_FILE" 2>/dev/null || true
fi
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH_SUFFIX="x86_64"
;;
aarch64|arm64)
ARCH_SUFFIX="aarch64"
;;
*)
echo "ERROR: Unsupported architecture: $ARCH"
exit 1
;;
esac
echo "Detected architecture: $ARCH -> Linux-$ARCH_SUFFIX"
# Use direct download URL to avoid GitHub API rate limits
# Format: FreeCAD_1.0.2-conda-Linux-aarch64-py311.AppImage
APPIMAGE_URL="https://github.com/FreeCAD/FreeCAD/releases/download/${FREECAD_TAG}/FreeCAD_${FREECAD_TAG}-conda-Linux-${ARCH_SUFFIX}-py311.AppImage"
APPIMAGE_NAME="FreeCAD_${FREECAD_TAG}-conda-Linux-${ARCH_SUFFIX}-py311.AppImage"
# APPIMAGE_PATH is defined earlier for use in cleanup_on_error trap
echo "FreeCAD release: $FREECAD_TAG"
echo "AppImage URL: $APPIMAGE_URL"
echo "AppImage name: $APPIMAGE_NAME"
# Download
mkdir -p "$APPIMAGE_DIR"
if [ ! -f "$APPIMAGE_PATH" ]; then
echo "Downloading FreeCAD AppImage..."
curl -L --retry 3 --retry-delay 5 --retry-all-errors --connect-timeout 30 --max-time 600 \
-f -o "$APPIMAGE_PATH" \
"$APPIMAGE_URL"
# Verify download succeeded and file exists
if [[ ! -f "$APPIMAGE_PATH" ]]; then
echo "ERROR: Download failed - file not found at $APPIMAGE_PATH"
exit 1
fi
# Verify SHA256 checksum if provided
if [[ -n "$APPIMAGE_SHA256" ]]; then
echo "Verifying SHA256 checksum..."
COMPUTED_SHA256=$(sha256sum "$APPIMAGE_PATH" | awk '{print $1}')
if [[ "$COMPUTED_SHA256" != "$APPIMAGE_SHA256" ]]; then
echo "ERROR: SHA256 checksum mismatch!"
echo " Expected: $APPIMAGE_SHA256"
echo " Computed: $COMPUTED_SHA256"
echo "Removing corrupted/tampered download..."
rm -f "$APPIMAGE_PATH"
exit 1
fi
echo "SHA256 checksum verified successfully"
else
echo "Note: No APPIMAGE_SHA256 provided, skipping checksum verification"
fi
fi
chmod +x "$APPIMAGE_PATH"
# Extract with proper error handling
cd "$APPIMAGE_DIR"
if [ ! -d "squashfs-root" ]; then
echo "Extracting AppImage..."
EXTRACTION_ERR=$(mktemp)
# Capture exit code directly to avoid shell negation issues with $?
EXTRACTION_EXIT_CODE=0
./FreeCAD.AppImage --appimage-extract > /dev/null 2>"$EXTRACTION_ERR" || EXTRACTION_EXIT_CODE=$?
if [[ $EXTRACTION_EXIT_CODE -ne 0 ]]; then
echo "ERROR: AppImage extraction failed with exit code $EXTRACTION_EXIT_CODE"
if [[ -s "$EXTRACTION_ERR" ]]; then
echo "Extraction stderr:"
cat "$EXTRACTION_ERR"
fi
rm -f "$EXTRACTION_ERR"
exit 1
fi
# Check for any stderr output even on success
if [[ -s "$EXTRACTION_ERR" ]]; then
echo "Warning: Extraction produced stderr output:"
cat "$EXTRACTION_ERR"
fi
rm -f "$EXTRACTION_ERR"
fi
# Verify extraction produced expected structure
if [ ! -d "squashfs-root/usr/bin" ]; then
echo "ERROR: Extracted AppImage missing expected structure (squashfs-root/usr/bin not found)"
exit 1
fi
echo "Checking AppImage structure..."
# Display directory contents for diagnostic purposes (not parsed programmatically)
# shellcheck disable=SC2012 # ls output piped to head for display only, not parsed
ls -la "$APPIMAGE_DIR/squashfs-root/" 2>/dev/null | head -20
# Create wrapper scripts using AppRun
echo "Creating wrapper scripts..."
# Derive APPDIR_PATH from APPIMAGE_DIR for consistency
APPDIR_PATH="$APPIMAGE_DIR/squashfs-root"
# Helper function to install wrapper script
# Uses sudo only if necessary (not root and sudo exists)
install_wrapper() {
local wrapper_name="$1"
local wrapper_content="$2"
local wrapper_path="/usr/local/bin/$wrapper_name"
local temp_file
temp_file=$(mktemp)
echo "$wrapper_content" > "$temp_file"
chmod +x "$temp_file"
# Install with sudo if not root and sudo is available
if [[ $EUID -ne 0 ]]; then
if command -v sudo &>/dev/null; then
sudo mv "$temp_file" "$wrapper_path"
sudo chmod +x "$wrapper_path"
else
echo "ERROR: Not running as root and sudo not available, cannot install to $wrapper_path"
rm -f "$temp_file"
exit 1
fi
else
mv "$temp_file" "$wrapper_path"
chmod +x "$wrapper_path"
fi
}
# freecadcmd wrapper - avoid trailing colon in LD_LIBRARY_PATH
FREECADCMD_WRAPPER="#!/bin/bash
export APPDIR=\"$APPDIR_PATH\"
export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
exec \"\$APPDIR/usr/bin/freecadcmd\" \"\$@\""
install_wrapper "freecadcmd" "$FREECADCMD_WRAPPER"
# freecad (GUI) wrapper - avoid trailing colon in LD_LIBRARY_PATH
FREECAD_WRAPPER="#!/bin/bash
export APPDIR=\"$APPDIR_PATH\"
export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
exec \"\$APPDIR/usr/bin/freecad\" \"\$@\""
install_wrapper "freecad" "$FREECAD_WRAPPER"
echo "Wrapper scripts created at /usr/local/bin/freecad{,cmd}"
# Verify installation - fail the script if verification fails
# Use explicit paths to avoid PATH resolution issues
echo "=== Verifying FreeCAD installation ==="
# Check wrapper exists before running
if [[ ! -x /usr/local/bin/freecadcmd ]]; then
echo "ERROR: freecadcmd wrapper not found or not executable at /usr/local/bin/freecadcmd"
exit 1
fi
echo "--- freecadcmd --version ---"
if ! /usr/local/bin/freecadcmd --version; then
echo "ERROR: freecadcmd version check failed"
exit 1
fi
echo "--- freecadcmd Python test ---"
if ! /usr/local/bin/freecadcmd -c "import sys; print(f'FreeCAD Python: {sys.version}')"; then
echo "ERROR: FreeCAD Python test failed"
exit 1
fi
echo "--- FreeCAD module import test ---"
if ! /usr/local/bin/freecadcmd -c "import FreeCAD; print(f'FreeCAD version: {FreeCAD.Version()}')"; then
echo "ERROR: FreeCAD module import failed - FreeCAD bindings may be missing or corrupted"
exit 1
fi
# Create marker file to indicate successful installation
echo "$FREECAD_TAG" > "$MARKER_FILE"
echo "Created marker file: $MARKER_FILE"
# Mark installation as successful (prevents cleanup_on_error from removing artifacts)
INSTALL_SUCCESSFUL=true
echo "=== FreeCAD setup complete ==="
+115 -29
View File
@@ -1,7 +1,8 @@
"""Pytest configuration for integration tests.
This module handles connection checking and provides consolidated skip behavior
when the FreeCAD MCP bridge is not available.
This module handles connection checking and provides hard error behavior
when the FreeCAD MCP bridge is not available. Connection failures are
treated as test errors, not skips.
Instance ID Verification:
The FreeCAD MCP bridge generates a unique instance ID at startup which is
@@ -13,7 +14,6 @@ Instance ID Verification:
from __future__ import annotations
import os
import warnings
import xmlrpc.client
from typing import Any
@@ -24,7 +24,7 @@ _bridge_available: bool | None = None
_bridge_error: str | None = None
_bridge_instance_id: str | None = None
_gui_available: bool | None = None
_warning_emitted: bool = False
_connection_checked: bool = False
def _check_bridge_connection() -> tuple[bool, str | None, str | None]:
@@ -47,12 +47,21 @@ def _check_bridge_connection() -> tuple[bool, str | None, str | None]:
# The ping response includes instance_id
_bridge_instance_id = result.get("instance_id")
# Check if GUI is available via get_status
# Check if GUI is available by executing code to check FreeCAD.GuiUp
try:
status: dict[str, Any] = proxy.get_status() # type: ignore[assignment]
_gui_available = status.get("gui_available", False)
gui_check: dict[str, Any] = proxy.execute( # type: ignore[assignment]
"""
import FreeCAD
_result_ = {"gui_up": bool(FreeCAD.GuiUp)}
"""
)
if gui_check.get("success") and gui_check.get("result"):
_gui_available = gui_check["result"].get("gui_up", False)
else:
# Execution failed, assume headless
_gui_available = False
except Exception:
# If get_status fails, assume headless
# If execute fails, assume headless
_gui_available = False
else:
_bridge_available = False
@@ -77,7 +86,12 @@ def is_gui_available() -> bool:
"""Check if FreeCAD GUI is available.
Returns:
True if running in GUI mode, False if headless.
True if running in GUI mode, False if headless or bridge unavailable.
Note:
When the bridge is unavailable, this returns False. The
pytest_collection_modifyitems hook will raise a hard error in this case,
so tests won't actually run with an incorrect skip condition.
"""
# Ensure bridge check has been performed
_check_bridge_connection()
@@ -89,27 +103,69 @@ def is_headless_mode() -> bool:
Returns:
True if running in headless mode, False if GUI is available.
Note:
When the bridge is unavailable, this returns True (assumes headless).
The pytest_collection_modifyitems hook will raise a hard error before
any tests run, so this assumption doesn't affect actual test execution.
"""
return not is_gui_available()
# Skip marker for GUI-only tests
def _should_skip_for_gui_requirement() -> bool:
"""Return True if test should be skipped due to requiring GUI mode.
Returns False when:
- Bridge is available and in GUI mode
Returns True when:
- Bridge is unavailable (will fail anyway, skip is irrelevant)
- Bridge is available and in headless mode
"""
_check_bridge_connection()
return _gui_available is not True
def _should_skip_for_headless_requirement() -> bool:
"""Return True if test should be skipped due to requiring headless mode.
Returns False when:
- Bridge is unavailable (collection will fail anyway)
- Bridge is available and in headless mode
Returns True when:
- Bridge is available and in GUI mode
"""
_check_bridge_connection()
if not _bridge_available:
return False # Don't skip, let collection error handle it
return _gui_available is True # Skip if in GUI mode
# Skip markers for mode-specific tests
# These markers handle the bridge unavailable case by deferring to
# pytest_collection_modifyitems which raises a hard error.
requires_gui = pytest.mark.skipif(
is_headless_mode(),
_should_skip_for_gui_requirement(),
reason="Test requires FreeCAD GUI mode (running in headless mode)",
)
requires_headless = pytest.mark.skipif(
_should_skip_for_headless_requirement(),
reason="Test requires FreeCAD headless mode (running in GUI mode)",
)
def pytest_collection_modifyitems(
config: pytest.Config, # noqa: ARG001
items: list[pytest.Item],
) -> None:
"""Skip all integration tests if the bridge is not available.
"""Verify bridge connection for integration tests.
This runs once during test collection and emits a single warning instead of
per-test skip messages.
This runs once during test collection. If the bridge is not available,
this raises a hard error instead of skipping tests.
"""
global _warning_emitted
global _connection_checked
# Filter to only integration tests in this directory
integration_tests = [
@@ -122,23 +178,53 @@ def pytest_collection_modifyitems(
# Check bridge connection once
is_available, error, _instance_id = _check_bridge_connection()
if not is_available:
# Apply skip marker to all integration tests
skip_marker = pytest.mark.skip(reason="FreeCAD MCP bridge unavailable")
for item in integration_tests:
item.add_marker(skip_marker)
# Emit a single warning (only once)
if not _warning_emitted:
_warning_emitted = True
warnings.warn(
f"Skipping {len(integration_tests)} integration tests: {error}. "
f"Start the bridge with 'just run-gui' or 'just run-headless'.",
pytest.PytestWarning,
stacklevel=1,
if not is_available and not _connection_checked:
_connection_checked = True
pytest.fail(
f"\n\n{'=' * 60}\n"
f"INTEGRATION TEST ERROR: FreeCAD MCP bridge not available\n"
f"{'=' * 60}\n\n"
f"Error: {error}\n\n"
f"To run integration tests, start FreeCAD with the MCP bridge:\n"
f" • GUI mode: just freecad::run-gui\n"
f" • Headless mode: just freecad::run-headless\n\n"
f"Then run the tests again.\n"
f"{'=' * 60}\n",
pytrace=False,
)
def pytest_terminal_summary(
terminalreporter: Any,
exitstatus: int, # noqa: ARG001
config: pytest.Config, # noqa: ARG001
) -> None:
"""Print a summary of FreeCAD connection status at the end of test run.
This provides clear visibility into which mode was used and confirms
successful connection.
"""
# Only show summary if we ran integration tests
if _bridge_available is None:
return
# Build the summary message
terminalreporter.write_sep("=", "FreeCAD MCP Bridge Status")
if _bridge_available:
mode = "GUI" if _gui_available else "Headless"
terminalreporter.write_line(" Connection: SUCCESS")
terminalreporter.write_line(f" Mode: {mode}")
if _bridge_instance_id:
terminalreporter.write_line(f" Instance: {_bridge_instance_id}")
else:
terminalreporter.write_line(" Connection: FAILED")
if _bridge_error:
terminalreporter.write_line(f" Error: {_bridge_error}")
terminalreporter.write_line("")
@pytest.fixture(scope="module")
def xmlrpc_proxy() -> xmlrpc.client.ServerProxy:
"""Create XML-RPC proxy to FreeCAD MCP bridge.
+4 -2
View File
@@ -19,12 +19,14 @@ from typing import TYPE_CHECKING, Any
import pytest
from tests.integration.conftest import requires_gui
if TYPE_CHECKING:
import xmlrpc.client
from collections.abc import Generator
# Mark all tests in this module as integration tests and gui tests
pytestmark = [pytest.mark.integration, pytest.mark.gui]
# Mark all tests in this module as integration tests, gui tests, and require GUI mode
pytestmark = [pytest.mark.integration, pytest.mark.gui, requires_gui]
# Note: xmlrpc_proxy fixture is defined in conftest.py
+259 -56
View File
@@ -1,11 +1,12 @@
"""Integration tests for FreeCAD MCP headless mode.
"""Integration tests for FreeCAD MCP bridge functionality.
These tests verify that the MCP bridge works correctly when FreeCAD is running
in headless mode (without GUI). They test object creation, manipulation, and
export functionality.
These tests verify that the MCP bridge works correctly with FreeCAD, including
object creation, manipulation, and export functionality. Most tests work in
both GUI and headless modes, with specific tests marked as headless-only.
Note: These tests require a running FreeCAD headless server.
Start it with: just freecad::run-headless
Note: These tests require a running FreeCAD server.
Start with: just freecad::run-gui (GUI mode)
or: just freecad::run-headless (headless mode)
To run these tests:
pytest tests/integration/test_headless_mode.py -v
@@ -14,11 +15,14 @@ To run these tests:
from __future__ import annotations
import tempfile
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from tests.integration.conftest import requires_headless
if TYPE_CHECKING:
import xmlrpc.client
from collections.abc import Generator
@@ -29,6 +33,11 @@ pytestmark = pytest.mark.integration
# Note: xmlrpc_proxy fixture is defined in conftest.py
def _unique_suffix() -> str:
"""Generate a unique suffix using timestamp."""
return time.strftime("%Y%m%d%H%M%S")
@pytest.fixture(scope="module")
def temp_dir() -> Generator[str, None, None]:
"""Create a temporary directory for test files."""
@@ -36,6 +45,15 @@ def temp_dir() -> Generator[str, None, None]:
yield tmpdir
@pytest.fixture(scope="module")
def unique_suffix() -> str:
"""Generate a unique suffix for document names in this test session.
Uses timestamp format YYYYMMDDHHMMSS to ensure unique names across runs.
"""
return _unique_suffix()
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
"""Execute Python code via the MCP bridge and return the result."""
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
@@ -43,6 +61,32 @@ def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
return result
# Common code snippet for centering viewport in GUI mode
CENTER_VIEWPORT_CODE = """
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
if FreeCADGui.ActiveDocument and FreeCADGui.ActiveDocument.ActiveView:
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
"""
def center_viewport(proxy: xmlrpc.client.ServerProxy) -> None:
"""Center all objects in the viewport if GUI is available.
Call this after creating/modifying objects to keep them centered.
Safe to call in headless mode - it will simply do nothing.
"""
proxy.execute( # type: ignore[union-attr]
f"""
import FreeCAD
{CENTER_VIEWPORT_CODE}
_result_ = True
"""
)
class TestHeadlessConnection:
"""Tests for basic headless mode connectivity."""
@@ -52,10 +96,14 @@ class TestHeadlessConnection:
assert result["pong"] is True
assert "timestamp" in result
@requires_headless
def test_headless_mode_detected(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that FreeCAD is running in headless mode (GuiUp=False)."""
"""Test that FreeCAD is running in headless mode (GuiUp=False).
This test is skipped when FreeCAD is running in GUI mode.
"""
result = execute_code(
xmlrpc_proxy,
"""
@@ -70,28 +118,40 @@ _result_ = {"gui_up": FreeCAD.GuiUp}
class TestDocumentManagement:
"""Tests for document creation and management in headless mode."""
def test_create_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def test_create_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test creating a new document."""
doc_name = f"TestDoc_{unique_suffix}"
result = execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
doc = FreeCAD.newDocument("TestDoc")
_result_ = {"name": doc.Name, "object_count": len(doc.Objects)}
doc_name = {doc_name!r}
# Close if it already exists (from a previous failed run)
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
_result_ = {{"name": doc.Name, "object_count": len(doc.Objects)}}
""",
)
assert result["result"]["name"] == "TestDoc"
assert result["result"]["name"] == doc_name
assert result["result"]["object_count"] == 0
def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def test_list_documents(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test listing open documents."""
doc_name = f"ListTestDoc_{unique_suffix}"
# First create a document
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
if not FreeCAD.listDocuments():
FreeCAD.newDocument("ListTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
FreeCAD.newDocument(doc_name)
_result_ = True
""",
)
@@ -106,43 +166,60 @@ _result_ = {"documents": docs, "count": len(docs)}
)
assert result["result"]["count"] >= 1
def test_close_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def test_close_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test closing a document."""
doc_name = f"ToClose_{unique_suffix}"
# Create a document to close
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
doc = FreeCAD.newDocument("ToClose")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
_result_ = True
""",
)
result = execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
FreeCAD.closeDocument("ToClose")
_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()}
doc_name = {doc_name!r}
FreeCAD.closeDocument(doc_name)
_result_ = {{"closed": doc_name not in FreeCAD.listDocuments()}}
""",
)
assert result["result"]["closed"] is True
class TestPrimitiveCreation:
"""Tests for creating primitive shapes in headless mode."""
"""Tests for creating primitive shapes."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Create a fresh document for each test."""
doc_name = f"PrimitiveTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
# Close any existing PrimitiveTestDoc
if "PrimitiveTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("PrimitiveTestDoc")
doc = FreeCAD.newDocument("PrimitiveTestDoc")
doc_name = {doc_name!r}
# Close any existing document with this name
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Set up initial view for GUI mode
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
_result_ = True
""",
)
@@ -159,8 +236,18 @@ doc = FreeCAD.ActiveDocument
box = Part.makeBox(10, 20, 30)
obj = doc.addObject("Part::Feature", "TestBox")
obj.Shape = box
# Center viewport immediately after adding object (before it renders off-center)
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
@@ -186,8 +273,18 @@ doc = FreeCAD.ActiveDocument
cylinder = Part.makeCylinder(5, 20)
obj = doc.addObject("Part::Feature", "TestCylinder")
obj.Shape = cylinder
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
expected_volume = math.pi * 5**2 * 20
_result_ = {
@@ -218,8 +315,18 @@ doc = FreeCAD.ActiveDocument
sphere = Part.makeSphere(10)
obj = doc.addObject("Part::Feature", "TestSphere")
obj.Shape = sphere
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
expected_volume = (4/3) * math.pi * 10**3
_result_ = {
@@ -249,8 +356,18 @@ doc = FreeCAD.ActiveDocument
cone = Part.makeCone(10, 0, 20) # Base radius=10, top radius=0, height=20
obj = doc.addObject("Part::Feature", "TestCone")
obj.Shape = cone
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
@@ -275,8 +392,18 @@ doc = FreeCAD.ActiveDocument
torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5
obj = doc.addObject("Part::Feature", "TestTorus")
obj.Shape = torus
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
@@ -290,18 +417,28 @@ _result_ = {
class TestBooleanOperations:
"""Tests for boolean operations in headless mode."""
"""Tests for boolean operations."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Create a fresh document for each test."""
doc_name = f"BooleanTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
if "BooleanTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("BooleanTestDoc")
doc = FreeCAD.newDocument("BooleanTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Set up initial view for GUI mode
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
_result_ = True
""",
)
@@ -325,12 +462,21 @@ obj1.Shape = box1
obj2 = doc.addObject("Part::Feature", "Box2")
obj2.Shape = box2
# Center viewport after adding initial objects
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
# Fuse them
fused = box1.fuse(box2)
obj_fused = doc.addObject("Part::Feature", "Fused")
obj_fused.Shape = fused
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
# Fused volume should be less than 2000 (two boxes) due to overlap
_result_ = {
"fused_volume": obj_fused.Shape.Volume,
@@ -360,8 +506,18 @@ cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5))
cut = box.cut(cylinder)
obj_cut = doc.addObject("Part::Feature", "Cut")
obj_cut.Shape = cut
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
box_volume = 20 * 20 * 20
import math
cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box
@@ -382,25 +538,36 @@ _result_ = {
class TestObjectManipulation:
"""Tests for object manipulation in headless mode."""
"""Tests for object manipulation."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Create a fresh document with a test box."""
doc_name = f"ManipTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
import Part
if "ManipTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ManipTestDoc")
doc = FreeCAD.newDocument("ManipTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
box = Part.makeBox(10, 10, 10)
obj = doc.addObject("Part::Feature", "ManipBox")
obj.Shape = box
doc.recompute()
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = True
""",
)
@@ -420,6 +587,12 @@ obj = doc.getObject("ManipBox")
# Move the object
obj.Placement.Base = FreeCAD.Vector(100, 200, 300)
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"x": obj.Placement.Base.x,
"y": obj.Placement.Base.y,
@@ -447,6 +620,12 @@ obj = doc.getObject("ManipBox")
obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
doc.recompute()
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
euler = obj.Placement.Rotation.toEuler()
_result_ = {
"rotation_z": euler[0], # Yaw
@@ -460,22 +639,24 @@ _result_ = {
class TestExportOperations:
"""Tests for export functionality in headless mode."""
"""Tests for export functionality."""
@pytest.fixture(autouse=True)
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str, unique_suffix: str
) -> None:
"""Create a document with objects for export tests."""
doc_name = f"ExportTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
import Part
if "ExportTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ExportTestDoc")
doc = FreeCAD.newDocument("ExportTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Create a simple box
box = Part.makeBox(10, 10, 10)
@@ -483,6 +664,12 @@ obj = doc.addObject("Part::Feature", "ExportBox")
obj.Shape = box
doc.recompute()
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = True
""",
)
@@ -537,8 +724,12 @@ _result_ = {{
assert fcstd_path.exists()
@requires_headless
class TestGUIFeaturesInHeadless:
"""Tests to verify GUI features fail gracefully in headless mode."""
"""Tests to verify GUI features fail gracefully in headless mode.
These tests are skipped when FreeCAD is running in GUI mode.
"""
def test_gui_features_not_available(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
@@ -569,22 +760,24 @@ else:
class TestComplexWorkflow:
"""Tests for complex multi-step workflows in headless mode."""
"""Tests for complex multi-step workflows."""
def test_parametric_modeling_workflow(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test a complete parametric modeling workflow."""
doc_name = f"WorkflowTestDoc_{unique_suffix}"
result = execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
import Part
# Create document
if "WorkflowTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("WorkflowTestDoc")
doc = FreeCAD.newDocument("WorkflowTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Step 1: Create base box
base_box = Part.makeBox(50, 50, 10)
@@ -609,14 +802,24 @@ try:
except Exception:
fillet_success = False
# Center viewport immediately after adding objects (before recompute)
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
_result_ = {
# Final viewport adjustment after recompute
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {{
"objects": [obj.Name for obj in doc.Objects],
"final_volume": final_obj.Shape.Volume,
"final_valid": final_obj.Shape.isValid(),
"fillet_success": fillet_success
}
}}
""",
)
assert "Base" in result["result"]["objects"]
+20
View File
@@ -0,0 +1,20 @@
"""Just command test suite.
This test suite validates all just commands in the project to catch
syntax errors, missing dependencies, and runtime failures early.
Test Categories:
- Syntax tests: Verify just can parse the command (--dry-run)
- Runtime tests: Actually execute commands and verify behavior
- Release tests: Special tests for release commands with cleanup
Usage:
uv run pytest tests/just_commands/ # Run all just command tests
uv run pytest tests/just_commands/ -m just_syntax # Only syntax checks
uv run pytest tests/just_commands/ -m just_runtime # Only runtime tests
uv run pytest tests/just_commands/ -m just_release # Only release tests (with cleanup)
Maintenance:
When adding or modifying just commands, update the corresponding test file.
See CLAUDE.md section "Just Command Testing" for details.
"""
+333
View File
@@ -0,0 +1,333 @@
"""Shared fixtures and utilities for just command tests.
This module provides:
- Fixtures for running just commands
- Helper functions for command validation
- Markers for test categorization
"""
from __future__ import annotations
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from collections.abc import Generator
# Get project root (where justfile is located)
PROJECT_ROOT = Path(__file__).parent.parent.parent
@dataclass(frozen=True)
class JustResult:
"""Result of running a just command.
This dataclass is frozen (immutable) since results should not be modified
after creation - they represent a snapshot of command execution.
"""
command: str
returncode: int
stdout: str
stderr: str
success: bool
@property
def output(self) -> str:
"""Combined stdout and stderr."""
return f"{self.stdout}\n{self.stderr}".strip()
def assert_command_executed(result: JustResult, command_name: str) -> None:
"""Assert that a command actually executed (didn't timeout or have missing deps).
This checks for:
- Timeout (returncode -1)
- Missing command/dependency (returncode 127)
- "command not found" in stderr
It does NOT check for success - the command may legitimately fail
due to linting issues, type errors, etc.
"""
assert result.returncode != -1, f"{command_name} timed out: {result.stderr}"
assert result.returncode != 127, (
f"{command_name} missing dependency (exit 127): {result.stderr}"
)
assert "command not found" not in result.output.lower(), (
f"{command_name} has missing tool: {result.output}"
)
class JustRunner:
"""Helper class for running just commands in tests."""
def __init__(self, project_root: Path) -> None:
"""Initialize the runner with project root path."""
self.project_root = project_root
just_path = shutil.which("just")
if not just_path:
raise RuntimeError("just command not found in PATH")
self._just_path: str = just_path
def run(
self,
command: str,
*args: str,
timeout: int = 60,
env: dict[str, str] | None = None,
input_text: str | None = None,
check: bool = False,
) -> JustResult:
"""Run a just command and return the result.
Args:
command: The just command to run (e.g., "quality::lint")
*args: Additional arguments to pass to the command
timeout: Timeout in seconds
env: Additional environment variables
input_text: Text to pass to stdin
check: If True, raise CalledProcessError on non-zero exit
Returns:
JustResult with command output and status
"""
cmd: list[str] = [self._just_path, command, *args]
# Merge environment
run_env = os.environ.copy()
if env:
run_env.update(env)
try:
# S603: subprocess call is safe here - we're running `just` with
# controlled arguments in a test context
result = subprocess.run( # noqa: S603
cmd,
cwd=self.project_root,
capture_output=True,
text=True,
timeout=timeout,
env=run_env,
input=input_text,
check=check,
)
return JustResult(
command=command,
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
success=result.returncode == 0,
)
except subprocess.TimeoutExpired as e:
# subprocess.run with text=True means e.stdout is str|None at runtime,
# but the type stub says bytes|str|None. Cast to satisfy mypy.
stdout_val = str(e.stdout) if e.stdout else ""
return JustResult(
command=command,
returncode=-1,
stdout=stdout_val,
stderr=f"Command timed out after {timeout}s",
success=False,
)
except subprocess.CalledProcessError as e:
return JustResult(
command=command,
returncode=e.returncode,
stdout=e.stdout or "",
stderr=e.stderr or "",
success=False,
)
def dry_run(self, command: str, *args: str, timeout: int = 30) -> JustResult:
"""Run a just command in dry-run mode (syntax check only).
This validates that just can parse the command without executing it.
"""
cmd: list[str] = [self._just_path, "--dry-run", command, *args]
# S603: subprocess call is safe here - we're running `just` with
# controlled arguments in a test context
result = subprocess.run( # noqa: S603
cmd,
cwd=self.project_root,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
return JustResult(
command=command,
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
success=result.returncode == 0,
)
def list_commands(self, module: str | None = None) -> list[str]:
"""List available just commands, optionally for a specific module."""
cmd: list[str] = [self._just_path, "--list"]
if module:
cmd.append(module)
# S603: subprocess call is safe here - we're running `just` with
# controlled arguments in a test context
result = subprocess.run( # noqa: S603
cmd,
cwd=self.project_root,
capture_output=True,
text=True,
check=False,
)
# Parse the output to extract command names
commands = []
for raw_line in result.stdout.splitlines():
# Skip empty lines and headers
stripped_line = raw_line.strip()
if not stripped_line or stripped_line.startswith("Available"):
continue
# Extract command name (first word before any description)
parts = stripped_line.split()
if parts:
commands.append(parts[0])
return commands
@pytest.fixture
def just() -> JustRunner:
"""Fixture providing a JustRunner instance."""
return JustRunner(PROJECT_ROOT)
@pytest.fixture
def project_root() -> Path:
"""Fixture providing the project root path."""
return PROJECT_ROOT
# Register custom markers
def pytest_configure(config: pytest.Config) -> None:
"""Register custom pytest markers."""
config.addinivalue_line(
"markers", "just_syntax: marks tests as just syntax checks (dry-run only)"
)
config.addinivalue_line(
"markers", "just_runtime: marks tests as just runtime tests (actually execute)"
)
config.addinivalue_line(
"markers",
"just_release: marks tests as release command tests (require special handling)",
)
config.addinivalue_line(
"markers", "requires_freecad: marks tests that require FreeCAD to be installed"
)
config.addinivalue_line(
"markers",
"requires_docker: marks tests that require Docker to be running",
)
config.addinivalue_line(
"markers",
"requires_coderabbit: marks tests that require CodeRabbit CLI to be installed",
)
config.addinivalue_line(
"markers",
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
)
# Cleanup fixtures for release tests
@pytest.fixture
def git_tag_cleanup() -> Generator[list[str], None, None]:
"""Fixture that tracks and cleans up git tags created during tests.
Usage:
def test_create_tag(git_tag_cleanup):
git_tag_cleanup.append("test-tag-v0.0.0")
# Create the tag...
# Tag will be deleted after test
"""
tags_to_cleanup: list[str] = []
yield tags_to_cleanup
# Cleanup: delete all tracked tags (only test- prefixed tags for safety)
for tag in tags_to_cleanup:
# Safety guard: only delete tags starting with "test-" to prevent
# accidental deletion of real release tags
if not tag.startswith("test-"):
# Warn about skipped non-test tags to surface accidental additions
import warnings
warnings.warn(
f"Skipping cleanup of non-test tag '{tag}' - "
"only 'test-' prefixed tags are allowed in git_tag_cleanup fixture. "
"This may indicate a bug in the test.",
UserWarning,
stacklevel=1,
)
continue
# Delete local tag
# S603, S607: git is a well-known command, safe in test cleanup context
subprocess.run( # noqa: S603
["git", "tag", "-d", tag], # noqa: S607
cwd=PROJECT_ROOT,
capture_output=True,
check=False,
)
# Delete remote tag (already guarded by the startswith check above)
subprocess.run( # noqa: S603
["git", "push", "origin", "--delete", tag], # noqa: S607
cwd=PROJECT_ROOT,
capture_output=True,
check=False,
)
# Safe image name patterns for docker_image_cleanup fixture
# Only images matching these patterns will be removed during cleanup
SAFE_DOCKER_IMAGE_PATTERNS = (
"freecad-robust-mcp", # Project's Docker image
"test-", # Any test-prefixed images
)
@pytest.fixture
def docker_image_cleanup() -> Generator[list[str], None, None]:
"""Fixture that tracks and cleans up Docker images created during tests."""
images_to_cleanup: list[str] = []
yield images_to_cleanup
# Cleanup: delete all tracked images (only safe/known patterns)
for image in images_to_cleanup:
# Safety guard: only delete images matching safe patterns
is_safe = any(
image == pattern or image.startswith(pattern)
for pattern in SAFE_DOCKER_IMAGE_PATTERNS
)
if not is_safe:
# Warn about skipped images to surface accidental additions
import warnings
warnings.warn(
f"Skipping cleanup of Docker image '{image}' - "
f"not in safe patterns {SAFE_DOCKER_IMAGE_PATTERNS}. "
"This may indicate a bug in the test.",
UserWarning,
stacklevel=1,
)
continue
# S603, S607: docker is a well-known command, safe in test cleanup context
subprocess.run( # noqa: S603
["docker", "rmi", "-f", image], # noqa: S607
cwd=PROJECT_ROOT,
capture_output=True,
check=False,
)
+76
View File
@@ -0,0 +1,76 @@
"""Tests for coderabbit module just commands.
These tests verify that CodeRabbit CLI commands work correctly.
Note: Most commands require CodeRabbit CLI to be installed and authenticated.
"""
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
def coderabbit_available() -> bool:
"""Check if CodeRabbit CLI is available."""
return shutil.which("coderabbit") is not None
class TestCoderabbitSyntax:
"""Syntax validation tests for CodeRabbit commands."""
CODERABBIT_COMMANDS: ClassVar[list[str]] = [
"coderabbit::install",
"coderabbit::check-installed",
"coderabbit::login",
"coderabbit::logout",
"coderabbit::auth-status",
"coderabbit::review",
"coderabbit::review-fix",
"coderabbit::review-all",
"coderabbit::review-last",
"coderabbit::review-since",
"coderabbit::review-branch",
"coderabbit::prompt-only",
"coderabbit::review-json",
"coderabbit::help",
"coderabbit::version",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", CODERABBIT_COMMANDS)
def test_coderabbit_command_syntax(self, just: JustRunner, command: str) -> None:
"""CodeRabbit command should have valid syntax."""
# Commands with required arguments
if command == "coderabbit::review-since":
result = just.dry_run(command, "HEAD~5")
else:
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestCoderabbitRuntime:
"""Runtime tests for CodeRabbit commands."""
@pytest.mark.just_runtime
def test_check_installed_reports_status(self, just: JustRunner) -> None:
"""check-installed should report installation status."""
result = just.run("coderabbit::check-installed", timeout=10)
if coderabbit_available():
assert result.success
else:
# Should fail with helpful message
assert not result.success
assert "not installed" in result.output.lower()
@pytest.mark.just_runtime
def test_version_runs(self, just: JustRunner) -> None:
"""version command should run if CodeRabbit is installed."""
if not coderabbit_available():
pytest.skip("CodeRabbit CLI not installed")
result = just.run("coderabbit::version", timeout=10)
assert result.success, f"Version failed: {result.stderr}"
+63
View File
@@ -0,0 +1,63 @@
"""Tests for dev module just commands.
These tests verify that development utility commands work correctly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestDevSyntax:
"""Syntax validation tests for dev commands."""
DEV_COMMANDS: ClassVar[list[str]] = [
"dev::install-deps",
"dev::install-pre-commit",
"dev::update-deps",
"dev::clean",
"dev::repl",
"dev::tree",
"dev::validate",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", DEV_COMMANDS)
def test_dev_command_syntax(self, just: JustRunner, command: str) -> None:
"""Dev command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestDevRuntime:
"""Runtime tests for dev commands."""
@pytest.mark.just_runtime
def test_clean_runs(self, just: JustRunner) -> None:
"""Clean command should run successfully."""
result = just.run("dev::clean", timeout=30)
assert result.success, f"Clean failed: {result.stderr}"
@pytest.mark.just_runtime
def test_validate_runs(self, just: JustRunner) -> None:
"""Validate command should run successfully."""
result = just.run("dev::validate", timeout=30)
assert result.success, f"Validate failed: {result.stderr}"
@pytest.mark.just_runtime
@pytest.mark.slow
def test_install_deps_runs(self, just: JustRunner) -> None:
"""Install-deps command should run (may take a while)."""
result = just.run("dev::install-deps", timeout=300)
assert result.success, f"Install-deps failed: {result.stderr}"
@pytest.mark.just_runtime
def test_install_pre_commit_runs(self, just: JustRunner) -> None:
"""Install-pre-commit should run successfully."""
result = just.run("dev::install-pre-commit", timeout=60)
assert result.success, f"Install-pre-commit failed: {result.stderr}"
+127
View File
@@ -0,0 +1,127 @@
"""Tests for docker module just commands.
These tests verify that Docker-related commands work correctly.
Some tests require Docker to be running.
"""
from __future__ import annotations
import shutil
import subprocess
from typing import TYPE_CHECKING, ClassVar
import pytest
from tests.just_commands.conftest import assert_command_executed
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
# Docker image name used in tests - defined once for maintainability
DOCKER_IMAGE_NAME = "freecad-robust-mcp"
# Mapping of commands to their required arguments for dry-run testing
COMMAND_ARG_MAP: dict[str, tuple[str, ...]] = {
"docker::build-tag": ("test",),
"docker::build-push": ("test",),
"docker::scan-sarif": ("test.sarif",),
"docker::run-env": ("-e", "TEST=1"),
"docker::gui-test-cmd": ("echo", "test"),
}
def docker_available() -> bool:
"""Check if Docker is available and running."""
if not shutil.which("docker"):
return False
try:
# S603, S607: docker is a well-known command, safe in test context
result = subprocess.run(
["docker", "info"], # noqa: S607
capture_output=True,
timeout=10,
check=False,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
class TestDockerSyntax:
"""Syntax validation tests for docker commands."""
DOCKER_COMMANDS: ClassVar[list[str]] = [
"docker::build",
"docker::build-tag",
"docker::build-multi",
"docker::build-push",
"docker::build-load",
"docker::run",
"docker::run-env",
"docker::shell",
"docker::inspect",
"docker::clean",
"docker::clean-all",
"docker::scan",
"docker::scan-strict",
"docker::scan-sarif",
"docker::setup-buildx",
"docker::test",
"docker::build-gui-test",
"docker::gui-test-shell",
"docker::gui-test-run",
"docker::gui-test-cmd",
"docker::gui-test",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", DOCKER_COMMANDS)
def test_docker_command_syntax(self, just: JustRunner, command: str) -> None:
"""Docker command should have valid syntax."""
# Get args from mapping, or empty tuple for commands without required args
args = COMMAND_ARG_MAP.get(command, ())
result = just.dry_run(command, *args)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
@pytest.mark.requires_docker
class TestDockerRuntime:
"""Runtime tests for docker commands (require Docker)."""
@pytest.fixture(autouse=True)
def skip_if_no_docker(self) -> None:
"""Skip tests if Docker is not available."""
if not docker_available():
pytest.skip("Docker not available")
@pytest.mark.just_runtime
@pytest.mark.slow
def test_build_runs(
self, just: JustRunner, docker_image_cleanup: list[str]
) -> None:
"""Docker build should run successfully."""
docker_image_cleanup.append(DOCKER_IMAGE_NAME)
# 10 minute timeout: Docker builds can be slow, especially on first run
# when base images need to be pulled and dependencies compiled
result = just.run("docker::build", timeout=600)
assert result.success, f"Docker build failed: {result.stderr}"
@pytest.mark.just_runtime
def test_inspect_runs(self, just: JustRunner) -> None:
"""Docker inspect should run (may fail if no image)."""
result = just.run("docker::inspect", timeout=30)
# May fail if image doesn't exist, but should run without missing deps
assert_command_executed(result, "docker::inspect")
@pytest.mark.just_runtime
def test_clean_runs(self, just: JustRunner) -> None:
"""Docker clean should run."""
result = just.run("docker::clean", timeout=60)
# May fail if no images to clean, but should run without missing deps
assert_command_executed(result, "docker::clean")
@pytest.mark.just_runtime
def test_setup_buildx_runs(self, just: JustRunner) -> None:
"""Setup buildx should run."""
result = just.run("docker::setup-buildx", timeout=120)
assert result.success, f"Setup buildx failed: {result.stderr}"
+47
View File
@@ -0,0 +1,47 @@
"""Tests for documentation module just commands.
These tests verify that documentation commands work correctly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestDocumentationSyntax:
"""Syntax validation tests for documentation commands."""
DOC_COMMANDS: ClassVar[list[str]] = [
"documentation::build",
"documentation::build-strict",
"documentation::serve",
"documentation::open",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", DOC_COMMANDS)
def test_documentation_command_syntax(self, just: JustRunner, command: str) -> None:
"""Documentation command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestDocumentationRuntime:
"""Runtime tests for documentation commands."""
@pytest.mark.just_runtime
def test_build_runs(self, just: JustRunner) -> None:
"""Documentation build should run successfully."""
result = just.run("documentation::build", timeout=120)
assert result.success, f"Documentation build failed: {result.stderr}"
@pytest.mark.just_runtime
def test_build_strict_runs(self, just: JustRunner) -> None:
"""Documentation build-strict should run successfully."""
result = just.run("documentation::build-strict", timeout=120)
assert result.success, f"Documentation build-strict failed: {result.stderr}"
+84
View File
@@ -0,0 +1,84 @@
"""Tests for freecad module just commands.
These tests verify that FreeCAD-related commands work correctly.
Note: Most FreeCAD commands require FreeCAD to be installed.
"""
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
def freecad_available() -> bool:
"""Check if FreeCAD is available."""
# Check for common FreeCAD command locations
if shutil.which("freecad") or shutil.which("FreeCAD"):
return True
return bool(shutil.which("freecadcmd") or shutil.which("FreeCADCmd"))
class TestFreecadSyntax:
"""Syntax validation tests for FreeCAD commands."""
FREECAD_COMMANDS: ClassVar[list[str]] = [
"freecad::run-headless",
"freecad::run-headless-custom",
"freecad::run-gui",
"freecad::run-gui-custom",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", FREECAD_COMMANDS)
def test_freecad_command_syntax(self, just: JustRunner, command: str) -> None:
"""FreeCAD command should have valid syntax."""
# Commands with required arguments
if command == "freecad::run-headless-custom":
result = just.dry_run(command, "/path/to/freecadcmd")
elif command == "freecad::run-gui-custom":
result = just.dry_run(command, "/path/to/freecad")
else:
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
@pytest.mark.requires_freecad
class TestFreecadRuntime:
"""Runtime tests for FreeCAD commands (require FreeCAD)."""
@pytest.fixture(autouse=True)
def skip_if_no_freecad(self) -> None:
"""Skip tests if FreeCAD is not available."""
if not freecad_available():
pytest.skip("FreeCAD not available")
@pytest.mark.just_runtime
def test_run_headless_starts(self, just: JustRunner) -> None:
"""run-headless should start FreeCAD (we just verify it doesn't crash immediately).
Note: This test starts FreeCAD headless and lets it run briefly.
In CI, this should work with Xvfb.
"""
# Start headless and kill after short timeout
result = just.run("freecad::run-headless", timeout=10)
# Check for missing executable first (exit code 127)
if result.returncode == 127:
pytest.fail(
"FreeCAD executable not found (exit code 127). "
"Ensure FreeCAD is installed and in PATH. "
f"Output: {result.output}"
)
# Will timeout (expected) - we just want to verify it started
# returncode -1 means timeout, which is expected
# returncode 0 means it exited cleanly
# Any other exit code indicates a problem
assert result.returncode in (-1, 0), (
f"FreeCAD failed unexpectedly (exit {result.returncode}): {result.output}"
)
+79
View File
@@ -0,0 +1,79 @@
"""Tests for install module just commands.
These tests verify that installation commands work correctly.
Note: Some tests may modify local installations.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestInstallSyntax:
"""Syntax validation tests for install commands."""
INSTALL_COMMANDS: ClassVar[list[str]] = [
"install::mcp-server",
"install::mcp-server-clean",
"install::uninstall-mcp-server",
"install::mcp-bridge-workbench",
"install::uninstall-mcp-bridge-workbench",
"install::macro-cut",
"install::uninstall-macro-cut",
"install::macro-export",
"install::uninstall-macro-export",
"install::macro-all",
"install::uninstall-macro-all",
"install::status",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", INSTALL_COMMANDS)
def test_install_command_syntax(self, just: JustRunner, command: str) -> None:
"""Install command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestInstallRuntime:
"""Runtime tests for install commands."""
@pytest.mark.just_runtime
def test_status_runs(self, just: JustRunner) -> None:
"""Status command should run and show installation status."""
result = just.run("install::status", timeout=60)
assert result.success, f"Status failed: {result.stderr}"
# Should contain some expected output
assert "Installation Status" in result.stdout
@pytest.mark.just_runtime
def test_freecad_dirs_helper_runs(self, just: JustRunner) -> None:
"""Private _freecad-dirs helper should work."""
result = just.run("install::_freecad-dirs", timeout=10)
assert result.success, f"_freecad-dirs failed: {result.stderr}"
# Should output shell code to set directories
assert "MOD_DIR" in result.stdout
assert "MACRO_DIR" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.slow
def test_mcp_server_install_uninstall(self, just: JustRunner) -> None:
"""MCP server install and uninstall should work."""
# Install
result = just.run("install::mcp-server", timeout=300)
assert result.success, f"MCP server install failed: {result.stderr}"
# Verify installation
status_result = just.run("install::status", timeout=60)
assert "Robust MCP Server" in status_result.stdout
# Uninstall
uninstall_result = just.run("install::uninstall-mcp-server", timeout=60)
assert uninstall_result.success, (
f"MCP server uninstall failed: {uninstall_result.stderr}"
)
+156
View File
@@ -0,0 +1,156 @@
"""Tests for just command listing functionality.
These tests verify that all listing commands work correctly and
that all expected modules are accessible.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestListCommands:
"""Test just command listing functionality."""
@pytest.mark.just_syntax
def test_default_lists_commands(self, just: JustRunner) -> None:
"""Default command should list available commands."""
result = just.run("default", timeout=10)
assert result.success, f"Failed: {result.stderr}"
# Should show at least some commands
assert "setup" in result.stdout or "all" in result.stdout
@pytest.mark.just_syntax
def test_list_all_shows_all_modules(self, just: JustRunner) -> None:
"""list-all should show commands from all modules."""
result = just.run("list-all", timeout=10)
assert result.success, f"Failed: {result.stderr}"
# Should include module headers (e.g., "quality:" or "testing:")
assert "quality:" in result.stdout or "testing:" in result.stdout
@pytest.mark.just_syntax
@pytest.mark.parametrize(
"module",
[
"coderabbit",
"dev",
"docker",
"documentation",
"freecad",
"install",
"mcp",
"quality",
"release",
"testing",
],
)
def test_list_module_commands(self, just: JustRunner, module: str) -> None:
"""Each module listing command should work."""
result = just.run(f"list-{module}", timeout=10)
assert result.success, f"list-{module} failed: {result.stderr}"
# Output should not be empty
assert result.stdout.strip(), f"list-{module} returned empty output"
class TestModulesExist:
"""Test that all expected modules and commands exist."""
# Map of modules to their expected commands (subset for validation)
EXPECTED_COMMANDS: ClassVar[dict[str, list[str]]] = {
"quality": ["check", "format", "lint", "typecheck", "security"],
"testing": ["unit", "cov", "fast", "integration"],
"dev": ["install-deps", "install-pre-commit", "clean"],
"docker": ["build", "run", "clean"],
"documentation": ["build", "serve", "open"],
"install": ["mcp-server", "mcp-bridge-workbench", "status"],
"mcp": ["run", "check"],
"freecad": ["run-gui", "run-headless"],
"release": ["status", "list-tags", "latest-versions"],
"coderabbit": ["install", "review"],
}
@pytest.mark.just_syntax
@pytest.mark.parametrize("module,commands", EXPECTED_COMMANDS.items())
def test_module_has_expected_commands(
self, just: JustRunner, module: str, commands: list[str]
) -> None:
"""Each module should have its expected commands."""
# Use list_commands to get parsed command names
available_commands = just.list_commands(module)
assert available_commands, f"No commands found in {module} module"
for cmd in commands:
assert cmd in available_commands, (
f"Command '{cmd}' not found in {module} module. "
f"Available: {available_commands}"
)
class TestSyntaxValidation:
"""Test that all commands have valid syntax (dry-run)."""
# Commands that can be safely dry-run tested
SAFE_DRY_RUN_COMMANDS: ClassVar[list[str]] = [
# Main justfile
"setup",
"all",
# Quality commands
"quality::check",
"quality::format",
"quality::lint",
"quality::typecheck",
"quality::security",
"quality::scan",
"quality::markdown-lint",
# Testing commands
"testing::unit",
"testing::cov",
"testing::fast",
"testing::verbose",
# Dev commands
"dev::install-deps",
"dev::install-pre-commit",
"dev::clean",
"dev::validate",
# Documentation commands
"documentation::build",
"documentation::build-strict",
"documentation::serve",
"documentation::open",
# Docker commands (dry-run safe)
"docker::build",
"docker::run",
"docker::shell",
"docker::inspect",
"docker::clean",
# Install commands
"install::mcp-server",
"install::mcp-bridge-workbench",
"install::macro-cut",
"install::macro-export",
"install::status",
# MCP commands
"mcp::check",
"mcp::run",
# FreeCAD commands
"freecad::run-gui",
"freecad::run-headless",
# Release commands (read-only ones)
"release::status",
"release::list-tags",
"release::latest-versions",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", SAFE_DRY_RUN_COMMANDS)
def test_command_syntax_valid(self, just: JustRunner, command: str) -> None:
"""Command should have valid syntax (just --dry-run succeeds)."""
result = just.dry_run(command)
assert result.success, (
f"Syntax error in '{command}': {result.stderr}\n{result.stdout}"
)
+87
View File
@@ -0,0 +1,87 @@
"""Tests for main justfile commands.
These tests verify that the top-level commands in the main justfile work correctly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestMainSyntax:
"""Syntax validation tests for main justfile commands."""
MAIN_COMMANDS: ClassVar[list[str]] = [
"default",
"setup",
"all",
"all-with-integration",
"list-all",
"list-coderabbit",
"list-dev",
"list-docker",
"list-documentation",
"list-freecad",
"list-install",
"list-mcp",
"list-quality",
"list-release",
"list-testing",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", MAIN_COMMANDS)
def test_main_command_syntax(self, just: JustRunner, command: str) -> None:
"""Main justfile command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestMainRuntime:
"""Runtime tests for main justfile commands."""
@pytest.mark.just_runtime
def test_default_shows_help(self, just: JustRunner) -> None:
"""Default command should show available commands."""
result = just.run("default", timeout=10)
assert result.success, f"Default failed: {result.stderr}"
# Should show some commands
assert "setup" in result.stdout or "all" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"module",
[
"coderabbit",
"dev",
"docker",
"documentation",
"freecad",
"install",
"mcp",
"quality",
"release",
"testing",
],
)
def test_list_module_shows_commands(self, just: JustRunner, module: str) -> None:
"""Each list-<module> command should show module commands."""
result = just.run(f"list-{module}", timeout=10)
assert result.success, f"list-{module} failed: {result.stderr}"
# Should have some output
assert result.stdout.strip()
@pytest.mark.just_runtime
def test_list_all_comprehensive(self, just: JustRunner) -> None:
"""list-all should show commands from multiple modules."""
result = just.run("list-all", timeout=10)
assert result.success, f"list-all failed: {result.stderr}"
# Should include module headers (using : separator for headers)
module_headers = ["quality:", "testing:", "dev:", "docker:"]
found_modules = sum(1 for header in module_headers if header in result.stdout)
assert found_modules >= 2, "list-all should show commands from multiple modules"

Some files were not shown because too many files have changed in this diff Show More