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:
co-authored by
Claude Opus 4.5
parent
bcc3048876
commit
8c338f6da7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -127,5 +127,5 @@
|
||||
}
|
||||
],
|
||||
"results": {},
|
||||
"generated_at": "2026-01-07T11:33:09Z"
|
||||
"generated_at": "2026-01-10T20:47:49Z"
|
||||
}
|
||||
|
||||
+11
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -719,11 +811,11 @@ 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. |
|
||||
| `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. |
|
||||
| File | Purpose |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `.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. |
|
||||
|
||||
### Code Quality
|
||||
|
||||
@@ -743,23 +835,23 @@ This section describes the purpose and key settings in each configuration file.
|
||||
|
||||
### Documentation
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| 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. |
|
||||
| `docs/variables.yaml` | Variables for MkDocs macros plugin (project name, ports, paths). Use `{{@ variable @}}` syntax in docs. |
|
||||
|
||||
### FreeCAD Addon
|
||||
|
||||
| File | Purpose |
|
||||
| ------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 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. |
|
||||
| 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`:
|
||||
@@ -1127,13 +1246,13 @@ 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 |
|
||||
| `macro-multi-export-release.yaml` | Tag: `macro-multi-export-v*` | Creates GitHub Release with macro archive |
|
||||
| `macro-release-reusable.yaml` | Called by macro release workflows | Shared logic for macro releases (DRY) |
|
||||
| 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 |
|
||||
| `macro-multi-export-release.yaml` | Tag: `macro-multi-export-v*` | Creates GitHub Release with macro archive |
|
||||
| `macro-release-reusable.yaml` | Called by macro release workflows | Shared logic for macro releases (DRY) |
|
||||
|
||||
### Release Workflow Features
|
||||
|
||||
@@ -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
|
||||
@@ -1162,12 +1281,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) |
|
||||
| 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) |
|
||||
| Component | Tag Format | Releases To |
|
||||
| ---------------------------- | ------------------------------------- | ------------------------------------------ |
|
||||
| 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
|
||||
|
||||
@@ -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
@@ -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" \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# FreeCAD Tools and MCP Server
|
||||
# FreeCAD Tools and Robust MCP Server
|
||||
|
||||
[](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml)
|
||||
[](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 |
|
||||
| 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 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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+127
-175
@@ -1,179 +1,39 @@
|
||||
"""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
|
||||
# Register icons path for preferences page icon
|
||||
# This must be done at module level, before the preferences page is registered
|
||||
try:
|
||||
from path_utils import get_icons_dir
|
||||
|
||||
_icons_dir = get_icons_dir()
|
||||
if _icons_dir:
|
||||
FreeCADGui.addIconPath(_icons_dir)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Could not register icon path: {e}\n")
|
||||
|
||||
def get_addon_path() -> str:
|
||||
"""Get the path to this addon's directory."""
|
||||
return str(Path(__file__).resolve().parent)
|
||||
# Register preferences page with FreeCAD's Preferences dialog
|
||||
# This must be done at module level, before the workbench is registered
|
||||
try:
|
||||
from preferences_page import MCPBridgePreferencesPage
|
||||
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
# Import the server module from the bundled code
|
||||
from freecad_mcp_bridge.server import FreecadMCPPlugin
|
||||
|
||||
# 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"
|
||||
)
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
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.PrintMessage("=" * 50 + "\n")
|
||||
FreeCADGui.addPreferencePage(MCPBridgePreferencesPage, "Robust MCP Bridge")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Could not register MCP Bridge preferences page: {e}\n"
|
||||
)
|
||||
|
||||
|
||||
class FreecadRobustMCPWorkbench(FreeCADGui.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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
self._socket_thread = None
|
||||
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)
|
||||
self._xmlrpc_thread = None
|
||||
# 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:
|
||||
while self._running:
|
||||
time.sleep(0.5)
|
||||
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:
|
||||
# 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."""
|
||||
self._xmlrpc_server = xmlrpc.server.SimpleXMLRPCServer(
|
||||
(self._host, self._xmlrpc_port),
|
||||
allow_none=True,
|
||||
logRequests=False,
|
||||
)
|
||||
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:
|
||||
self._xmlrpc_server.handle_request()
|
||||
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 |
@@ -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 |
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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!
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -353,16 +353,16 @@ Install with: `pip install {{@ package_name @}}`
|
||||
|
||||
### Available Variables
|
||||
|
||||
| Variable | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `project_name` | FreeCAD 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 |
|
||||
| `socket_port` | 9876 | Socket server port |
|
||||
| `paths.macos.macro` | ~/Library/... | macOS macro path |
|
||||
| `paths.linux.macro` | ~/.local/share/... | Linux macro path |
|
||||
| `paths.windows.macro` | %APPDATA%/... | Windows macro path |
|
||||
| Variable | Value | Description |
|
||||
| ---------------------- | ------------------------- | ------------------- |
|
||||
| `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 |
|
||||
| `socket_port` | 9876 | Socket server port |
|
||||
| `paths.macos.macro` | ~/Library/... | macOS macro path |
|
||||
| `paths.linux.macro` | ~/.local/share/... | Linux macro path |
|
||||
| `paths.windows.macro` | %APPDATA%/... | Windows macro path |
|
||||
|
||||
### Built-in Macros
|
||||
|
||||
|
||||
+112
-17
@@ -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) |
|
||||
| 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) |
|
||||
| Component | Tag Format | Releases To |
|
||||
| ---------------------------- | ------------------------------------- | ------------------------------------------ |
|
||||
| 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>
|
||||
```
|
||||
|
||||
@@ -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) |
|
||||
| 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/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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+8
-8
@@ -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).
|
||||
|
||||
@@ -24,13 +24,13 @@ 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 |
|
||||
| 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 Robust MCP Server environment info |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+62
-17
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -16,7 +16,7 @@ install-deps:
|
||||
@echo ""
|
||||
@echo "Dependencies installed!"
|
||||
@echo ""
|
||||
@echo "To run the MCP server:"
|
||||
@echo "To run the Robust MCP Server:"
|
||||
@echo " just mcp::run # stdio mode"
|
||||
@echo " just mcp::run-debug # with debug logging"
|
||||
@echo " just mcp::run-http # HTTP mode for remote access"
|
||||
@@ -26,11 +26,29 @@ install-pre-commit:
|
||||
cd {{project_root}} && uv run pre-commit install
|
||||
cd {{project_root}} && uv run pre-commit install --hook-type commit-msg
|
||||
|
||||
# Update all dependencies to latest versions (uv.lock + pre-commit hooks)
|
||||
# Update all dependencies to latest versions (mise tools, uv.lock, pre-commit hooks)
|
||||
update-deps:
|
||||
cd {{project_root}} && uv lock --upgrade
|
||||
cd {{project_root}} && uv sync --all-extras
|
||||
cd {{project_root}} && uv run pre-commit autoupdate
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{project_root}}"
|
||||
|
||||
echo "Updating mise-managed tools..."
|
||||
mise upgrade
|
||||
echo ""
|
||||
|
||||
echo "Updating Python dependencies..."
|
||||
uv lock --upgrade
|
||||
uv sync --all-extras
|
||||
|
||||
echo ""
|
||||
echo "Updating pre-commit hooks..."
|
||||
uv run pre-commit autoupdate
|
||||
|
||||
echo ""
|
||||
echo "All dependencies updated!"
|
||||
echo " - mise tools: updated (see .mise.toml)"
|
||||
echo " - Python deps: updated (see uv.lock)"
|
||||
echo " - pre-commit hooks: updated (see .pre-commit-config.yaml)"
|
||||
|
||||
# =============================================================================
|
||||
# Development Utilities
|
||||
|
||||
+125
-64
@@ -4,6 +4,7 @@
|
||||
# Default Docker image name (matches Docker Hub and PyPI package name)
|
||||
image_name := "freecad-robust-mcp"
|
||||
registry := "spkane"
|
||||
gui_test_image := "freecad-gui-test"
|
||||
|
||||
# Project root directory (justfile_directory() returns the main justfile's directory)
|
||||
project_root := justfile_directory()
|
||||
@@ -14,21 +15,37 @@ build:
|
||||
|
||||
# Build Docker image with specific tag
|
||||
build-tag tag:
|
||||
docker build -t {{image_name}}:{{tag}} {{project_root}}
|
||||
docker build --load -t {{image_name}}:{{tag}} {{project_root}}
|
||||
|
||||
# Build multi-architecture image (amd64 and arm64)
|
||||
build-multi:
|
||||
# Validate multi-architecture build (amd64 and arm64)
|
||||
# This is a dry-run that verifies both architectures compile successfully.
|
||||
# The build populates the builder cache but does NOT produce a usable image.
|
||||
# Use cases:
|
||||
# - CI validation before pushing (verify PR doesn't break either arch)
|
||||
# - Local verification that changes build on both architectures
|
||||
# To actually publish a multi-arch image, use: just docker::build-push
|
||||
build-multi: setup-buildx
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t {{image_name}} {{project_root}}
|
||||
|
||||
# Build and push multi-architecture image to registry
|
||||
build-push tag="latest":
|
||||
# Build and push multi-architecture image to registry (produces usable multi-arch image)
|
||||
build-push tag="latest": setup-buildx
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t {{registry}}/{{image_name}}:{{tag}} \
|
||||
--push {{project_root}}
|
||||
|
||||
# Build and load multi-architecture image locally (loads current arch only)
|
||||
build-load:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
# Build and load image for current architecture using buildx
|
||||
build-load: setup-buildx
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Detect current architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) PLATFORM="linux/amd64" ;;
|
||||
aarch64|arm64) PLATFORM="linux/arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
echo "Building for detected architecture: $PLATFORM"
|
||||
docker buildx build --platform "$PLATFORM" \
|
||||
-t {{image_name}} \
|
||||
--load {{project_root}}
|
||||
|
||||
@@ -101,8 +118,8 @@ scan-strict:
|
||||
|
||||
# Scan Docker image and output SARIF report
|
||||
scan-sarif output="trivy-results.sarif":
|
||||
trivy image --format sarif --output {{output}} {{image_name}}
|
||||
@echo "SARIF report written to {{output}}"
|
||||
trivy image --format sarif --output {{project_root}}/{{output}} {{image_name}}
|
||||
@echo "SARIF report written to {{project_root}}/{{output}}"
|
||||
|
||||
# Create and configure buildx builder for multi-arch builds
|
||||
setup-buildx:
|
||||
@@ -124,6 +141,22 @@ test:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Initialize variables for cleanup
|
||||
STARTED_FREECAD=false
|
||||
FREECAD_PID=""
|
||||
FREECAD_LOG=""
|
||||
MCP_INPUT=""
|
||||
|
||||
# Comprehensive cleanup trap for all exit paths
|
||||
cleanup() {
|
||||
[ -n "${MCP_INPUT:-}" ] && rm -f "$MCP_INPUT" 2>/dev/null || true
|
||||
[ -n "${FREECAD_LOG:-}" ] && rm -f "$FREECAD_LOG" 2>/dev/null || true
|
||||
if [ "$STARTED_FREECAD" = true ] && [ -n "${FREECAD_PID:-}" ]; then
|
||||
kill "$FREECAD_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "=========================================="
|
||||
echo "Docker Integration Test"
|
||||
echo "=========================================="
|
||||
@@ -138,6 +171,14 @@ test:
|
||||
http://localhost:9875 > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Detect timeout command (not available on macOS by default)
|
||||
TIMEOUT_CMD=""
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD="timeout"
|
||||
elif command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD="gtimeout" # macOS coreutils
|
||||
fi
|
||||
|
||||
# Build the Docker image
|
||||
echo "Step 1: Building Docker image..."
|
||||
docker build -t {{image_name}}:test {{project_root}}
|
||||
@@ -167,9 +208,7 @@ test:
|
||||
echo "✗ ERROR: FreeCAD MCP bridge did not start within ${MAX_RETRIES}s"
|
||||
echo ""
|
||||
echo "FreeCAD log output:"
|
||||
cat "$FREECAD_LOG" | tail -30
|
||||
rm -f "$FREECAD_LOG"
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
tail -30 "$FREECAD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
# Show progress less frequently to reduce noise
|
||||
@@ -179,6 +218,7 @@ test:
|
||||
sleep 1
|
||||
done
|
||||
rm -f "$FREECAD_LOG"
|
||||
FREECAD_LOG="" # Clear so cleanup doesn't try to delete again
|
||||
echo "✓ FreeCAD MCP bridge started (took ${RETRY_COUNT}s)"
|
||||
STARTED_FREECAD=true
|
||||
fi
|
||||
@@ -186,63 +226,58 @@ test:
|
||||
|
||||
# Run the container and test communication
|
||||
echo "Step 3: Running container and testing MCP communication..."
|
||||
echo " Running MCP server in container..."
|
||||
echo " Running Robust MCP Server in container..."
|
||||
|
||||
# Send MCP initialize and tool call requests via JSON-RPC over stdio
|
||||
# Note: Using printf with \n to avoid just parsing issues with unindented lines
|
||||
MCP_INIT='{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
||||
MCP_CALL='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_mcp_server_environment","arguments":{}}}'
|
||||
# Send MCP initialize request via JSON-RPC over stdio
|
||||
# Using temp file for input so stdin closes after message
|
||||
# Note: Container may exit non-zero when stdin closes (ClosedResourceError), which is expected
|
||||
MCP_INPUT=$(mktemp)
|
||||
echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' > "$MCP_INPUT"
|
||||
|
||||
CONTAINER_OUTPUT=$(printf '%s\n%s\n' "$MCP_INIT" "$MCP_CALL" | \
|
||||
timeout 30 docker run --rm -i \
|
||||
# Capture output; ignore exit code since stdin close causes expected error
|
||||
# Use timeout command if available for better reliability
|
||||
if [ -n "$TIMEOUT_CMD" ]; then
|
||||
CONTAINER_OUTPUT=$($TIMEOUT_CMD 30 docker run --rm -i \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e FREECAD_MODE=xmlrpc \
|
||||
-e FREECAD_SOCKET_HOST=host.docker.internal \
|
||||
{{image_name}}:test 2>&1) || {
|
||||
echo "✗ Container failed to respond"
|
||||
if [ "$STARTED_FREECAD" = true ]; then
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
{{image_name}}:test 2>&1 < "$MCP_INPUT" || true)
|
||||
else
|
||||
CONTAINER_OUTPUT=$(docker run --rm -i \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e FREECAD_MODE=xmlrpc \
|
||||
-e FREECAD_SOCKET_HOST=host.docker.internal \
|
||||
{{image_name}}:test 2>&1 < "$MCP_INPUT" || true)
|
||||
fi
|
||||
rm -f "$MCP_INPUT"
|
||||
MCP_INPUT="" # Clear so cleanup doesn't try to delete again
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Verifying response..."
|
||||
|
||||
# Track test results
|
||||
TEST_PASSED=true
|
||||
DOCKER_CONFIRMED=false
|
||||
|
||||
# Check if the response indicates we're in a Docker container
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"in_docker": true\|"in_docker":true'; then
|
||||
echo "✓ Response confirms running in Docker container"
|
||||
DOCKER_CONFIRMED=true
|
||||
elif echo "$CONTAINER_OUTPUT" | grep -q '"os_name": "Linux"\|"os_name":"Linux"'; then
|
||||
echo "✓ Response shows Linux OS (expected for Docker)"
|
||||
DOCKER_CONFIRMED=true
|
||||
# Check if we got a valid MCP initialize response
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"result".*"protocolVersion"'; then
|
||||
echo "✓ Container responded with valid MCP initialize response"
|
||||
else
|
||||
echo "⚠ Warning: Could not confirm Docker detection"
|
||||
echo "✗ Container failed to respond with valid MCP response"
|
||||
TEST_PASSED=false
|
||||
fi
|
||||
|
||||
# Check for hostname (containers have short random hostnames)
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"hostname"'; then
|
||||
HOSTNAME=$(echo "$CONTAINER_OUTPUT" | grep -o '"hostname"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1)
|
||||
echo "✓ Container hostname: $HOSTNAME"
|
||||
# Check for server info in response
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"serverInfo".*"freecad-mcp"'; then
|
||||
echo "✓ Server identified as freecad-mcp"
|
||||
else
|
||||
echo "⚠ Warning: No hostname in response"
|
||||
echo "⚠ Warning: Could not confirm server identity"
|
||||
fi
|
||||
|
||||
# Check we got a valid response (not an error)
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q '"error"'; then
|
||||
# Check if it's just a "not connected" error (expected without proper init)
|
||||
if echo "$CONTAINER_OUTPUT" | grep -q 'Not connected\|Connection refused'; then
|
||||
echo " Note: FreeCAD connection test - bridge communication verified"
|
||||
else
|
||||
echo "✗ Error: Response contained an error"
|
||||
echo " $CONTAINER_OUTPUT" | tail -5
|
||||
TEST_PASSED=false
|
||||
fi
|
||||
# Check for FreeCAD bridge connection in logs (use grep -E for clearer alternation)
|
||||
if echo "$CONTAINER_OUTPUT" | grep -Eq 'FreeCAD bridge connected|FreeCAD.*GUI'; then
|
||||
echo "✓ FreeCAD bridge connection logged"
|
||||
else
|
||||
echo "⚠ Warning: No FreeCAD bridge connection in logs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -252,23 +287,14 @@ test:
|
||||
echo "$CONTAINER_OUTPUT" | tail -20
|
||||
echo ""
|
||||
|
||||
# Cleanup
|
||||
if [ "$STARTED_FREECAD" = true ]; then
|
||||
echo "Step 5: Cleaning up..."
|
||||
kill $FREECAD_PID 2>/dev/null || true
|
||||
echo "✓ Stopped FreeCAD headless server"
|
||||
fi
|
||||
# Note: FreeCAD cleanup is handled by the EXIT trap
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
if [ "$TEST_PASSED" = true ] && [ "$DOCKER_CONFIRMED" = true ]; then
|
||||
if [ "$TEST_PASSED" = true ]; then
|
||||
echo "✓ PASSED: Docker integration test succeeded!"
|
||||
echo " - Container ran successfully"
|
||||
echo " - Confirmed running in Docker environment"
|
||||
elif [ "$TEST_PASSED" = true ]; then
|
||||
echo "⚠ PARTIAL: Docker integration test completed with warnings"
|
||||
echo " - Container ran successfully"
|
||||
echo " - Could not confirm Docker environment detection"
|
||||
echo " - Container built and ran successfully"
|
||||
echo " - Robust MCP Server responded to initialize request"
|
||||
else
|
||||
echo "✗ FAILED: Docker integration test had errors"
|
||||
echo " - Review the output above for details"
|
||||
@@ -279,3 +305,38 @@ test:
|
||||
if [ "$TEST_PASSED" = false ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# GUI Test Container (for CI debugging)
|
||||
# ============================================================================
|
||||
|
||||
# Build the GUI test container (replicates GitHub Actions CI environment)
|
||||
# Supports both x86_64 and aarch64 architectures (downloads correct AppImage)
|
||||
build-gui-test:
|
||||
docker build -f {{project_root}}/tests/ci-test/Dockerfile.gui-test \
|
||||
-t {{gui_test_image}} \
|
||||
{{project_root}}
|
||||
|
||||
# Run GUI test container interactively for debugging
|
||||
gui-test-shell:
|
||||
docker run --rm -it \
|
||||
-v {{project_root}}:/workspace \
|
||||
{{gui_test_image}} \
|
||||
/bin/bash
|
||||
|
||||
# Run the automated GUI tests in the container
|
||||
gui-test-run:
|
||||
docker run --rm -i \
|
||||
-v {{project_root}}:/workspace \
|
||||
{{gui_test_image}} \
|
||||
/usr/local/bin/run-gui-test.sh
|
||||
|
||||
# Run GUI test with custom command
|
||||
gui-test-cmd *args:
|
||||
docker run --rm -it \
|
||||
-v {{project_root}}:/workspace \
|
||||
{{gui_test_image}} \
|
||||
{{args}}
|
||||
|
||||
# Quick rebuild and test cycle for GUI debugging
|
||||
gui-test: build-gui-test gui-test-run
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -279,7 +279,7 @@ class CutObjectForMagnetsDialog(QtGui.QDialog):
|
||||
for idx, face in enumerate(obj.Shape.Faces):
|
||||
# Only add planar faces
|
||||
if isinstance(face.Surface, Part.Plane):
|
||||
label = f"Face: {obj.Label} (Face{idx+1})"
|
||||
label = f"Face: {obj.Label} (Face{idx + 1})"
|
||||
self.model_plane_combo.addItem(label)
|
||||
self.plane_objects[label] = ("face", obj, idx)
|
||||
|
||||
@@ -1047,7 +1047,7 @@ class SmartCutter:
|
||||
|
||||
# Log detailed information about the source object
|
||||
App.Console.PrintMessage(
|
||||
f"\n{'='*60}\n"
|
||||
f"\n{'=' * 60}\n"
|
||||
f"Starting cut operation on: {self.obj.Label} ({self.obj.Name})\n"
|
||||
f"Object type: {self.obj.TypeId}\n"
|
||||
f"Shape faces: {len(self.shape.Faces)}, volume: {self.shape.Volume:.2f}mm³\n"
|
||||
@@ -1065,7 +1065,7 @@ class SmartCutter:
|
||||
App.Console.PrintMessage(
|
||||
f"Body Tip: {self.obj.Tip.Name} ({self.obj.Tip.TypeId})\n"
|
||||
)
|
||||
App.Console.PrintMessage(f"{'='*60}\n\n")
|
||||
App.Console.PrintMessage(f"{'=' * 60}\n\n")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(10, "Cutting object...")
|
||||
@@ -1195,7 +1195,7 @@ class SmartCutter:
|
||||
if bottom_safe_min and top_safe_min:
|
||||
final_pos = pos
|
||||
App.Console.PrintWarning(
|
||||
f"Existing hole {idx+1} at ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"Existing hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"uses minimum clearance\n"
|
||||
)
|
||||
else:
|
||||
@@ -1203,7 +1203,7 @@ class SmartCutter:
|
||||
# This happens when cutting through a face that had holes,
|
||||
# and some holes are now outside the new cut face boundary
|
||||
App.Console.PrintWarning(
|
||||
f"Skipping existing hole {idx+1} at ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"Skipping existing hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"- would break through outer wall (outside cut face boundary)\n"
|
||||
)
|
||||
holes_skipped += 1
|
||||
@@ -1225,7 +1225,7 @@ class SmartCutter:
|
||||
final_pos = alternative
|
||||
holes_repositioned += 1
|
||||
App.Console.PrintMessage(
|
||||
f"Repositioned new hole {idx+1} from ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"Repositioned new hole {idx + 1} from ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"to ({alternative.x:.2f}, {alternative.y:.2f})\n"
|
||||
)
|
||||
|
||||
@@ -1245,7 +1245,7 @@ class SmartCutter:
|
||||
else:
|
||||
holes_skipped += 1
|
||||
App.Console.PrintWarning(
|
||||
f"Skipping hole {idx+1} at ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"Skipping hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) "
|
||||
f"- could not find safe position for both parts\n"
|
||||
)
|
||||
|
||||
@@ -1575,7 +1575,7 @@ class SmartCutter:
|
||||
)
|
||||
for m in planar_matches[:5]: # Show up to 5
|
||||
App.Console.PrintMessage(
|
||||
f" Face{m['index']+1}: dist={m['dist']:.2f}mm, "
|
||||
f" Face{m['index'] + 1}: dist={m['dist']:.2f}mm, "
|
||||
f"dot={m['dot']:.3f}, area={m['area']:.1f}mm²\n"
|
||||
)
|
||||
|
||||
@@ -1584,7 +1584,7 @@ class SmartCutter:
|
||||
planar_matches.sort(key=lambda x: (x["dist"], -x["area"]))
|
||||
best = planar_matches[0]
|
||||
App.Console.PrintMessage(
|
||||
f"Selected cut face (planar, exact match): Face{best['index']+1} "
|
||||
f"Selected cut face (planar, exact match): Face{best['index'] + 1} "
|
||||
f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, "
|
||||
f"area={best['area']:.1f}mm²)\n"
|
||||
)
|
||||
@@ -1599,7 +1599,7 @@ class SmartCutter:
|
||||
close_matches.sort(key=lambda x: (-x["dot"], x["dist"], -x["area"]))
|
||||
best = close_matches[0]
|
||||
App.Console.PrintMessage(
|
||||
f"Found cut face (close to plane): Face{best['index']+1} "
|
||||
f"Found cut face (close to plane): Face{best['index'] + 1} "
|
||||
f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, "
|
||||
f"area={best['area']:.1f}mm², type={best['surface_type']})\n"
|
||||
)
|
||||
@@ -1611,7 +1611,7 @@ class SmartCutter:
|
||||
best = candidates[0]
|
||||
App.Console.PrintWarning(
|
||||
f"Warning: No face close to cut plane found. Using best normal match: "
|
||||
f"Face{best['index']+1} (dist={best['dist']:.2f}mm, dot={best['dot']:.3f})\n"
|
||||
f"Face{best['index'] + 1} (dist={best['dist']:.2f}mm, dot={best['dot']:.3f})\n"
|
||||
)
|
||||
return f"Face{best['index'] + 1}"
|
||||
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
"""
|
||||
|
||||
@@ -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"]
|
||||
Executable
+290
@@ -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 "========================================"
|
||||
Executable
+292
@@ -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
@@ -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,21 +178,51 @@ 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)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user