Fix: rename workbench and prepare for release (#27)

* chore: rename workbench to FreecadRobustMCPBridge

* fix: stdio cleanup and bug fix for JSON RPC

* fix: update FreeCAD MCP bridge and tests

* test: Fix GUI Integration tests and other issues

* fix: unit tests in CI and a few other things

* docs: generate GitHub pages site and link to it.

* fix: General code improvements and DRY refactoring

* chore: General cleanup

* chore: small fixes

* docs: cleanup

* docs: fixes

* docs: small corrections

* docs: fix

* test: release check

* bump: workbench v0.6.0

* chore: bump macros to 0.6.0

* docs: Release improvements

* refactor: improve DRYness of code
This commit is contained in:
Sean P. Kane
2026-01-12 09:30:05 -08:00
committed by GitHub
parent 8c338f6da7
commit f14ab8177d
92 changed files with 2740 additions and 727 deletions
+2 -2
View File
@@ -64,7 +64,7 @@ reviews:
- Proper docstrings for tool discovery - Proper docstrings for tool discovery
- Consistent error handling patterns - Consistent error handling patterns
- GUI-safe checks (FreeCAD.GuiUp) for view operations - GUI-safe checks (FreeCAD.GuiUp) for view operations
- path: "addon/FreecadRobustMCP/**/*.py" - path: "addon/FreecadRobustMCPBridge/**/*.py"
instructions: > instructions: >
This code runs inside FreeCAD's Python environment as a workbench addon. This code runs inside FreeCAD's Python environment as a workbench addon.
It cannot import packages from the project's virtualenv (mcp, pydantic). It cannot import packages from the project's virtualenv (mcp, pydantic).
@@ -76,7 +76,7 @@ reviews:
- path: "tests/**/*.py" - path: "tests/**/*.py"
instructions: > instructions: >
Test files. Ensure good test coverage and clear assertions. Test files. Ensure good test coverage and clear assertions.
Integration tests require FreeCAD MCP bridge to be running. Integration tests require FreeCAD Robust MCP Bridge to be running.
- path: ".github/workflows/**/*.yaml" - path: ".github/workflows/**/*.yaml"
instructions: > instructions: >
GitHub Actions workflows. Check for: GitHub Actions workflows. Check for:
+125
View File
@@ -0,0 +1,125 @@
# Documentation deployment workflow
# Deploys MkDocs documentation to GitHub Pages with versioning via mike
#
# Deployment strategy:
# - Push to main: Deploys as "latest" (always the current main branch docs)
# - MCP server release tag: Deploys versioned docs (e.g., "1.0.0") as permanent snapshots
#
# The "latest" alias always points to the main branch documentation.
# Tagged releases get permanent versioned documentation (e.g., "1.0.0", "1.1.0").
# Users can switch between versions using the version selector in the docs.
name: Deploy Documentation
on:
push:
branches:
- main
paths:
- "docs/**"
- "mkdocs.yaml"
- "src/**"
- ".github/workflows/docs.yaml"
tags:
# Deploy versioned docs when MCP server is released
- "robust-mcp-server-v*"
workflow_dispatch:
inputs:
version:
description: "Version to deploy (e.g., 1.0.0, dev)"
required: false
default: "dev"
set_latest:
description: "Set this version as 'latest' alias"
required: false
default: false
type: boolean
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: write
pages: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for git-revision-date plugin
- name: Configure Git for mike
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install dependencies
run: uv sync --all-extras --frozen
- name: Determine version to deploy
id: version
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Manual dispatch - use provided version
VERSION="${{ github.event.inputs.version }}"
SET_LATEST="${{ github.event.inputs.set_latest }}"
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
# Tag push - extract version from tag
# robust-mcp-server-v1.0.0 -> 1.0.0
TAG="${{ github.ref_name }}"
VERSION="${TAG#robust-mcp-server-v}"
# Tagged releases never set latest (main branch is always latest)
SET_LATEST="false"
else
# Push to main - deploy as "latest"
VERSION="latest"
SET_LATEST="true"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "set_latest=${SET_LATEST}" >> "$GITHUB_OUTPUT"
echo "Deploying version: ${VERSION} (set_latest: ${SET_LATEST})"
- name: Deploy latest documentation (main branch)
if: steps.version.outputs.version == 'latest'
run: |
# Deploy main branch docs as "latest" and set as default
# Run deploy without --push, then set-default with --push for single git push
uv run mike deploy --update-aliases latest
uv run mike set-default --push latest
- name: Deploy versioned documentation (tagged release)
if: steps.version.outputs.version != 'latest'
run: |
VERSION="${{ steps.version.outputs.version }}"
SET_LATEST="${{ steps.version.outputs.set_latest }}"
if [[ "$SET_LATEST" == "true" ]]; then
# Manual dispatch requested setting as latest
# Run deploy without --push, then set-default with --push for single git push
uv run mike deploy --update-aliases "$VERSION" latest
uv run mike set-default --push latest
else
# Deploy version only (tagged releases don't update latest)
uv run mike deploy --push "$VERSION"
fi
- name: List deployed versions
run: uv run mike list
@@ -143,6 +143,21 @@ jobs:
exit 1 exit 1
fi fi
# Check wiki-source.txt
WIKI_FILE="macros/${MACRO_DIR}/wiki-source.txt"
if [ -f "$WIKI_FILE" ]; then
WIKI_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_FILE" | cut -d= -f2 | tr -d '\n')
if [ "$WIKI_VERSION" != "$VERSION" ]; then
echo "ERROR: Version mismatch in $WIKI_FILE"
echo " Expected: $VERSION"
echo " Found: $WIKI_VERSION"
echo ""
echo "Run the appropriate bump command first."
exit 1
fi
echo "✓ $WIKI_FILE: $WIKI_VERSION"
fi
# Check package.xml # Check package.xml
PKG_VERSION=$(awk -v name="$MACRO_NAME" ' PKG_VERSION=$(awk -v name="$MACRO_NAME" '
/<macro>/ { in_macro=1 } /<macro>/ { in_macro=1 }
+4 -4
View File
@@ -7,7 +7,7 @@ on:
- "src/freecad_mcp/**/*.py" - "src/freecad_mcp/**/*.py"
- "macros/**/*.FCMacro" - "macros/**/*.FCMacro"
- "macros/**/*.py" - "macros/**/*.py"
- "addon/FreecadRobustMCP/**/*.py" - "addon/FreecadRobustMCPBridge/**/*.py"
- "tests/integration/**/*.py" - "tests/integration/**/*.py"
- ".github/workflows/macro-test.yaml" - ".github/workflows/macro-test.yaml"
- ".github/actions/setup-freecad/**" - ".github/actions/setup-freecad/**"
@@ -17,7 +17,7 @@ on:
- "src/freecad_mcp/**/*.py" - "src/freecad_mcp/**/*.py"
- "macros/**/*.FCMacro" - "macros/**/*.FCMacro"
- "macros/**/*.py" - "macros/**/*.py"
- "addon/FreecadRobustMCP/**/*.py" - "addon/FreecadRobustMCPBridge/**/*.py"
- "tests/integration/**/*.py" - "tests/integration/**/*.py"
- ".github/workflows/macro-test.yaml" - ".github/workflows/macro-test.yaml"
- ".github/actions/setup-freecad/**" - ".github/actions/setup-freecad/**"
@@ -73,7 +73,7 @@ jobs:
# Start FreeCAD headless with MCP bridge in background # Start FreeCAD headless with MCP bridge in background
# Uses the workbench addon's blocking bridge script # Uses the workbench addon's blocking bridge script
# Use setsid to create a new process group for reliable cleanup # 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 & setsid freecadcmd addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 &
FREECAD_PID=$! FREECAD_PID=$!
echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV" echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV"
@@ -288,7 +288,7 @@ jobs:
# Uses blocking_bridge.py which blocks with run_forever() to keep process alive # Uses blocking_bridge.py which blocks with run_forever() to keep process alive
# GUI features are available since we're using 'freecad' not 'freecadcmd' # GUI features are available since we're using 'freecad' not 'freecadcmd'
# Use setsid to create a new process group for reliable cleanup # 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 & setsid freecad addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_gui.log 2>&1 &
FREECAD_PID=$! FREECAD_PID=$!
echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV" echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV"
+20 -5
View File
@@ -55,7 +55,7 @@ jobs:
echo "Verifying version in source files matches tag: $VERSION" echo "Verifying version in source files matches tag: $VERSION"
# Check __version__ in __init__.py # Check __version__ in __init__.py
INIT_FILE="addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py" INIT_FILE="addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py"
INIT_VERSION=$(grep -o '__version__ = "[^"]*"' "$INIT_FILE" | cut -d'"' -f2) INIT_VERSION=$(grep -o '__version__ = "[^"]*"' "$INIT_FILE" | cut -d'"' -f2)
if [ "$INIT_VERSION" != "$VERSION" ]; then if [ "$INIT_VERSION" != "$VERSION" ]; then
echo "ERROR: Version mismatch in $INIT_FILE" echo "ERROR: Version mismatch in $INIT_FILE"
@@ -68,6 +68,21 @@ jobs:
fi fi
echo "✓ $INIT_FILE: $INIT_VERSION" echo "✓ $INIT_FILE: $INIT_VERSION"
# Check wiki-source.txt
WIKI_FILE="addon/FreecadRobustMCPBridge/wiki-source.txt"
if [ -f "$WIKI_FILE" ]; then
WIKI_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_FILE" | cut -d= -f2 | tr -d '\n')
if [ "$WIKI_VERSION" != "$VERSION" ]; then
echo "ERROR: Version mismatch in $WIKI_FILE"
echo " Expected: $VERSION"
echo " Found: $WIKI_VERSION"
echo ""
echo "Run: just release::bump-workbench $VERSION"
exit 1
fi
echo "✓ $WIKI_FILE: $WIKI_VERSION"
fi
# Check package.xml # Check package.xml
PKG_VERSION=$(awk '/<workbench>/,/<\/workbench>/' package.xml | grep -o '<version>[^<]*</version>' | head -1 | sed 's/<[^>]*>//g') PKG_VERSION=$(awk '/<workbench>/,/<\/workbench>/' package.xml | grep -o '<version>[^<]*</version>' | head -1 | sed 's/<[^>]*>//g')
if [ "$PKG_VERSION" != "$VERSION" ]; then if [ "$PKG_VERSION" != "$VERSION" ]; then
@@ -89,7 +104,7 @@ jobs:
mkdir -p "staging/freecad-mcp-workbench-${VERSION}" mkdir -p "staging/freecad-mcp-workbench-${VERSION}"
# Copy workbench files # Copy workbench files
cp -r addon/FreecadRobustMCP/* "staging/freecad-mcp-workbench-${VERSION}/" cp -r addon/FreecadRobustMCPBridge/* "staging/freecad-mcp-workbench-${VERSION}/"
# Copy LICENSE # Copy LICENSE
cp LICENSE "staging/freecad-mcp-workbench-${VERSION}/" cp LICENSE "staging/freecad-mcp-workbench-${VERSION}/"
@@ -114,9 +129,9 @@ jobs:
Copy the contents of this archive to your FreeCAD Mod directory: Copy the contents of this archive to your FreeCAD Mod directory:
- **macOS**: \`~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCP/\` - **macOS**: \`~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCPBridge/\`
- **Linux**: \`~/.local/share/FreeCAD/Mod/FreecadRobustMCP/\` - **Linux**: \`~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/\`
- **Windows**: \`%APPDATA%/FreeCAD/Mod/FreecadRobustMCP/\` - **Windows**: \`%APPDATA%/FreeCAD/Mod/FreecadRobustMCPBridge/\`
## Usage ## Usage
+1 -1
View File
@@ -3,7 +3,7 @@
# #
# This configuration extends the default rules with project-specific settings. # This configuration extends the default rules with project-specific settings.
title = "FreeCAD MCP Gitleaks Configuration" title = "FreeCAD Robust MCP Gitleaks Configuration"
[extend] [extend]
# Extend the default gitleaks configuration # Extend the default gitleaks configuration
+1 -1
View File
@@ -127,5 +127,5 @@
} }
], ],
"results": {}, "results": {},
"generated_at": "2026-01-10T20:47:49Z" "generated_at": "2026-01-12T16:32:14Z"
} }
+7
View File
@@ -26,6 +26,13 @@ This is a multi-component project. Each component has its own versioning and rel
### Robust MCP Bridge Workbench ### Robust MCP Bridge Workbench
#### Fixed
- Fixed GUI crashes during auto-start by deferring bridge initialization 2 seconds after `FreeCAD.GuiUp` becomes True
- Increased GUI wait timeout from 3 seconds to 60 seconds to accommodate slow FreeCAD startups on macOS
- Bridge no longer attempts to start on timeout (prevents crash from background thread usage)
- `startup_bridge.py` now has same defensive GUI wait logic as `Init.py`
### Cut Object for Magnets Macro ### Cut Object for Magnets Macro
### Multi Export Macro ### Multi Export Macro
+91 -6
View File
@@ -204,6 +204,8 @@ just testing::all # Run all tests including integration
# Documentation commands # Documentation commands
just documentation::build # Build documentation just documentation::build # Build documentation
just documentation::serve # Serve documentation locally just documentation::serve # Serve documentation locally
just documentation::serve-versioned # Serve versioned docs (from gh-pages)
just documentation::list-versions # List deployed doc versions
# Docker commands # Docker commands
just docker::build # Build Docker image for local architecture just docker::build # Build Docker image for local architecture
@@ -251,7 +253,7 @@ just all-with-integration # Run all checks and integration tests
| `quality` | Code quality and linting | `check`, `lint`, `format`, `scan` | | `quality` | Code quality and linting | `check`, `lint`, `format`, `scan` |
| `testing` | Test execution | `unit`, `cov`, `integration-freecad-auto`, `watch` | | `testing` | Test execution | `unit`, `cov`, `integration-freecad-auto`, `watch` |
| `docker` | Docker build and run commands | `build`, `build-multi`, `run`, `clean-all` | | `docker` | Docker build and run commands | `build`, `build-multi`, `run`, `clean-all` |
| `documentation` | Documentation building | `build`, `serve`, `open` | | `documentation` | Documentation building and deployment | `build`, `serve`, `serve-versioned`, `list-versions`|
| `dev` | Development utilities | `install-deps`, `update-deps`, `clean` | | `dev` | Development utilities | `install-deps`, `update-deps`, `clean` |
| `release` | Release and tagging | `status`, `tag-mcp-server`, `delete-tag` | | `release` | Release and tagging | `status`, `tag-mcp-server`, `delete-tag` |
| `coderabbit` | AI code reviews (local) | `install`, `login`, `review`, `review-fix` | | `coderabbit` | AI code reviews (local) | `install`, `login`, `review`, `review-fix` |
@@ -484,13 +486,41 @@ To avoid conflicts with Python dict literals in code blocks, this project uses c
Variables are defined in `docs/variables.yaml`: Variables are defined in `docs/variables.yaml`:
```yaml ```yaml
project_name: FreeCAD MCP Server project_name: FreeCAD Robust MCP Suite
xmlrpc_port: 9875 xmlrpc_port: 9875
socket_port: 9876 socket_port: 9876
``` ```
**Reference**: See `docs/development/mkdocs-guide.md` for complete documentation on available extensions (admonitions, tabs, code annotations, mermaid diagrams, etc.). **Reference**: See `docs/development/mkdocs-guide.md` for complete documentation on available extensions (admonitions, tabs, code annotations, mermaid diagrams, etc.).
### Documentation Deployment (GitHub Pages)
Documentation is deployed to GitHub Pages with versioning via **mike**:
- **Automatic deployment**: The `docs.yaml` workflow deploys docs automatically
- **Version selector**: Users can switch between versions in the docs UI
- **"latest" version**: Always reflects the current `main` branch (default landing page)
- **Versioned releases**: Created when MCP server tags (`robust-mcp-server-vX.Y.Z`) are pushed
**Deployment triggers**:
| Trigger | Version Deployed | Sets Default? |
| ---------------------------- | ---------------- | -------------- |
| Push to `main` | `latest` | Yes |
| Tag `robust-mcp-server-v1.0` | `1.0.0` | No |
| Manual workflow dispatch | User-specified | User choice |
**Local testing commands**:
```bash
just documentation::serve-versioned # Serve versioned docs locally
just documentation::list-versions # List deployed versions
just documentation::deploy-dev # Deploy "dev" version locally
just documentation::deploy-latest 1.0.0 # Deploy version and set as latest
```
**Note**: Local `deploy-*` commands modify the `gh-pages` branch locally. The GitHub Actions workflow handles actual deployment to GitHub Pages.
--- ---
## Testing Requirements ## Testing Requirements
@@ -717,7 +747,7 @@ project-root/
│ │ └── test.yaml # Unit/integration tests │ │ └── test.yaml # Unit/integration tests
│ └── dependabot.yaml # Dependency updates │ └── dependabot.yaml # Dependency updates
├── addon/ # FreeCAD addon (workbench) ├── addon/ # FreeCAD addon (workbench)
│ └── FreecadRobustMCP/ # Robust MCP Bridge workbench │ └── FreecadRobustMCPBridge/ # Robust MCP Bridge workbench
│ ├── freecad_mcp_bridge/ # Bridge Python package │ ├── freecad_mcp_bridge/ # Bridge Python package
│ ├── Init.py # FreeCAD workbench init │ ├── Init.py # FreeCAD workbench init
│ ├── InitGui.py # FreeCAD GUI init │ ├── InitGui.py # FreeCAD GUI init
@@ -990,6 +1020,60 @@ else:
pass pass
``` ```
### MCP Bridge Startup Race Condition (Critical Bug Pattern)
**CRITICAL BUG PATTERN**: The MCP bridge must wait for `FreeCAD.GuiUp` to be `True` before starting in GUI mode. Starting the bridge while `FreeCAD.GuiUp` is `False` causes a race condition that leads to crashes.
**The Problem:**
When FreeCAD starts in GUI mode, there's a timing window where:
1. `Init.py` runs when `FreeCAD.GuiUp = False` (GUI not yet initialized)
2. Qt/PySide is available (can import successfully)
3. The bridge starts, sees `GuiUp = False`, and starts a background thread for queue processing
4. FreeCAD GUI finishes initializing (`GuiUp` becomes `True`)
5. Code execution still happens on the background thread
6. Qt operations from the background thread cause SIGABRT crashes
**Symptoms:**
- FreeCAD crashes with `SIGABRT` in `QCocoaWindow::createNSWindow` (macOS)
- Integration tests pass initial connection, then crash on first document operation
- Thread check shows `is_main_thread: False` with `thread_name: 'MCP-QueueProcessor'` even when `gui_up: True`
**The Fix (in `Init.py`):**
When Qt is available but `FreeCAD.GuiUp` is `False`, use a repeating timer to wait for `GuiUp` to become `True` before starting the bridge:
```python
# WRONG - starts bridge immediately, will use background thread
elif QtCore is not None:
_auto_start_bridge()
# CORRECT - wait for GUI to be ready
elif QtCore is not None:
_auto_start_timer = QtCore.QTimer()
_auto_start_timer.setSingleShot(False) # Repeating
_auto_start_timer.timeout.connect(_wait_for_gui_and_start)
_auto_start_timer.start(100) # Check every 100ms
```
**Note:** This QTimer wait pattern is **only for GUI startup scenarios**. In headless mode (`freecadcmd`), the bridge starts directly without waiting because there is no Qt event loop to wait for. The three startup paths are:
1. **GUI already up** (`FreeCAD.GuiUp = True`): Start bridge immediately
2. **GUI starting** (Qt available, `GuiUp = False`): Use QTimer to wait for GUI
3. **Headless** (no Qt): Start bridge immediately with background thread
**Testing:**
An integration test in `tests/integration/test_thread_safety.py` verifies that:
- In GUI mode, code executes on the main thread (not `MCP-QueueProcessor`)
- The queue processor mode matches `FreeCAD.GuiUp` state
- Document creation works without crashing
**Key Lesson:** Never assume Qt availability means GUI is ready. Always check `FreeCAD.GuiUp` before doing operations that depend on the Qt event loop running on the main thread.
### Why NOT to Check for PySide/Qt ### Why NOT to Check for PySide/Qt
**WRONG** - Do not use Qt availability to detect GUI mode: **WRONG** - Do not use Qt availability to detect GUI mode:
@@ -1237,12 +1321,13 @@ This project uses component-specific release workflows along with CI/CD pipeline
### CI Workflows ### CI Workflows
| Workflow | Trigger | Purpose | | Workflow | Trigger | Purpose |
| ------------------ | -------------------- | ---------------------------------------------------------- | | ------------------ | ------------------------------ | --------------------------------------------------------- |
| `test.yaml` | Push, PR | Runs unit tests and integration tests on Ubuntu and macOS | | `test.yaml` | Push, PR | Runs unit tests and integration tests on Ubuntu and macOS |
| `pre-commit.yaml` | Push, PR | Runs all pre-commit hooks for code quality | | `pre-commit.yaml` | Push, PR | Runs all pre-commit hooks for code quality |
| `docker.yaml` | Push, PR | Builds Docker image to verify Dockerfile works | | `docker.yaml` | Push, PR | Builds Docker image to verify Dockerfile works |
| `macro-test.yaml` | Push, PR | Tests FreeCAD macros in headless Docker environment | | `macro-test.yaml` | Push, PR | Tests FreeCAD macros in headless Docker environment |
| `codeql.yaml` | Push, PR, scheduled | GitHub CodeQL security analysis | | `codeql.yaml` | Push, PR, scheduled | GitHub CodeQL security analysis |
| `docs.yaml` | Push to main, MCP server tags | Deploys versioned documentation to GitHub Pages |
### Release Workflows ### Release Workflows
@@ -1411,9 +1496,9 @@ Each component can have a different version, and the release workflows automatic
--- ---
## FreeCAD MCP Tools Reference ## FreeCAD Robust MCP Tools Reference
When Claude Code is connected to the FreeCAD MCP server, the following tools are available for interacting with FreeCAD. Use these tools to control FreeCAD, create/modify objects, and debug issues. When Claude Code is connected to the FreeCAD Robust MCP server, the following tools are available for interacting with FreeCAD. Use these tools to control FreeCAD, create/modify objects, and debug issues.
### Discovering Capabilities at Runtime ### Discovering Capabilities at Runtime
+5 -3
View File
@@ -1,4 +1,4 @@
# FreeCAD Tools and Robust MCP Server # FreeCAD Robust MCP Suite
[![CI Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml) [![CI Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml)
[![Integration Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml) [![Integration Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml)
@@ -9,6 +9,7 @@
[![Python Versions](https://img.shields.io/pypi/pyversions/freecad-robust-mcp)](https://pypi.org/project/freecad-robust-mcp/) [![Python Versions](https://img.shields.io/pypi/pyversions/freecad-robust-mcp)](https://pypi.org/project/freecad-robust-mcp/)
[![Docker Image Version](https://img.shields.io/docker/v/spkane/freecad-robust-mcp?sort=semver&label=docker)](https://hub.docker.com/r/spkane/freecad-robust-mcp) [![Docker Image Version](https://img.shields.io/docker/v/spkane/freecad-robust-mcp?sort=semver&label=docker)](https://hub.docker.com/r/spkane/freecad-robust-mcp)
[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://spkane.github.io/freecad-robust-mcp-and-more/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
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. 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.
@@ -19,7 +20,7 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
<!--TOC--> <!--TOC-->
- [FreeCAD Tools and Robust MCP Server](#freecad-tools-and-robust-mcp-server) - [FreeCAD Robust MCP Suite](#freecad-robust-mcp-suite)
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Features](#features) - [Features](#features)
- [Requirements](#requirements) - [Requirements](#requirements)
@@ -106,6 +107,7 @@ This section covers installation and usage for end users who want to use the Rob
| Resource | Description | | Resource | Description |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------ | | --------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| [**Documentation**](https://spkane.github.io/freecad-robust-mcp-and-more/) | Full documentation, guides, and API reference |
| [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp) | Pre-built Docker images for easy deployment | | [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp) | Pre-built Docker images for easy deployment |
| [PyPI](https://pypi.org/project/freecad-robust-mcp/) | Python package for pip installation | | [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 | | [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases) | Release archives, changelogs, and standalone macro downloads |
@@ -840,7 +842,7 @@ See the [detailed architecture document](docs/development/architecture-detailed.
## Acknowledgements ## Acknowledgements
This project was developed after analyzing several existing FreeCAD MCP implementations. We are grateful to these projects for their pioneering work and the ideas they contributed to the FreeCAD + AI ecosystem: This project was developed after analyzing several existing FreeCAD Robust MCP implementations. We are grateful to these projects for their pioneering work and the ideas they contributed to the FreeCAD + AI ecosystem:
### Related Projects ### Related Projects
@@ -1,45 +0,0 @@
<?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 logo representation (left side) - simplified gear/cog -->
<g id="freecad-icon" transform="translate(10, 22)">
<!-- Gear body -->
<circle cx="10" cy="10" r="8" fill="#e74c3c" stroke="#c0392b" stroke-width="1"/>
<!-- Gear teeth -->
<rect x="7" y="0" width="6" height="3" fill="#e74c3c"/>
<rect x="7" y="17" width="6" height="3" fill="#e74c3c"/>
<rect x="0" y="7" width="3" height="6" fill="#e74c3c"/>
<rect x="17" y="7" width="3" height="6" fill="#e74c3c"/>
<!-- Inner circle -->
<circle cx="10" cy="10" r="4" fill="#2c3e50"/>
</g>
<!-- Claude/AI representation (right side) - stylized brain/network -->
<g id="claude-icon" transform="translate(44, 22)">
<!-- Main circle -->
<circle cx="10" cy="10" r="8" fill="#d4a574" stroke="#b8956a" stroke-width="1"/>
<!-- Network nodes -->
<circle cx="10" cy="6" r="2" fill="#2c3e50"/>
<circle cx="6" cy="12" r="2" fill="#2c3e50"/>
<circle cx="14" cy="12" r="2" fill="#2c3e50"/>
<!-- Network connections -->
<line x1="10" y1="6" x2="6" y2="12" stroke="#2c3e50" stroke-width="1"/>
<line x1="10" y1="6" x2="14" y2="12" stroke="#2c3e50" stroke-width="1"/>
<line x1="6" y1="12" x2="14" y2="12" stroke="#2c3e50" stroke-width="1"/>
</g>
<!-- Bridge/Connection arrows -->
<g id="bridge">
<!-- Arrow from FreeCAD to Claude -->
<line x1="28" y1="28" x2="40" y2="28" stroke="#27ae60" stroke-width="2"/>
<polygon points="40,28 36,25 36,31" fill="#27ae60"/>
<!-- Arrow from Claude to FreeCAD -->
<line x1="40" y1="36" x2="28" y2="36" stroke="#3498db" stroke-width="2"/>
<polygon points="28,36 32,33 32,39" fill="#3498db"/>
</g>
<!-- MCP text label -->
<text x="32" y="56" font-family="Arial, sans-serif" font-size="8" font-weight="bold" fill="#ecf0f1" text-anchor="middle">MCP</text>
<!-- Connection status indicator (green dot) -->
<circle cx="32" cy="10" r="4" fill="#27ae60" stroke="#1e8449" stroke-width="1"/>
<!-- Pulse ring animation hint -->
<circle cx="32" cy="10" r="6" fill="none" stroke="#27ae60" stroke-width="0.5" opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,56 +0,0 @@
"""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,72 +0,0 @@
#!/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")
@@ -1,32 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,43 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,43 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

-44
View File
@@ -1,44 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,32 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- White background -->
<rect width="64" height="64" fill="#ffffff"/>
<!-- Bridge Structure -->
<!-- Bridge deck -->
<rect x="4" y="40" width="56" height="5" rx="1" fill="#8b7355" stroke="#6b5344" stroke-width="1"/>
<!-- Bridge railings -->
<rect x="4" y="37" width="56" height="2" rx="0.5" fill="#a08060"/>
<!-- Bridge supports/pillars -->
<rect x="8" y="45" width="6" height="8" fill="#6b5344"/>
<rect x="50" y="45" width="6" height="8" fill="#6b5344"/>
<!-- Bridge arch underneath -->
<path d="M 14 53 Q 32 47 50 53" fill="none" stroke="#6b5344" stroke-width="2"/>
<!-- Robot on the bridge (facing forward) -->
<!-- Robot body -->
<rect x="24" y="18" width="16" height="14" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot head -->
<rect x="26" y="8" width="12" height="10" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot face plate -->
<rect x="28" y="10" width="8" height="6" rx="1" fill="#e8f4fc"/>
<!-- Robot eyes -->
<circle cx="30" cy="13" r="1.5" fill="#2c5aa0"/>
<circle cx="34" cy="13" r="1.5" fill="#2c5aa0"/>
<!-- Robot antenna -->
<line x1="32" y1="8" x2="32" y2="3" stroke="#2c5aa0" stroke-width="2" stroke-linecap="round"/>
<circle cx="32" cy="2" r="3" fill="#27ae60"/>
<!-- Robot arms -->
<rect x="18" y="20" width="6" height="4" rx="1.5" fill="#3a7bc8"/>
<rect x="40" y="20" width="6" height="4" rx="1.5" fill="#3a7bc8"/>
<!-- Robot legs -->
<rect x="26" y="32" width="4" height="8" rx="1" fill="#3a7bc8"/>
<rect x="34" y="32" width="4" height="8" rx="1" fill="#3a7bc8"/>
<!-- Bidirectional Data Flow (river under bridge) -->
<!-- Data stream background -->
<path d="M 0 60 Q 32 55 64 60" fill="none" stroke="#27ae60" stroke-width="4" opacity="0.3"/>
<!-- Left arrow -->
<path d="M 6 59 L 16 59" stroke="#27ae60" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="6,59 11,56 11,62" fill="#27ae60"/>
<!-- Right arrow -->
<path d="M 48 59 L 58 59" stroke="#27ae60" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="58,59 53,56 53,62" fill="#27ae60"/>
<!-- Center data dots (flowing) -->
<circle cx="26" cy="57" r="2" fill="#27ae60"/>
<circle cx="32" cy="56" r="2" fill="#27ae60"/>
<circle cx="38" cy="57" r="2" fill="#27ae60"/>
<!-- MCP Label on bridge deck -->
<text x="32" y="44" font-family="Arial, Helvetica, sans-serif" font-size="5" font-weight="bold" fill="#ffffff" text-anchor="middle">MCP</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -11,12 +11,21 @@ Note: Status bar updates are handled by InitGui.py since Qt operations
must run on the main thread. must run on the main thread.
""" """
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from freecad_mcp_bridge.bridge_utils import GuiWaiter
import FreeCAD import FreeCAD
FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init loaded\n") FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init loaded\n")
# Global reference to timer to prevent garbage collection # Global reference to GuiWaiter and auto-start timer to prevent garbage collection
_auto_start_timer = None # Type annotations use Any for timer since it could be QTimer from PySide2 or PySide6
_auto_start_timer: Any | None = None
_gui_waiter: GuiWaiter | None = None
def _auto_start_bridge() -> None: def _auto_start_bridge() -> None:
@@ -43,26 +52,24 @@ def _auto_start_bridge() -> None:
) )
# Import and start the bridge directly # Import and start the bridge directly
import commands
from freecad_mcp_bridge.server import FreecadMCPPlugin from freecad_mcp_bridge.server import FreecadMCPPlugin
from preferences import get_socket_port, get_xmlrpc_port from preferences import get_socket_port, get_xmlrpc_port
xmlrpc_port = get_xmlrpc_port() xmlrpc_port = get_xmlrpc_port()
socket_port = get_socket_port() socket_port = get_socket_port()
commands._mcp_plugin = FreecadMCPPlugin( plugin = FreecadMCPPlugin(
host="localhost", host="localhost",
port=socket_port, port=socket_port,
xmlrpc_port=xmlrpc_port, xmlrpc_port=xmlrpc_port,
enable_xmlrpc=True, enable_xmlrpc=True,
) )
commands._mcp_plugin.start() plugin.start()
# Track running configuration for restart detection # Register plugin with commands module for restart detection
commands._running_config = { from freecad_mcp_bridge.bridge_utils import register_mcp_plugin
"xmlrpc_port": xmlrpc_port,
"socket_port": socket_port, register_mcp_plugin(plugin, xmlrpc_port, socket_port)
}
FreeCAD.Console.PrintMessage("\n") FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n") FreeCAD.Console.PrintMessage("=" * 50 + "\n")
@@ -81,9 +88,15 @@ def _auto_start_bridge() -> None:
# Schedule auto-start after FreeCAD finishes loading # Schedule auto-start after FreeCAD finishes loading
# Strategy: # Strategy:
# - If FreeCAD.GuiUp is True: Qt event loop is running, use timer for deferred start # - 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 # - If FreeCAD.GuiUp is False but Qt is available: FreeCAD GUI is initializing.
# (GUI mode starting up - InitGui.py will handle status bar later) # Use GuiWaiter to wait for GuiUp to become True before starting.
# This ensures the bridge uses Qt timer (not background thread) for queue processing.
# - If Qt is not available: Pure headless mode, start bridge directly # - If Qt is not available: Pure headless mode, start bridge directly
#
# CRITICAL: We must wait for FreeCAD.GuiUp to be True before starting the bridge
# in GUI mode. If we start when GuiUp is False, the bridge's _start_queue_processor()
# will see GuiUp=False and use a background thread. Later, code executed on that
# thread will try to do Qt operations, causing crashes (SIGABRT in QCocoaWindow).
try: try:
from preferences import get_auto_start from preferences import get_auto_start
@@ -110,8 +123,18 @@ try:
_auto_start_bridge() _auto_start_bridge()
elif QtCore is not None: elif QtCore is not None:
# GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode) # GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode)
# Start bridge directly - InitGui.py will handle status bar update # Use GuiWaiter to wait for GuiUp to become True before starting
_auto_start_bridge() from freecad_mcp_bridge.bridge_utils import GuiWaiter
_gui_waiter = GuiWaiter(
callback=_auto_start_bridge,
log_prefix="Robust MCP Bridge",
timeout_error_extra=(
"\nTo start the bridge manually, select the Robust MCP Bridge "
"workbench\nand click 'Start MCP Bridge'.\n\n"
),
)
_gui_waiter.start()
else: else:
# True headless mode - no Qt, no GUI # True headless mode - no Qt, no GUI
_auto_start_bridge() _auto_start_bridge()
@@ -36,11 +36,21 @@ except Exception as e:
) )
class FreecadRobustMCPWorkbench(FreeCADGui.Workbench): class FreecadRobustMCPBridgeWorkbench(FreeCADGui.Workbench):
"""FreeCAD Robust MCP Workbench. """Robust MCP Bridge workbench for FreeCAD.
Provides toolbar and menu commands to start, stop, and monitor Provides toolbar and menu commands to start, stop, and monitor the MCP
the MCP bridge server for AI assistant integration. bridge server for AI assistant integration.
Attributes:
MenuText: Workbench display name in FreeCAD.
ToolTip: Short description shown by FreeCAD.
Icon: Icon path used by FreeCAD.
Example:
The workbench is registered at import time by FreeCAD::
FreeCADGui.addWorkbench(FreecadRobustMCPBridgeWorkbench())
""" """
MenuText = "Robust MCP Bridge" MenuText = "Robust MCP Bridge"
@@ -136,7 +146,7 @@ class FreecadRobustMCPWorkbench(FreeCADGui.Workbench):
# Register the workbench # Register the workbench
FreeCADGui.addWorkbench(FreecadRobustMCPWorkbench()) FreeCADGui.addWorkbench(FreecadRobustMCPBridgeWorkbench())
# Schedule status bar sync after a short delay to allow GUI to finish initializing # 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) # This runs on the main thread (InitGui.py is executed on main thread)
@@ -133,7 +133,7 @@ class StartMCPBridgeCommand:
_running_config = None _running_config = None
FreeCAD.Console.PrintError(f"Failed to import MCP Bridge module: {e}\n") FreeCAD.Console.PrintError(f"Failed to import MCP Bridge module: {e}\n")
FreeCAD.Console.PrintError( FreeCAD.Console.PrintError(
"Ensure the FreecadRobustMCP addon is properly installed.\n" "Ensure the FreecadRobustMCPBridge addon is properly installed.\n"
) )
try: try:
from preferences import get_status_bar_enabled from preferences import get_status_bar_enabled
@@ -333,7 +333,7 @@ class MCPBridgePreferencesCommand:
def GetResources(self) -> dict[str, str]: def GetResources(self) -> dict[str, str]:
"""Return the command resources (icon, menu text, tooltip).""" """Return the command resources (icon, menu text, tooltip)."""
return { return {
"Pixmap": get_icon_path("icons/mcp_preferences.svg"), "Pixmap": get_icon_path("icons/preferences-robust_mcp_bridge.svg"),
"MenuText": "MCP Bridge Preferences...", "MenuText": "MCP Bridge Preferences...",
"ToolTip": "Configure MCP Bridge settings (ports, auto-start, etc.)", "ToolTip": "Configure MCP Bridge settings (ports, auto-start, etc.)",
} }
@@ -344,6 +344,11 @@ class MCPBridgePreferencesCommand:
def Activated(self) -> None: def Activated(self) -> None:
"""Execute the command to show preferences dialog.""" """Execute the command to show preferences dialog."""
if not FreeCAD.GuiUp:
FreeCAD.Console.PrintError(
"MCP Bridge Preferences requires FreeCAD GUI mode.\n"
)
return
# Import here to avoid issues during module loading # Import here to avoid issues during module loading
import FreeCADGui import FreeCADGui
from preferences import ( from preferences import (
@@ -1,4 +1,4 @@
"""FreeCAD MCP Bridge - Bundled server module for the workbench addon. """FreeCAD Robust MCP Bridge - Bundled server module for the workbench addon.
SPDX-License-Identifier: MIT SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane) Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
@@ -9,5 +9,5 @@ It is bundled with the workbench addon for self-contained installation.
from .server import FreecadMCPPlugin from .server import FreecadMCPPlugin
__version__ = "0.0.0" # Updated by release workflow __version__ = "0.6.0" # Updated by release workflow
__all__ = ["FreecadMCPPlugin", "__version__"] __all__ = ["FreecadMCPPlugin", "__version__"]
@@ -1,28 +1,31 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
r"""Blocking FreeCAD MCP Bridge Server. r"""Blocking FreeCAD Robust MCP Bridge Server.
SPDX-License-Identifier: MIT SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane) Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
This script starts the MCP bridge server and blocks with run_forever(). This script starts the MCP bridge server and blocks with run_forever().
It works with both FreeCAD GUI and FreeCADCmd (headless) modes. It works with both freecad (GUI) and freecadcmd (headless) modes.
Use this script when you need FreeCAD to keep running (CI, background servers). Use this script when you need FreeCAD to keep running (CI, background servers).
For interactive GUI sessions, use startup_bridge.py instead (non-blocking). For interactive GUI sessions, use startup_bridge.py instead (non-blocking).
Usage: Usage:
# Headless mode (no GUI features): # Headless mode (no GUI features):
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/\
freecad_mcp_bridge/blocking_bridge.py
# GUI mode (full features including screenshots): # GUI mode (full features including screenshots):
freecad ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py freecad ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/\
freecad_mcp_bridge/blocking_bridge.py
# On macOS: # On macOS:
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \ /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py ~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCPBridge/\
freecad_mcp_bridge/blocking_bridge.py
Note: In headless mode (FreeCADCmd), GUI features like screenshots are not available. Note: In headless mode (freecadcmd), GUI features like screenshots are not available.
For full functionality, run with FreeCAD GUI executable. For full functionality, run with the freecad GUI executable.
""" """
from __future__ import annotations from __future__ import annotations
@@ -37,23 +40,23 @@ try:
print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}") print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}")
except ImportError: except ImportError:
print("ERROR: This script must be run with FreeCAD or FreeCADCmd.") print("ERROR: This script must be run with freecad or freecadcmd.")
print("") print("")
print("Usage:") print("Usage:")
print( print(
" FreeCADCmd /path/to/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py" " freecadcmd /path/to/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py"
) )
print("") print("")
print("On macOS (if workbench installed):") print("On macOS (if workbench installed):")
print(" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \\") print(" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \\")
print( print(
" ~/Library/Application\\ Support/FreeCAD/Mod/FreecadRobustMCP/" " ~/Library/Application\\ Support/FreeCAD/Mod/FreecadRobustMCPBridge/"
"freecad_mcp_bridge/blocking_bridge.py" "freecad_mcp_bridge/blocking_bridge.py"
) )
print("") print("")
print("On Linux (if workbench installed):") print("On Linux (if workbench installed):")
print( print(
" freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/" " freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/"
"freecad_mcp_bridge/blocking_bridge.py" "freecad_mcp_bridge/blocking_bridge.py"
) )
sys.exit(1) sys.exit(1)
@@ -0,0 +1,272 @@
"""Shared utilities for the FreeCAD Robust 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,
startup_bridge.py, and Init.py to avoid code duplication.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
from server import FreecadMCPPlugin
# Default timing constants for GUI waiting
DEFAULT_GUI_CHECK_INTERVAL_MS: int = 100 # How often to check if GUI is ready
DEFAULT_GUI_DEFER_START_MS: int = 2000 # Delay before starting bridge after GUI ready
DEFAULT_GUI_WAIT_MAX_RETRIES: int = 600 # Max retries (600 * 100ms = 60s timeout)
class GuiWaiter:
"""Helper class to wait for FreeCAD GUI to be ready before starting the bridge.
This class encapsulates the logic for waiting for FreeCAD.GuiUp to become True
before invoking a callback. It uses Qt timers to poll the GUI state and defers
the callback after the GUI is ready to allow FreeCAD to fully stabilize.
CRITICAL: Starting the MCP bridge before FreeCAD.GuiUp is True causes the bridge
to use a background thread for queue processing, which leads to crashes when
executing Qt operations from that thread.
Usage:
waiter = GuiWaiter(
callback=my_start_function,
log_prefix="My Component",
)
waiter.start()
The waiter will:
1. Poll FreeCAD.GuiUp every check_interval_ms milliseconds
2. Log progress every 5 seconds
3. Once GuiUp is True, defer the callback by defer_ms milliseconds
4. If timeout is reached, log an error without starting (to prevent crashes)
"""
def __init__(
self,
callback: Callable[[], None],
log_prefix: str = "Bridge",
check_interval_ms: int = DEFAULT_GUI_CHECK_INTERVAL_MS,
defer_ms: int = DEFAULT_GUI_DEFER_START_MS,
max_retries: int = DEFAULT_GUI_WAIT_MAX_RETRIES,
timeout_error_extra: str = "",
) -> None:
"""Initialize the GUI waiter.
Args:
callback: Function to call when GUI is ready (after defer delay).
log_prefix: Prefix for log messages (e.g., "Startup Bridge").
check_interval_ms: How often to check FreeCAD.GuiUp (milliseconds).
defer_ms: Delay after GUI ready before calling callback (milliseconds).
max_retries: Maximum number of check attempts before timeout.
timeout_error_extra: Additional text to include in timeout error message.
"""
self.callback = callback
self.log_prefix = log_prefix
self.check_interval_ms = check_interval_ms
self.defer_ms = defer_ms
self.max_retries = max_retries
self.timeout_error_extra = timeout_error_extra
# Timer references use Any since they could be from PySide2 or PySide6
self._check_timer: Any | None = None
self._defer_timer: Any | None = None
self._retry_count: int = 0
self._qtcore: ModuleType | None = None
def start(self) -> None:
"""Start waiting for GUI to be ready.
This method sets up a repeating timer that checks FreeCAD.GuiUp.
The timer reference is stored to prevent garbage collection.
The QtCore module is resolved once and stored for later use.
"""
import FreeCAD
# Resolve QtCore once and store for later use
try:
from PySide2 import QtCore # type: ignore[import]
except ImportError:
try:
from PySide6 import QtCore # type: ignore[import]
except ImportError:
FreeCAD.Console.PrintError(
f"{self.log_prefix}: Neither PySide2 nor PySide6 is available. "
"Cannot wait for GUI - Qt is required for timer-based waiting.\n"
)
return
self._qtcore = QtCore
self._check_timer = QtCore.QTimer()
self._check_timer.setSingleShot(False) # Repeating timer
self._check_timer.timeout.connect(self._check_gui)
self._check_timer.start(self.check_interval_ms)
FreeCAD.Console.PrintMessage(
f"{self.log_prefix}: Waiting for GUI to be ready...\n"
)
def _check_gui(self) -> None:
"""Check if GUI is ready and handle the result.
Called repeatedly by the check timer. When GUI is ready, stops the timer
and schedules the callback with a defer delay.
"""
import FreeCAD
self._retry_count += 1
# Log progress every 50 checks (5 seconds at default interval)
if self._retry_count % 50 == 0:
elapsed = self._retry_count * (self.check_interval_ms / 1000.0)
FreeCAD.Console.PrintMessage(
f"{self.log_prefix}: Still waiting for GUI... ({elapsed:.1f}s elapsed)\n"
)
if FreeCAD.GuiUp:
self._on_gui_ready()
elif self._retry_count >= self.max_retries:
self._on_timeout()
def _on_gui_ready(self) -> None:
"""Handle GUI becoming ready."""
import FreeCAD
# Stop the check timer
if self._check_timer is not None:
self._check_timer.stop()
self._check_timer = None
elapsed = self._retry_count * (self.check_interval_ms / 1000.0)
FreeCAD.Console.PrintMessage(
f"{self.log_prefix}: GUI ready after {elapsed:.1f}s, "
"deferring bridge start...\n"
)
# IMPORTANT: Don't start the bridge immediately from this timer callback!
# Even though GuiUp is True, FreeCAD may still be initializing internally.
# Use a single-shot timer to defer the actual start to a later, more stable
# point in the event loop.
# Note: self._qtcore was resolved in start() so we don't need to re-import
if self._qtcore is None:
# This should never happen if start() was called, but handle gracefully
FreeCAD.Console.PrintError(
f"{self.log_prefix}: QtCore not initialized - start() was not called\n"
)
return
self._defer_timer = self._qtcore.QTimer()
self._defer_timer.setSingleShot(True)
self._defer_timer.timeout.connect(self.callback)
self._defer_timer.start(self.defer_ms)
def _on_timeout(self) -> None:
"""Handle timeout - GUI did not become ready in time."""
import FreeCAD
# Stop the check timer
if self._check_timer is not None:
self._check_timer.stop()
self._check_timer = None
timeout_seconds = self.max_retries * (self.check_interval_ms / 1000.0)
FreeCAD.Console.PrintError(
f"\n{'=' * 60}\n"
f"{self.log_prefix.upper()} ERROR: GUI did not become ready "
f"within {timeout_seconds:.0f}s!\n"
f"{'=' * 60}\n\n"
f"The bridge was NOT started because starting with a background\n"
f"thread would cause FreeCAD to crash when executing Qt operations.\n\n"
f"Possible causes:\n"
f" - FreeCAD is running in headless mode\n"
f" - FreeCAD GUI initialization is extremely slow\n"
f" - There's an issue with the FreeCAD installation\n"
f"{self.timeout_error_extra}"
f"{'=' * 60}\n"
)
# Do NOT call callback here - it would use background thread and crash
def register_mcp_plugin(
plugin: FreecadMCPPlugin,
xmlrpc_port: int,
socket_port: int,
) -> None:
"""Register an MCP plugin with the workbench commands module.
This centralizes plugin registration so both Init.py auto-start and
startup_bridge.py use the same logic. Registration allows the workbench
to detect if a bridge is already running.
Args:
plugin: The FreecadMCPPlugin instance to register.
xmlrpc_port: The XML-RPC port the plugin is using.
socket_port: The JSON-RPC socket port the plugin is using.
Note:
If the commands module isn't available (workbench not loaded yet),
registration silently fails. The bridge will still work but won't
be visible to the workbench UI.
"""
try:
import commands
commands._mcp_plugin = plugin
commands._running_config = {
"xmlrpc_port": xmlrpc_port,
"socket_port": socket_port,
}
except ImportError:
# Commands module not available (workbench not loaded yet)
pass
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
import commands
plugin = getattr(commands, "_mcp_plugin", None)
if plugin is not None and plugin.is_running:
# Get actual ports from running config, with sensible defaults
config = getattr(commands, "_running_config", {})
xmlrpc_port = config.get("xmlrpc_port", 9875)
socket_port = config.get("socket_port", 9876)
FreeCAD.Console.PrintMessage(
"\nMCP Bridge already running (from auto-start).\n"
)
FreeCAD.Console.PrintMessage(f" - XML-RPC: localhost:{xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" - Socket: localhost:{socket_port}\n\n")
return 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,4 +1,4 @@
"""FreeCAD MCP Bridge Plugin - Socket Server with Queue-based Thread Safety. """FreeCAD Robust MCP Bridge Plugin - Socket Server with Queue-based Thread Safety.
This module provides a socket server that runs inside FreeCAD to handle This module provides a socket server that runs inside FreeCAD to handle
MCP bridge requests. It must be executed within FreeCAD's Python environment. MCP bridge requests. It must be executed within FreeCAD's Python environment.
@@ -236,11 +236,11 @@ class FreecadMCPPlugin:
self._running = True self._running = True
# Print instance ID to stdout for test automation to capture # Print instance ID to stderr for test automation to capture.
# This is printed before logging to ensure it's easily parseable # Stdout may be reserved for JSON-RPC when running in stdio mode.
print( print(
f"FREECAD_MCP_BRIDGE_INSTANCE_ID={self._instance_id}", f"FREECAD_MCP_BRIDGE_INSTANCE_ID={self._instance_id}",
file=sys.stdout, file=sys.stderr,
flush=True, flush=True,
) )
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""FreeCAD Robust 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.
CRITICAL: This script waits for FreeCAD.GuiUp to be True before starting the
bridge. If we start when GuiUp is False, the bridge uses a background thread
for queue processing, which causes crashes when executing Qt operations.
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 contextlib
import os
import sys
import traceback
from pathlib import Path
from typing import Any
# 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)
# Global reference to GuiWaiter to prevent garbage collection
_gui_waiter: Any | None = None
def _start_bridge() -> None:
"""Start the MCP bridge if not already running.
This function checks if a bridge is already running (via get_running_plugin)
and only starts a new bridge if none exists. It reads port configuration from
environment variables and registers the plugin with the workbench commands
module for visibility to other components.
Environment Variables:
FREECAD_XMLRPC_PORT: XML-RPC port (default: 9875)
FREECAD_SOCKET_PORT: JSON-RPC socket port (default: 9876)
Raises:
ValueError: If FREECAD_XMLRPC_PORT or FREECAD_SOCKET_PORT contain
non-integer values. The exception is re-raised after logging.
Exception: Any exception from FreecadMCPPlugin initialization or start()
is caught, logged to FreeCAD.Console, and suppressed.
Side Effects:
- Creates and starts a FreecadMCPPlugin instance
- Registers the plugin with the workbench commands module
- Prints status messages to FreeCAD.Console
"""
# Check if bridge is already running (from auto-start in Init.py)
from bridge_utils import get_running_plugin
if get_running_plugin() is not None:
FreeCAD.Console.PrintMessage(
"MCP Bridge already running (started by workbench auto-start)\n"
)
return
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()
# Register plugin with commands module so Init.py auto-start can see it
# This prevents both scripts from trying to start separate bridges
from bridge_utils import register_mcp_plugin
register_mcp_plugin(plugin, xmlrpc_port, socket_port)
FreeCAD.Console.PrintMessage("\n")
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
FreeCAD.Console.PrintMessage("MCP Bridge started (via startup script)!\n")
FreeCAD.Console.PrintMessage(f" - XML-RPC: localhost:{xmlrpc_port}\n")
FreeCAD.Console.PrintMessage(f" - Socket: localhost:{socket_port}\n")
FreeCAD.Console.PrintMessage(
f" - Mode: {'GUI' if FreeCAD.GuiUp else 'Headless'}\n"
)
FreeCAD.Console.PrintMessage("=" * 50 + "\n\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n")
FreeCAD.Console.PrintError(traceback.format_exc())
# Schedule bridge start after FreeCAD finishes loading
# Strategy:
# - If FreeCAD.GuiUp is True: Qt event loop is running, start bridge directly
# - If FreeCAD.GuiUp is False but Qt is available: FreeCAD GUI is initializing.
# Use GuiWaiter to wait for GuiUp to become True before starting.
# This ensures the bridge uses Qt timer (not background thread) for queue processing.
# - If Qt is not available: Pure headless mode, start bridge directly
#
# CRITICAL: We must wait for FreeCAD.GuiUp to be True before starting the bridge
# in GUI mode. If we start when GuiUp is False, the bridge's _start_queue_processor()
# will see GuiUp=False and use a background thread. Later, code executed on that
# thread will try to do Qt operations, causing crashes (SIGABRT in QCocoaWindow).
try:
# Try to import Qt
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 - start bridge directly
FreeCAD.Console.PrintMessage("Startup Bridge: GUI already up, starting...\n")
_start_bridge()
elif QtCore is not None:
# GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode)
# Use GuiWaiter to wait for GuiUp to become True before starting
from bridge_utils import GuiWaiter
_gui_waiter = GuiWaiter(
callback=_start_bridge,
log_prefix="Startup Bridge",
timeout_error_extra=(
"\nTo start the bridge in headless mode, use:\n"
" just freecad::run-headless\n\n"
),
)
_gui_waiter.start()
else:
# True headless mode - no Qt, no GUI
FreeCAD.Console.PrintMessage(
"Startup Bridge: Headless mode, starting directly...\n"
)
_start_bridge()
except Exception as e:
FreeCAD.Console.PrintError(f"Startup Bridge: Failed to initialize: {e}\n")
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- White background -->
<rect width="64" height="64" fill="#ffffff"/>
<!-- Bridge Structure -->
<!-- Bridge deck -->
<rect x="4" y="40" width="56" height="5" rx="1" fill="#8b7355" stroke="#6b5344" stroke-width="1"/>
<!-- Bridge railings -->
<rect x="4" y="37" width="56" height="2" rx="0.5" fill="#a08060"/>
<!-- Bridge supports/pillars -->
<rect x="8" y="45" width="6" height="8" fill="#6b5344"/>
<rect x="50" y="45" width="6" height="8" fill="#6b5344"/>
<!-- Bridge arch underneath -->
<path d="M 14 53 Q 32 47 50 53" fill="none" stroke="#6b5344" stroke-width="2"/>
<!-- Robot on the bridge (facing RIGHT - same direction as play buttons) -->
<!-- Robot body -->
<rect x="24" y="18" width="16" height="14" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot head (facing right) -->
<rect x="26" y="8" width="12" height="10" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot face plate (on right side) -->
<rect x="32" y="10" width="6" height="6" rx="1" fill="#e8f4fc"/>
<!-- Robot eyes (looking right) -->
<circle cx="36" cy="12" r="1.5" fill="#2c5aa0"/>
<circle cx="36" cy="15" r="1.5" fill="#2c5aa0"/>
<!-- Robot antenna -->
<line x1="32" y1="8" x2="32" y2="3" stroke="#2c5aa0" stroke-width="2" stroke-linecap="round"/>
<circle cx="32" cy="2" r="3" fill="#27ae60"/>
<!-- Robot arms (reaching forward to right) -->
<rect x="18" y="22" width="6" height="4" rx="1.5" fill="#3a7bc8"/>
<rect x="40" y="20" width="8" height="4" rx="1.5" fill="#3a7bc8"/>
<!-- Robot legs (walking pose) -->
<rect x="24" y="32" width="4" height="8" rx="1" fill="#3a7bc8" transform="rotate(10, 26, 36)"/>
<rect x="36" y="32" width="4" height="8" rx="1" fill="#3a7bc8" transform="rotate(-10, 38, 36)"/>
<!-- Bold Play Triangles in TOP corners -->
<polygon points="4,4 4,16 12,10" fill="#27ae60" stroke="#1e8449" stroke-width="1.5"/>
<polygon points="52,4 52,16 60,10" fill="#27ae60" stroke="#1e8449" stroke-width="1.5"/>
<!-- Green Data Flow (river under bridge - active) -->
<!-- Data stream background -->
<path d="M 0 60 Q 32 55 64 60" fill="none" stroke="#27ae60" stroke-width="4" opacity="0.3"/>
<!-- Left arrow -->
<path d="M 6 59 L 16 59" stroke="#27ae60" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="6,59 11,56 11,62" fill="#27ae60"/>
<!-- Right arrow -->
<path d="M 48 59 L 58 59" stroke="#27ae60" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="58,59 53,56 53,62" fill="#27ae60"/>
<!-- Center data dots (flowing) -->
<circle cx="26" cy="57" r="2" fill="#27ae60"/>
<circle cx="32" cy="56" r="2" fill="#27ae60"/>
<circle cx="38" cy="57" r="2" fill="#27ae60"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- White background -->
<rect width="64" height="64" fill="#ffffff"/>
<!-- Bridge Structure -->
<!-- Bridge deck -->
<rect x="4" y="40" width="56" height="5" rx="1" fill="#8b7355" stroke="#6b5344" stroke-width="1"/>
<!-- Bridge railings -->
<rect x="4" y="37" width="56" height="2" rx="0.5" fill="#a08060"/>
<!-- Bridge supports/pillars -->
<rect x="8" y="45" width="6" height="8" fill="#6b5344"/>
<rect x="50" y="45" width="6" height="8" fill="#6b5344"/>
<!-- Bridge arch underneath -->
<path d="M 14 53 Q 32 47 50 53" fill="none" stroke="#6b5344" stroke-width="2"/>
<!-- Robot SITTING on bridge edge, feet dangling -->
<!-- Robot body (sitting position - shorter/compressed) -->
<rect x="24" y="24" width="16" height="10" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot head -->
<rect x="26" y="14" width="12" height="10" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot face plate -->
<rect x="28" y="16" width="8" height="6" rx="1" fill="#e8f4fc"/>
<!-- Robot eyes (looking curious) -->
<circle cx="30" cy="19" r="1.5" fill="#2c5aa0"/>
<circle cx="34" cy="19" r="1.5" fill="#2c5aa0"/>
<!-- Robot antenna with YELLOW ball (matches status color) -->
<line x1="32" y1="14" x2="32" y2="9" stroke="#2c5aa0" stroke-width="2" stroke-linecap="round"/>
<circle cx="32" cy="8" r="3" fill="#f1c40f"/>
<!-- Robot arms (resting on bridge) -->
<rect x="18" y="28" width="6" height="4" rx="1.5" fill="#3a7bc8"/>
<rect x="40" y="28" width="6" height="4" rx="1.5" fill="#3a7bc8"/>
<!-- Robot legs (dangling over edge) -->
<rect x="26" y="34" width="4" height="12" rx="1" fill="#3a7bc8"/>
<rect x="34" y="34" width="4" height="12" rx="1" fill="#3a7bc8"/>
<!-- Robot feet -->
<rect x="25" y="45" width="6" height="3" rx="1" fill="#2c5aa0"/>
<rect x="33" y="45" width="6" height="3" rx="1" fill="#2c5aa0"/>
<!-- Bold Question marks in TOP corners -->
<text x="9" y="16" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="900" fill="#f1c40f" stroke="#d4ac0d" stroke-width="1" text-anchor="middle">?</text>
<text x="55" y="16" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="900" fill="#f1c40f" stroke="#d4ac0d" stroke-width="1" text-anchor="middle">?</text>
<!-- Yellow Data Flow (river under bridge - status check) -->
<!-- Data stream background -->
<path d="M 0 60 Q 32 55 64 60" fill="none" stroke="#f1c40f" stroke-width="4" opacity="0.3"/>
<!-- Left arrow -->
<path d="M 6 59 L 16 59" stroke="#f1c40f" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="6,59 11,56 11,62" fill="#f1c40f"/>
<!-- Right arrow -->
<path d="M 48 59 L 58 59" stroke="#f1c40f" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="58,59 53,56 53,62" fill="#f1c40f"/>
<!-- Center data dots (checking) -->
<circle cx="26" cy="57" r="2" fill="#f1c40f"/>
<circle cx="32" cy="56" r="2" fill="#f1c40f"/>
<circle cx="38" cy="57" r="2" fill="#f1c40f"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 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">
<!-- White background -->
<rect width="64" height="64" fill="#ffffff"/>
<!-- Bold Stop Squares in TOP corners (not overlapping bridge) -->
<rect x="2" y="2" width="14" height="14" rx="2" fill="#e74c3c" stroke="#c0392b" stroke-width="2"/>
<rect x="48" y="2" width="14" height="14" rx="2" fill="#e74c3c" stroke="#c0392b" stroke-width="2"/>
<!-- DRAWBRIDGE OPEN - No robot -->
<!-- Left bridge section (raised) -->
<g transform="rotate(-35, 4, 40)">
<rect x="4" y="40" width="26" height="5" rx="1" fill="#8b7355" stroke="#6b5344" stroke-width="1"/>
<rect x="4" y="37" width="26" height="2" rx="0.5" fill="#a08060"/>
</g>
<!-- Right bridge section (raised) -->
<g transform="rotate(35, 60, 40)">
<rect x="34" y="40" width="26" height="5" rx="1" fill="#8b7355" stroke="#6b5344" stroke-width="1"/>
<rect x="34" y="37" width="26" height="2" rx="0.5" fill="#a08060"/>
</g>
<!-- Bridge supports/pillars -->
<rect x="8" y="45" width="6" height="8" fill="#6b5344"/>
<rect x="50" y="45" width="6" height="8" fill="#6b5344"/>
<!-- Bridge arch underneath (broken/gap) -->
<path d="M 14 53 Q 20 49 26 50" fill="none" stroke="#6b5344" stroke-width="2"/>
<path d="M 38 50 Q 44 49 50 53" fill="none" stroke="#6b5344" stroke-width="2"/>
<!-- Red Data Flow (river under bridge - stopped) -->
<!-- Data stream background -->
<path d="M 0 60 Q 32 55 64 60" fill="none" stroke="#e74c3c" stroke-width="4" opacity="0.3"/>
<!-- Left arrow (blocked) -->
<path d="M 6 59 L 16 59" stroke="#e74c3c" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="6,59 11,56 11,62" fill="#e74c3c"/>
<!-- Right arrow (blocked) -->
<path d="M 48 59 L 58 59" stroke="#e74c3c" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="58,59 53,56 53,62" fill="#e74c3c"/>
<!-- Center X (flow blocked) -->
<line x1="28" y1="55" x2="36" y2="61" stroke="#e74c3c" stroke-width="2.5" stroke-linecap="round"/>
<line x1="36" y1="55" x2="28" y2="61" stroke="#e74c3c" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<!-- White background -->
<rect width="64" height="64" fill="#ffffff"/>
<!-- Bridge Structure -->
<!-- Bridge deck -->
<rect x="4" y="40" width="56" height="5" rx="1" fill="#8b7355" stroke="#6b5344" stroke-width="1"/>
<!-- Bridge railings -->
<rect x="4" y="37" width="56" height="2" rx="0.5" fill="#a08060"/>
<!-- Bridge supports/pillars -->
<rect x="8" y="45" width="6" height="8" fill="#6b5344"/>
<rect x="50" y="45" width="6" height="8" fill="#6b5344"/>
<!-- Bridge arch underneath -->
<path d="M 14 53 Q 32 47 50 53" fill="none" stroke="#6b5344" stroke-width="2"/>
<!-- Robot SITTING on bridge edge, feet dangling -->
<!-- Robot body (sitting position - shorter/compressed) -->
<rect x="24" y="24" width="16" height="10" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot head -->
<rect x="26" y="14" width="12" height="10" rx="2" fill="#4a90d9" stroke="#2c5aa0" stroke-width="1.5"/>
<!-- Robot face plate -->
<rect x="28" y="16" width="8" height="6" rx="1" fill="#e8f4fc"/>
<!-- Robot eyes -->
<circle cx="30" cy="19" r="1.5" fill="#2c5aa0"/>
<circle cx="34" cy="19" r="1.5" fill="#2c5aa0"/>
<!-- Robot antenna with GRAY ball (matches gear color) -->
<line x1="32" y1="14" x2="32" y2="9" stroke="#2c5aa0" stroke-width="2" stroke-linecap="round"/>
<circle cx="32" cy="8" r="3" fill="#95a5a6"/>
<!-- Robot arms (resting on bridge) -->
<rect x="18" y="28" width="6" height="4" rx="1.5" fill="#3a7bc8"/>
<rect x="40" y="28" width="6" height="4" rx="1.5" fill="#3a7bc8"/>
<!-- Robot legs (dangling over edge) -->
<rect x="26" y="34" width="4" height="12" rx="1" fill="#3a7bc8"/>
<rect x="34" y="34" width="4" height="12" rx="1" fill="#3a7bc8"/>
<!-- Robot feet -->
<rect x="25" y="45" width="6" height="3" rx="1" fill="#2c5aa0"/>
<rect x="33" y="45" width="6" height="3" rx="1" fill="#2c5aa0"/>
<!-- Better Gear symbols in TOP corners (more teeth, more obvious) -->
<!-- Left gear -->
<circle cx="10" cy="10" r="6" fill="#95a5a6" stroke="#7f8c8d" stroke-width="1"/>
<!-- Gear teeth (8 teeth around the gear) -->
<rect x="8" y="2" width="4" height="3" fill="#95a5a6"/>
<rect x="8" y="15" width="4" height="3" fill="#95a5a6"/>
<rect x="2" y="8" width="3" height="4" fill="#95a5a6"/>
<rect x="15" y="8" width="3" height="4" fill="#95a5a6"/>
<!-- Diagonal teeth -->
<rect x="3" y="3" width="3" height="3" fill="#95a5a6" transform="rotate(45, 4.5, 4.5)"/>
<rect x="14" y="3" width="3" height="3" fill="#95a5a6" transform="rotate(-45, 15.5, 4.5)"/>
<rect x="3" y="14" width="3" height="3" fill="#95a5a6" transform="rotate(-45, 4.5, 15.5)"/>
<rect x="14" y="14" width="3" height="3" fill="#95a5a6" transform="rotate(45, 15.5, 15.5)"/>
<!-- Gear center hole -->
<circle cx="10" cy="10" r="2.5" fill="#6b5344"/>
<!-- Right gear -->
<circle cx="54" cy="10" r="6" fill="#95a5a6" stroke="#7f8c8d" stroke-width="1"/>
<!-- Gear teeth (8 teeth around the gear) -->
<rect x="52" y="2" width="4" height="3" fill="#95a5a6"/>
<rect x="52" y="15" width="4" height="3" fill="#95a5a6"/>
<rect x="46" y="8" width="3" height="4" fill="#95a5a6"/>
<rect x="59" y="8" width="3" height="4" fill="#95a5a6"/>
<!-- Diagonal teeth -->
<rect x="47" y="3" width="3" height="3" fill="#95a5a6" transform="rotate(45, 48.5, 4.5)"/>
<rect x="58" y="3" width="3" height="3" fill="#95a5a6" transform="rotate(-45, 59.5, 4.5)"/>
<rect x="47" y="14" width="3" height="3" fill="#95a5a6" transform="rotate(-45, 48.5, 15.5)"/>
<rect x="58" y="14" width="3" height="3" fill="#95a5a6" transform="rotate(45, 59.5, 15.5)"/>
<!-- Gear center hole -->
<circle cx="54" cy="10" r="2.5" fill="#6b5344"/>
<!-- Gray Data Flow (river under bridge - settings) -->
<!-- Data stream background -->
<path d="M 0 60 Q 32 55 64 60" fill="none" stroke="#95a5a6" stroke-width="4" opacity="0.3"/>
<!-- Left arrow -->
<path d="M 6 59 L 16 59" stroke="#95a5a6" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="6,59 11,56 11,62" fill="#95a5a6"/>
<!-- Right arrow -->
<path d="M 48 59 L 58 59" stroke="#95a5a6" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="58,59 53,56 53,62" fill="#95a5a6"/>
<!-- Center data dots -->
<circle cx="26" cy="57" r="2" fill="#95a5a6"/>
<circle cx="32" cy="56" r="2" fill="#95a5a6"/>
<circle cx="38" cy="57" r="2" fill="#95a5a6"/>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -18,6 +18,9 @@ import os # noqa: PTH
import FreeCAD import FreeCAD
# Addon directory name - single source of truth for renames
_ADDON_DIRNAME = "FreecadRobustMCPBridge"
# Cache for addon path to avoid repeated filesystem lookups # Cache for addon path to avoid repeated filesystem lookups
_addon_path_cache: str | None = None _addon_path_cache: str | None = None
@@ -48,7 +51,7 @@ def get_addon_path() -> str:
# Method 2: Use FreeCAD's Mod path + our addon name # Method 2: Use FreeCAD's Mod path + our addon name
try: try:
mod_path = os.path.join( # noqa: PTH118 mod_path = os.path.join( # noqa: PTH118
FreeCAD.getUserAppDataDir(), "Mod", "FreecadRobustMCP" FreeCAD.getUserAppDataDir(), "Mod", _ADDON_DIRNAME
) )
if os.path.exists(mod_path): # noqa: PTH110 if os.path.exists(mod_path): # noqa: PTH110
_addon_path_cache = mod_path _addon_path_cache = mod_path
@@ -62,7 +65,7 @@ def get_addon_path() -> str:
for item in os.listdir(base_path): # noqa: PTH208 for item in os.listdir(base_path): # noqa: PTH208
if item.startswith("v1-"): if item.startswith("v1-"):
versioned_mod = os.path.join( # noqa: PTH118 versioned_mod = os.path.join( # noqa: PTH118
base_path, item, "Mod", "FreecadRobustMCP" base_path, item, "Mod", _ADDON_DIRNAME
) )
if os.path.exists(versioned_mod): # noqa: PTH110 if os.path.exists(versioned_mod): # noqa: PTH110
_addon_path_cache = versioned_mod _addon_path_cache = versioned_mod
@@ -103,14 +106,14 @@ def get_icons_dir() -> str:
def get_workbench_icon() -> str: def get_workbench_icon() -> str:
"""Get the path to the workbench's main icon (FreecadRobustMCP.svg). """Get the path to the workbench's main icon (FreecadRobustMCPBridge.svg).
Returns: Returns:
The absolute path to the workbench icon, or empty string if not found. The absolute path to the workbench icon, or empty string if not found.
""" """
addon_path = get_addon_path() addon_path = get_addon_path()
if addon_path: if addon_path:
icon_path = os.path.join(addon_path, "FreecadRobustMCP.svg") # noqa: PTH118 icon_path = os.path.join(addon_path, f"{_ADDON_DIRNAME}.svg") # noqa: PTH118
if os.path.exists(icon_path): # noqa: PTH110 if os.path.exists(icon_path): # noqa: PTH110
return icon_path return icon_path
return "" return ""
@@ -0,0 +1,196 @@
<languages/>
<translate>
<!--T:172-->
[[Image:FreecadRobustMCPBridge.svg|thumb|128px|Robust MCP Bridge workbench icon]]
<!--T:1-->
{{Workbench
|Name=Robust MCP Bridge Workbench
|Icon=FreecadRobustMCPBridge.svg
|Description=Bridge workbench designed to provide an interface between FreeCAD and the Robust MCP Server to enable AI assistants (like Claude) to control FreeCAD via the Model Context Protocol (MCP). It provides XML-RPC and JSON-RPC interfaces for external automation.
|Author=Sean P. Kane
|Version=99.99.99-test
|Date=2026-01-12
|FCVersion=0.21+
|Download=[https://github.com/spkane/freecad-robust-mcp-and-more/releases Latest Release]
|SeeAlso=[[Macros|Macros]], [[External_workbenches|External Workbenches]]
}}
==Description== <!--T:2-->
<!--T:3-->
The '''Robust MCP Bridge Workbench''' is the server-side connection point that bridges to the [https://pypi.org/project/freecad-robust-mcp/ Robust MCP Server], which enables external applications to control FreeCAD through the [https://modelcontextprotocol.io/ Model Context Protocol (MCP)]. The workbench runs inside FreeCAD and exposes XML-RPC and JSON-RPC interfaces that external MCP clients can connect to.
<!--T:4-->
This workbench is designed to work with the [https://pypi.org/project/freecad-robust-mcp/ Robust MCP Server] (available on PyPI), which allows AI assistants like [https://claude.ai Claude] to interact with FreeCAD through natural language. The full documentation and source code can be found at [https://github.com/spkane/freecad-robust-mcp-and-more github/spkane/freecad-robust-mcp-and-more], where you will find the MCP Server, Bridge and some FreeCAD Macros used for various things, not necessarily related to AI or the MCP work.
<!--T:5-->
'''Key Features:'''
* Toolbar controls for starting/stopping the MCP bridge
* Status indicator showing connection state (green=running, red=stopped)
* XML-RPC server on configurable port (default: 9875)
* JSON-RPC socket server on configurable port (default: 9876)
* Headless mode support for automation and CI/CD pipelines
* Thread-safe queue system ensuring safe FreeCAD operations
* Configurable auto-start on FreeCAD launch
==Installation== <!--T:6-->
===Via Addon Manager (Recommended)=== <!--T:7-->
<!--T:8-->
# Open FreeCAD
# Go to {{MenuCommand|Tools → Addon Manager}}
# Search for "Robust MCP Bridge"
# Click {{Button|Install}}
# Restart FreeCAD
===Manual Installation=== <!--T:9-->
<!--T:10-->
Download the latest release from [https://github.com/spkane/freecad-robust-mcp-and-more/releases GitHub Releases] and extract to your FreeCAD Mod directory:
<!--T:11-->
* '''Linux''': {{FileName|~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/}}
* '''macOS''': {{FileName|~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCPBridge/}}
* '''Windows''': {{FileName|%APPDATA%/FreeCAD/Mod/FreecadRobustMCPBridge/}}
==Usage== <!--T:12-->
===GUI Mode=== <!--T:13-->
<!--T:173-->
[[Image:Addon_RobustMCPBridge_toolbar.png|Robust MCP Bridge workbench toolbar]]
<!--T:14-->
# Switch to the '''Robust MCP Bridge''' workbench using the workbench selector
# Click {{Button|Start Bridge}} in the toolbar
# The status indicator turns green when running
# External MCP clients can now connect to localhost:9875 (XML-RPC) or localhost:9876 (Socket)
<!--T:15-->
To stop the bridge, click {{Button|Stop Bridge}} in the toolbar.
<!--T:174-->
[[Image:Addon_RobustMCPBridge_statusbar.png|Robust MCP Bridge workbench statusbar]]
===Headless Mode=== <!--T:16-->
<!--T:17-->
For automation and CI/CD pipelines, the bridge can run without the GUI:
<!--T:18-->
'''Linux:'''
{{Code|code=
freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
}}
<!--T:19-->
'''macOS:'''
{{Code|code=
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
}}
<!--T:20-->
The bridge will start and keep FreeCAD running until you press Ctrl+C.
==Configuration== <!--T:21-->
<!--T:22-->
Access preferences via {{MenuCommand|Edit → Preferences → Robust MCP Bridge}} or {{MenuCommand|Robust MCP Bridge → MCP Bridge Preferences...}}
<!--T:23-->
{| class="wikitable"
! Setting !! Description !! Default
|-
| Auto-start bridge || Start bridge automatically when FreeCAD launches || Disabled
|-
| Show status indicator || Display connection 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
|}
[[Image:Addon RobustMCPBridge preferences.png|800px|Robust MCP Bridge workbench preference pane]]
==Features by Mode== <!--T:24-->
<!--T:25-->
{| class="wikitable"
! Feature !! GUI Mode !! Headless Mode
|-
| Object creation || Yes || Yes
|-
| Boolean operations || Yes || Yes
|-
| Export (STEP, STL, 3MF) || Yes || Yes
|-
| Macro execution || Yes || Yes
|-
| Document management || Yes || Yes
|-
| Screenshots || Yes || No
|-
| Object colors/visibility || Yes || No
|-
| Camera/view control || Yes || No
|}
==Connecting MCP Clients== <!--T:26-->
<!--T:27-->
This workbench provides the server that MCP clients connect to. To use with AI assistants like Claude, you need an MCP client such as the [https://pypi.org/project/freecad-robust-mcp/ Robust MCP Server]:
<!--T:28-->
{{Code|code=
pip install freecad-robust-mcp
}}
<!--T:29-->
Or using uv:
{{Code|code=
uv tool install freecad-robust-mcp
}}
<!--T:30-->
See the [https://github.com/spkane/freecad-robust-mcp-and-more GitHub repository] for full documentation on configuring MCP clients.
==Troubleshooting== <!--T:31-->
===Bridge Won't Start=== <!--T:32-->
<!--T:33-->
# Check the FreeCAD Python console ({{MenuCommand|View → Panels → Python console}}) for error messages
# Ensure no other process is using ports 9875/9876
# Try restarting FreeCAD
===Connection Refused=== <!--T:34-->
<!--T:35-->
# Verify the bridge is running (green status indicator in toolbar)
# Check that ports match between the workbench preferences and your MCP client configuration
# If connecting from Docker, use {{incode|host.docker.internal}} instead of {{incode|localhost}}
===Headless Mode Won't Start=== <!--T:36-->
<!--T:37-->
# Ensure you're using {{incode|freecadcmd}} (not {{incode|freecad}})
# Verify the script path is correct for your installation
# Test FreeCAD first: {{incode|freecadcmd -c "print('test')"}}
==Links== <!--T:38-->
<!--T:39-->
* [https://spkane.github.io/freecad-robust-mcp-and-more/ Full Documentation] - Complete guides, API reference, and tutorials
* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository] - Source code and issue tracker
* [https://pypi.org/project/freecad-robust-mcp/ Robust MCP Server on PyPI] - The MCP client that connects to this bridge
* [https://modelcontextprotocol.io/ Model Context Protocol] - The protocol specification
</translate>
[[Category:User Documentation{{#translation:}}]]
[[Category:Addons{{#translation:}}]]
[[Category:External Workbenches{{#translation:}}]]
+1 -1
View File
@@ -1,6 +1,6 @@
# FreeCAD Robust 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. This document analyzes existing FreeCAD Robust MCP server implementations to identify best practices and improvements for our architecture.
## Existing Implementations ## Existing Implementations
+1 -1
View File
@@ -1,4 +1,4 @@
# FreeCAD MCP User Guide # FreeCAD Robust MCP User Guide
This guide explains how to use AI assistants with FreeCAD via the MCP (Model Context Protocol) server to create and manipulate 3D CAD models. This guide explains how to use AI assistants with FreeCAD via the MCP (Model Context Protocol) server to create and manipulate 3D CAD models.
+6 -6
View File
@@ -1,4 +1,4 @@
# FreeCAD MCP Server Architecture # FreeCAD Robust MCP Server Architecture
## Executive Summary ## Executive Summary
@@ -10,11 +10,11 @@ This document describes the architecture for a Model Context Protocol (MCP) serv
<!--TOC--> <!--TOC-->
- [FreeCAD MCP Server Architecture](#freecad-mcp-server-architecture) - [FreeCAD Robust MCP Server Architecture](#freecad-robust-mcp-server-architecture)
- [Executive Summary](#executive-summary) - [Executive Summary](#executive-summary)
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Competitive Analysis](#competitive-analysis) - [Competitive Analysis](#competitive-analysis)
- [Existing FreeCAD MCP Servers](#existing-freecad-mcp-servers) - [Existing FreeCAD Robust MCP Servers](#existing-freecad-robust-mcp-servers)
- [Our Differentiators](#our-differentiators) - [Our Differentiators](#our-differentiators)
- [Key Learnings Applied](#key-learnings-applied) - [Key Learnings Applied](#key-learnings-applied)
- [System Overview](#system-overview) - [System Overview](#system-overview)
@@ -89,7 +89,7 @@ This document describes the architecture for a Model Context Protocol (MCP) serv
See [COMPARISON.md](../COMPARISON.md) for detailed analysis of existing implementations. See [COMPARISON.md](../COMPARISON.md) for detailed analysis of existing implementations.
### Existing FreeCAD MCP Servers ### Existing FreeCAD Robust MCP Servers
| Project | Stars | Approach | Strengths | | Project | Stars | Approach | Strengths |
| ----------------------------------------------------------------------------------- | ----- | -------------- | ------------------------------------------------- | | ----------------------------------------------------------------------------------- | ----- | -------------- | ------------------------------------------------- |
@@ -157,7 +157,7 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is a standa
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ FreeCAD MCP Server │ │ FreeCAD Robust MCP Server │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Tools │ │ Resources │ │ Prompts │ │ │ │ Tools │ │ Resources │ │ Prompts │ │
│ │ - execute_py │ │ - documents │ │ - modeling │ │ │ │ - execute_py │ │ - documents │ │ - modeling │ │
@@ -293,7 +293,7 @@ freecad_mcp/
The main entry point using FastMCP from the official MCP Python SDK. The main entry point using FastMCP from the official MCP Python SDK.
```python ```python
"""FreeCAD MCP Server - Main entry point.""" """FreeCAD Robust MCP Server - Main entry point."""
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
+4 -4
View File
@@ -1,6 +1,6 @@
# Architecture # Architecture
This document provides a technical overview of the FreeCAD MCP Server architecture. This document provides a technical overview of the FreeCAD Robust MCP Server architecture.
For the full architecture document with design decisions and rationale, see [Detailed Architecture](architecture-detailed.md). For the full architecture document with design decisions and rationale, see [Detailed Architecture](architecture-detailed.md).
@@ -8,7 +8,7 @@ For the full architecture document with design decisions and rationale, see [Det
## Overview ## Overview
The FreeCAD MCP Server follows a **Bridge with Adapter** pattern: The FreeCAD Robust MCP Server follows a **Bridge with Adapter** pattern:
```text ```text
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
@@ -125,10 +125,10 @@ class FreecadBridge(ABC):
The workbench addon runs inside FreeCAD: The workbench addon runs inside FreeCAD:
```text ```text
addon/FreecadRobustMCP/ addon/FreecadRobustMCPBridge/
├── Init.py # Module initialization ├── Init.py # Module initialization
├── InitGui.py # GUI initialization (workbench) ├── InitGui.py # GUI initialization (workbench)
├── FreecadRobustMCP.svg # Workbench icon ├── FreecadRobustMCPBridge.svg # Workbench icon
└── freecad_mcp_bridge/ # Bridge plugin └── freecad_mcp_bridge/ # Bridge plugin
├── __init__.py ├── __init__.py
├── server.py # XML-RPC/JSON-RPC server ├── server.py # XML-RPC/JSON-RPC server
+1 -1
View File
@@ -88,7 +88,7 @@ freecad-robust-mcp-and-more/
│ ├── prompts/ # MCP prompt templates │ ├── prompts/ # MCP prompt templates
│ └── server.py # Main server entry point │ └── server.py # Main server entry point
├── addon/ # FreeCAD workbench addon ├── addon/ # FreeCAD workbench addon
│ └── FreecadRobustMCP/ # Workbench files │ └── FreecadRobustMCPBridge/ # Workbench files
├── macros/ # Standalone FreeCAD macros ├── macros/ # Standalone FreeCAD macros
├── tests/ # Test suite ├── tests/ # Test suite
│ ├── unit/ # Unit tests │ ├── unit/ # Unit tests
+2 -2
View File
@@ -338,7 +338,7 @@ Variables are defined in `docs/variables.yaml`:
```yaml ```yaml
# docs/variables.yaml # docs/variables.yaml
project_name: FreeCAD Robust MCP Server project_name: FreeCAD Robust MCP Suite
package_name: freecad-robust-mcp package_name: freecad-robust-mcp
xmlrpc_port: 9875 xmlrpc_port: 9875
socket_port: 9876 socket_port: 9876
@@ -355,7 +355,7 @@ Install with: `pip install {{@ package_name @}}`
| Variable | Value | Description | | Variable | Value | Description |
| ---------------------- | ------------------------- | ------------------- | | ---------------------- | ------------------------- | ------------------- |
| `project_name` | FreeCAD Robust MCP Server | Display name | | `project_name` | FreeCAD Robust MCP Suite | Display name |
| `package_name` | freecad-robust-mcp | PyPI package name | | `package_name` | freecad-robust-mcp | PyPI package name |
| `docker_image` | spkane/freecad-robust-mcp | Docker image | | `docker_image` | spkane/freecad-robust-mcp | Docker image |
| `xmlrpc_port` | 9875 | XML-RPC server port | | `xmlrpc_port` | 9875 | XML-RPC server port |
+1 -1
View File
@@ -244,7 +244,7 @@ just release::tag-workbench 1.0.0
**Files updated by `bump-workbench`:** **Files updated by `bump-workbench`:**
- `addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py` (`__version__`) - `addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py` (`__version__`)
- `package.xml` (workbench section: `<version>` and `<date>`) - `package.xml` (workbench section: `<version>` and `<date>`)
**What happens automatically:** **What happens automatically:**
+1 -1
View File
@@ -151,7 +151,7 @@ just freecad::run-gui
just freecad::run-headless just freecad::run-headless
# Or run directly with FreeCADCmd # Or run directly with FreeCADCmd
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
``` ```
--- ---
+1 -1
View File
@@ -62,7 +62,7 @@ The Robust MCP Bridge Workbench runs inside FreeCAD and provides the connection
1. Open FreeCAD 1. Open FreeCAD
1. Go to **Tools > Addon Manager** 1. Go to **Tools > Addon Manager**
1. Search for "FreeCAD MCP and More" or "Robust MCP Bridge" 1. Search for "FreeCAD Robust MCP Suite" or "Robust MCP Bridge"
1. Click **Install** 1. Click **Install**
1. Restart FreeCAD 1. Restart FreeCAD
+1 -1
View File
@@ -27,7 +27,7 @@ Before starting, ensure you have:
```bash ```bash
# If installed via Addon Manager (Linux) # If installed via Addon Manager (Linux)
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
# If working from source # If working from source
just freecad::run-headless just freecad::run-headless
+1 -1
View File
@@ -115,7 +115,7 @@ Embedded mode is tested in the CI pipeline with unit tests that mock FreeCAD. Ho
```mermaid ```mermaid
graph TD graph TD
A[Need FreeCAD MCP?] --> B{Platform?} A[Need FreeCAD Robust MCP?] --> B{Platform?}
B -->|macOS/Windows| C[Use xmlrpc or socket] B -->|macOS/Windows| C[Use xmlrpc or socket]
B -->|Linux| D{Need GUI features?} B -->|Linux| D{Need GUI features?}
D -->|Yes| C D -->|Yes| C
+2 -2
View File
@@ -58,7 +58,7 @@ See [MultiExport documentation](https://github.com/spkane/freecad-robust-mcp-and
### Via FreeCAD Addon Manager ### Via FreeCAD Addon Manager
When you install the "FreeCAD MCP and More" addon, the macros are installed automatically. When you install the "FreeCAD Robust MCP Suite" addon, the macros are installed automatically.
### Manual Installation ### Manual Installation
@@ -156,7 +156,7 @@ create_macro_from_template(
**Available templates:** **Available templates:**
| Template | Description | | Template | Description |
| ----------- | --------------------------- | | ----------- | --------------------------------- |
| `basic` | Minimal macro with imports | | `basic` | Minimal macro with imports |
| `part` | Part workbench operations | | `part` | Part workbench operations |
| `sketch` | Sketcher operations | | `sketch` | Sketcher operations |
+6 -6
View File
@@ -22,7 +22,7 @@ The workbench provides:
1. Open FreeCAD 1. Open FreeCAD
1. Go to **Tools > Addon Manager** 1. Go to **Tools > Addon Manager**
1. Search for "FreeCAD MCP and More" or "Robust MCP Bridge" 1. Search for "FreeCAD Robust MCP Suite" or "Robust MCP Bridge"
1. Click **Install** 1. Click **Install**
1. Restart FreeCAD 1. Restart FreeCAD
@@ -30,9 +30,9 @@ The workbench provides:
Download from [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases) and extract to your FreeCAD Mod directory: Download from [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases) and extract to your FreeCAD Mod directory:
- **Linux:** `~/.local/share/FreeCAD/Mod/FreecadRobustMCP/` - **Linux:** `~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/`
- **macOS:** `~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCP/` - **macOS:** `~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCPBridge/`
- **Windows:** `%APPDATA%\FreeCAD\Mod\FreecadRobustMCP\` - **Windows:** `%APPDATA%\FreeCAD\Mod\FreecadRobustMCPBridge\`
--- ---
@@ -75,14 +75,14 @@ The workbench includes a blocking bridge script for running in server mode (keep
**Linux:** **Linux:**
```bash ```bash
freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
``` ```
**macOS:** **macOS:**
```bash ```bash
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \ /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \
~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py ~/Library/Application\ Support/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
``` ```
**Using just commands (from source):** **Using just commands (from source):**
+15 -10
View File
@@ -1,8 +1,8 @@
# FreeCAD Robust MCP Server # FreeCAD Robust MCP Suite
Welcome to the FreeCAD Robust MCP Server documentation. Welcome to the FreeCAD Robust MCP Suite 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. This project provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server, FreeCAD workbench, and standalone macros that enable 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.
--- ---
@@ -23,9 +23,10 @@ This project provides an [MCP (Model Context Protocol)](https://modelcontextprot
pip install freecad-robust-mcp pip install freecad-robust-mcp
# Install the workbench via FreeCAD Addon Manager # Install the workbench via FreeCAD Addon Manager
# (search for "FreeCAD MCP and More") # (search for "Robust MCP" - the package is "FreeCAD Robust MCP Suite")
# Start FreeCAD and click "Start Bridge" in the Robust MCP Bridge workbench # Start FreeCAD and switch to the "Robust MCP Bridge" workbench
# Click "Start Bridge" in the toolbar
# Configure your MCP client and start building! # Configure your MCP client and start building!
``` ```
@@ -79,13 +80,17 @@ This project includes standalone FreeCAD macros:
| [Tools Reference](MCP_TOOLS_REFERENCE.md) | Complete API reference for all 82+ MCP tools | | [Tools Reference](MCP_TOOLS_REFERENCE.md) | Complete API reference for all 82+ MCP tools |
| [API Reference](api/server.md) | Python API documentation | | [API Reference](api/server.md) | Python API documentation |
| [Development](development/contributing.md) | Contributing, architecture, and development setup | | [Development](development/contributing.md) | Contributing, architecture, and development setup |
| [Comparison](COMPARISON.md) | Analysis of other FreeCAD MCP implementations | | [Comparison](COMPARISON.md) | Compare with other FreeCAD MCP implementations |
--- ---
## Links ## Links
- [GitHub Repository](https://github.com/spkane/freecad-robust-mcp-and-more) - [GitHub Repository](https://github.com/spkane/freecad-robust-mcp-and-more) - Source code and issue tracker
- [PyPI Package](https://pypi.org/project/freecad-robust-mcp/) - [PyPI Package](https://pypi.org/project/freecad-robust-mcp/) - Python package for pip installation
- [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp) - [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp) - Pre-built Docker images
- [Issue Tracker](https://github.com/spkane/freecad-robust-mcp-and-more/issues)
---
!!! tip "Share This Documentation"
Direct link: **[https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/)**
+1 -1
View File
@@ -2,7 +2,7 @@
# Use in docs with {{@ variable_name @}} (custom delimiters to avoid Python dict conflicts) # Use in docs with {{@ variable_name @}} (custom delimiters to avoid Python dict conflicts)
# Project info # Project info
project_name: FreeCAD Robust MCP Server project_name: FreeCAD Robust MCP Suite
package_name: freecad-robust-mcp package_name: freecad-robust-mcp
docker_image: spkane/freecad-robust-mcp docker_image: spkane/freecad-robust-mcp
+1 -1
View File
@@ -62,7 +62,7 @@ clean:
# Open Python REPL with project modules available # Open Python REPL with project modules available
repl: repl:
cd {{project_root}} && uv run python -c "import freecad_mcp; print('FreeCAD MCP loaded')" && uv run python cd {{project_root}} && uv run python -c "import freecad_mcp; print('FreeCAD Robust MCP loaded')" && uv run python
# Show project structure (tree view, requires 'tree' command) # Show project structure (tree view, requires 'tree' command)
tree: tree:
+5 -5
View File
@@ -186,12 +186,12 @@ test:
echo "" echo ""
# Check if FreeCAD bridge is already running # Check if FreeCAD bridge is already running
echo "Step 2: Checking for FreeCAD MCP bridge..." echo "Step 2: Checking for FreeCAD Robust MCP Bridge..."
if check_xmlrpc; then if check_xmlrpc; then
echo "✓ FreeCAD MCP bridge is already running on port 9875" echo "✓ FreeCAD Robust MCP Bridge is already running on port 9875"
STARTED_FREECAD=false STARTED_FREECAD=false
else else
echo "FreeCAD MCP bridge not detected. Starting FreeCAD headless..." echo "FreeCAD Robust MCP Bridge not detected. Starting FreeCAD headless..."
echo " (This may take 30-60 seconds for FreeCAD to initialize...)" echo " (This may take 30-60 seconds for FreeCAD to initialize...)"
# Start FreeCAD headless in background, capturing output # Start FreeCAD headless in background, capturing output
@@ -205,7 +205,7 @@ test:
while ! check_xmlrpc; do while ! check_xmlrpc; do
RETRY_COUNT=$((RETRY_COUNT + 1)) RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "✗ ERROR: FreeCAD MCP bridge did not start within ${MAX_RETRIES}s" echo "✗ ERROR: FreeCAD Robust MCP Bridge did not start within ${MAX_RETRIES}s"
echo "" echo ""
echo "FreeCAD log output:" echo "FreeCAD log output:"
tail -30 "$FREECAD_LOG" tail -30 "$FREECAD_LOG"
@@ -219,7 +219,7 @@ test:
done done
rm -f "$FREECAD_LOG" rm -f "$FREECAD_LOG"
FREECAD_LOG="" # Clear so cleanup doesn't try to delete again FREECAD_LOG="" # Clear so cleanup doesn't try to delete again
echo "✓ FreeCAD MCP bridge started (took ${RETRY_COUNT}s)" echo "✓ FreeCAD Robust MCP Bridge started (took ${RETRY_COUNT}s)"
STARTED_FREECAD=true STARTED_FREECAD=true
fi fi
echo "" echo ""
+44
View File
@@ -29,3 +29,47 @@ open:
else else
echo "Documentation built at: {{project_root}}/site/index.html" echo "Documentation built at: {{project_root}}/site/index.html"
fi fi
# --- Versioned Documentation (mike) ---
# List all deployed documentation versions
list-versions:
cd {{project_root}} && uv run mike list
# Serve versioned documentation locally (from gh-pages branch)
serve-versioned:
-cd {{project_root}} && uv run mike serve
# Deploy documentation as "dev" version (for local testing)
# Note: This modifies the gh-pages branch locally
deploy-dev:
#!/usr/bin/env bash
cd "{{project_root}}"
git config user.name "local-dev"
git config user.email "local@dev"
uv run mike deploy dev
# Deploy a specific version (for local testing)
# Usage: just documentation::deploy-version 1.0.0
# Note: This modifies the gh-pages branch locally
deploy-version VERSION:
#!/usr/bin/env bash
cd "{{project_root}}"
git config user.name "local-dev"
git config user.email "local@dev"
uv run mike deploy "{{VERSION}}"
# Deploy a version and set it as latest (for local testing)
# Usage: just documentation::deploy-latest 1.0.0
deploy-latest VERSION:
#!/usr/bin/env bash
cd "{{project_root}}"
git config user.name "local-dev"
git config user.email "local@dev"
uv run mike deploy --update-aliases "{{VERSION}}" latest
uv run mike set-default latest
# Delete a deployed version (for local cleanup)
# Usage: just documentation::delete-version 1.0.0
delete-version VERSION:
cd {{project_root}} && uv run mike delete "{{VERSION}}"
+4 -4
View File
@@ -14,7 +14,7 @@ run-headless:
set -euo pipefail set -euo pipefail
PROJECT_DIR="{{project_root}}" PROJECT_DIR="{{project_root}}"
# Use the blocking bridge script from the addon directory (source of truth) # Use the blocking bridge script from the addon directory (source of truth)
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py" SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py"
# Find FreeCADCmd executable based on OS # Find FreeCADCmd executable based on OS
FREECAD_CMD="" FREECAD_CMD=""
@@ -73,7 +73,7 @@ run-headless-custom freecad_cmd:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
PROJECT_DIR="{{project_root}}" PROJECT_DIR="{{project_root}}"
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py" SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py"
if [[ ! -x "{{freecad_cmd}}" ]]; then if [[ ! -x "{{freecad_cmd}}" ]]; then
echo "ERROR: FreeCADCmd not found or not executable: {{freecad_cmd}}" echo "ERROR: FreeCADCmd not found or not executable: {{freecad_cmd}}"
@@ -95,7 +95,7 @@ run-gui:
PROJECT_DIR="{{project_root}}" PROJECT_DIR="{{project_root}}"
# Use the shared startup script from the addon directory # Use the shared startup script from the addon directory
STARTUP_SCRIPT="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/startup_bridge.py" STARTUP_SCRIPT="${PROJECT_DIR}/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/startup_bridge.py"
if [[ ! -f "$STARTUP_SCRIPT" ]]; then if [[ ! -f "$STARTUP_SCRIPT" ]]; then
echo "ERROR: Startup script not found: $STARTUP_SCRIPT" echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
@@ -172,7 +172,7 @@ run-gui-custom freecad_path:
PROJECT_DIR="{{project_root}}" PROJECT_DIR="{{project_root}}"
# Use the shared startup script from the addon directory # Use the shared startup script from the addon directory
STARTUP_SCRIPT="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/startup_bridge.py" STARTUP_SCRIPT="${PROJECT_DIR}/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/startup_bridge.py"
if [[ ! -f "$STARTUP_SCRIPT" ]]; then if [[ ! -f "$STARTUP_SCRIPT" ]]; then
echo "ERROR: Startup script not found: $STARTUP_SCRIPT" echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
+9 -9
View File
@@ -133,7 +133,7 @@ mcp-bridge-workbench:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
PROJECT_DIR="{{project_root}}" PROJECT_DIR="{{project_root}}"
ADDON_NAME="FreecadRobustMCP" ADDON_NAME="FreecadRobustMCPBridge"
# Set FreeCAD directories # Set FreeCAD directories
eval "$(just install::_freecad-dirs)" eval "$(just install::_freecad-dirs)"
@@ -239,7 +239,7 @@ mcp-bridge-workbench:
u = ET.SubElement(standalone, 'url', type='readme') u = ET.SubElement(standalone, 'url', type='readme')
u.text = readme_url.text u.text = readme_url.text
icon_text = wb_icon.text if wb_icon is not None else 'FreecadRobustMCP.svg' icon_text = wb_icon.text if wb_icon is not None else 'FreecadRobustMCPBridge.svg'
ET.SubElement(standalone, 'icon').text = icon_text ET.SubElement(standalone, 'icon').text = icon_text
fcmin_text = wb_freecadmin.text if wb_freecadmin is not None else '0.21' fcmin_text = wb_freecadmin.text if wb_freecadmin is not None else '0.21'
ET.SubElement(standalone, 'freecadmin').text = fcmin_text ET.SubElement(standalone, 'freecadmin').text = fcmin_text
@@ -247,7 +247,7 @@ mcp-bridge-workbench:
# Add content/workbench section # Add content/workbench section
content = ET.SubElement(standalone, 'content') content = ET.SubElement(standalone, 'content')
wb_el = ET.SubElement(content, 'workbench') wb_el = ET.SubElement(content, 'workbench')
cls_text = wb_classname.text if wb_classname is not None else 'FreecadRobustMCPWorkbench' cls_text = wb_classname.text if wb_classname is not None else 'FreecadRobustMCPBridgeWorkbench'
ET.SubElement(wb_el, 'classname').text = cls_text ET.SubElement(wb_el, 'classname').text = cls_text
ET.SubElement(wb_el, 'subdirectory').text = './' ET.SubElement(wb_el, 'subdirectory').text = './'
@@ -306,7 +306,7 @@ mcp-bridge-workbench:
uninstall-mcp-bridge-workbench: uninstall-mcp-bridge-workbench:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
ADDON_NAME="FreecadRobustMCP" ADDON_NAME="FreecadRobustMCPBridge"
# Set FreeCAD directories # Set FreeCAD directories
eval "$(just install::_freecad-dirs)" eval "$(just install::_freecad-dirs)"
@@ -539,16 +539,16 @@ except Exception:
} }
# Check workbench # Check workbench
if [[ -d "$MOD_DIR/FreecadRobustMCP" ]]; then if [[ -d "$MOD_DIR/FreecadRobustMCPBridge" ]]; then
WB_VERSION="unknown" WB_VERSION="unknown"
if [[ -f "$MOD_DIR/FreecadRobustMCP/package.xml" ]]; then if [[ -f "$MOD_DIR/FreecadRobustMCPBridge/package.xml" ]]; then
WB_VERSION=$(extract_package_version "$MOD_DIR/FreecadRobustMCP/package.xml") WB_VERSION=$(extract_package_version "$MOD_DIR/FreecadRobustMCPBridge/package.xml")
fi fi
WB_MOD_TIME=$(get_mod_time "$MOD_DIR/FreecadRobustMCP/InitGui.py") WB_MOD_TIME=$(get_mod_time "$MOD_DIR/FreecadRobustMCPBridge/InitGui.py")
echo "✓ Robust MCP Bridge Workbench: INSTALLED" echo "✓ Robust MCP Bridge Workbench: INSTALLED"
echo " Version: $WB_VERSION" echo " Version: $WB_VERSION"
echo " Updated: $WB_MOD_TIME" echo " Updated: $WB_MOD_TIME"
echo " Path: $MOD_DIR/FreecadRobustMCP" echo " Path: $MOD_DIR/FreecadRobustMCPBridge"
else else
echo "✗ Robust MCP Bridge Workbench: NOT INSTALLED" echo "✗ Robust MCP Bridge Workbench: NOT INSTALLED"
echo " Install: just install::mcp-bridge-workbench" echo " Install: just install::mcp-bridge-workbench"
+92 -32
View File
@@ -50,7 +50,7 @@ bump-workbench version:
echo "" echo ""
# Update __version__ in the bridge module's __init__.py # Update __version__ in the bridge module's __init__.py
INIT_FILE="{{project_root}}/addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py" INIT_FILE="{{project_root}}/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py"
if [ -f "$INIT_FILE" ]; then if [ -f "$INIT_FILE" ]; then
sed 's/^__version__ = "[^"]*"/__version__ = "'"$VERSION"'"/' "$INIT_FILE" > "$INIT_FILE.tmp" && mv "$INIT_FILE.tmp" "$INIT_FILE" sed 's/^__version__ = "[^"]*"/__version__ = "'"$VERSION"'"/' "$INIT_FILE" > "$INIT_FILE.tmp" && mv "$INIT_FILE.tmp" "$INIT_FILE"
echo "Updated $INIT_FILE:" echo "Updated $INIT_FILE:"
@@ -60,6 +60,18 @@ bump-workbench version:
exit 1 exit 1
fi fi
# Update wiki-source.txt version and date
WIKI_FILE="{{project_root}}/addon/FreecadRobustMCPBridge/wiki-source.txt"
if [ -f "$WIKI_FILE" ]; then
sed "s/|Version=.*/|Version=${VERSION}/" "$WIKI_FILE" > "$WIKI_FILE.tmp" && mv "$WIKI_FILE.tmp" "$WIKI_FILE"
sed "s/|Date=.*/|Date=${TODAY}/" "$WIKI_FILE" > "$WIKI_FILE.tmp" && mv "$WIKI_FILE.tmp" "$WIKI_FILE"
echo ""
echo "Updated $WIKI_FILE:"
grep -E "^\|Version=|\|Date=" "$WIKI_FILE"
else
echo "WARNING: File not found: $WIKI_FILE"
fi
# Update the workbench version in package.xml # Update the workbench version in package.xml
PACKAGE_XML="{{project_root}}/package.xml" PACKAGE_XML="{{project_root}}/package.xml"
if [ -f "$PACKAGE_XML" ]; then if [ -f "$PACKAGE_XML" ]; then
@@ -252,7 +264,7 @@ tag-workbench version:
echo "Verifying version in source files..." echo "Verifying version in source files..."
# Check __init__.py # Check __init__.py
INIT_FILE="{{project_root}}/addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py" INIT_FILE="{{project_root}}/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py"
INIT_VERSION=$(grep -o '__version__ = "[^"]*"' "$INIT_FILE" | cut -d'"' -f2) INIT_VERSION=$(grep -o '__version__ = "[^"]*"' "$INIT_FILE" | cut -d'"' -f2)
if [ "$INIT_VERSION" != "$VERSION" ]; then if [ "$INIT_VERSION" != "$VERSION" ]; then
echo "ERROR: Version mismatch in $INIT_FILE" echo "ERROR: Version mismatch in $INIT_FILE"
@@ -262,6 +274,22 @@ tag-workbench version:
echo "Run 'just release::bump-workbench $VERSION' first, then commit the changes." echo "Run 'just release::bump-workbench $VERSION' first, then commit the changes."
exit 1 exit 1
fi fi
echo "✓ $INIT_FILE: $INIT_VERSION"
# Check wiki-source.txt
WIKI_FILE="{{project_root}}/addon/FreecadRobustMCPBridge/wiki-source.txt"
if [ -f "$WIKI_FILE" ]; then
WIKI_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_FILE" | cut -d= -f2 | tr -d '\n')
if [ "$WIKI_VERSION" != "$VERSION" ]; then
echo "ERROR: Version mismatch in $WIKI_FILE"
echo " Expected: $VERSION"
echo " Found: $WIKI_VERSION"
echo ""
echo "Run 'just release::bump-workbench $VERSION' first, then commit the changes."
exit 1
fi
echo "✓ $WIKI_FILE: $WIKI_VERSION"
fi
# Check package.xml # Check package.xml
PACKAGE_XML="{{project_root}}/package.xml" PACKAGE_XML="{{project_root}}/package.xml"
@@ -274,7 +302,9 @@ tag-workbench version:
echo "Run 'just release::bump-workbench $VERSION' first, then commit the changes." echo "Run 'just release::bump-workbench $VERSION' first, then commit the changes."
exit 1 exit 1
fi fi
echo "✓ $PACKAGE_XML (workbench): $PKG_VERSION"
echo ""
echo "Version verification passed!" echo "Version verification passed!"
echo "" echo ""
echo "Creating tag: $TAG" echo "Creating tag: $TAG"
@@ -333,6 +363,22 @@ _tag-macro macro_dir macro_name macro_file_basename tag_prefix bump_command tag_
echo "Run 'just release::{{bump_command}} $VERSION' first, then commit the changes." echo "Run 'just release::{{bump_command}} $VERSION' first, then commit the changes."
exit 1 exit 1
fi fi
echo "✓ $MACRO_FILE: $MACRO_VERSION"
# Check wiki-source.txt
WIKI_FILE="$MACRO_DIR/wiki-source.txt"
if [ -f "$WIKI_FILE" ]; then
WIKI_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_FILE" | cut -d= -f2 | tr -d '\n')
if [ "$WIKI_VERSION" != "$VERSION" ]; then
echo "ERROR: Version mismatch in $WIKI_FILE"
echo " Expected: $VERSION"
echo " Found: $WIKI_VERSION"
echo ""
echo "Run 'just release::{{bump_command}} $VERSION' first, then commit the changes."
exit 1
fi
echo "✓ $WIKI_FILE: $WIKI_VERSION"
fi
# Check package.xml # Check package.xml
PACKAGE_XML="{{project_root}}/package.xml" PACKAGE_XML="{{project_root}}/package.xml"
@@ -389,7 +435,7 @@ list-tags:
echo "=== Robust MCP Server Releases ===" echo "=== Robust MCP Server Releases ==="
git tag -l 'robust-mcp-server-v*' --sort=-v:refname | head -10 git tag -l 'robust-mcp-server-v*' --sort=-v:refname | head -10
echo "" echo ""
echo "=== MCP Workbench Releases ===" echo "=== Robust MCP Bridge Workbench Releases ==="
git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -10 git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -10
echo "" echo ""
echo "=== Cut Object for Magnets Macro Releases ===" echo "=== Cut Object for Magnets Macro Releases ==="
@@ -408,7 +454,7 @@ latest-versions:
MAGNETS_TAG=$(git tag -l 'macro-cut-object-for-magnets-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) EXPORT_TAG=$(git tag -l 'macro-multi-export-v*' --sort=-v:refname | head -n1)
echo " Robust MCP Server: ${SERVER_TAG:-none}" echo " Robust MCP Server: ${SERVER_TAG:-none}"
echo " Robust MCP Workbench: ${WORKBENCH_TAG:-none}" echo " MCP Bridge Workbench: ${WORKBENCH_TAG:-none}"
echo " Macro Magnets: ${MAGNETS_TAG:-none}" echo " Macro Magnets: ${MAGNETS_TAG:-none}"
echo " Macro Export: ${EXPORT_TAG:-none}" echo " Macro Export: ${EXPORT_TAG:-none}"
@@ -424,7 +470,7 @@ changes-since component:
;; ;;
workbench) workbench)
PREFIX="robust-mcp-workbench-v" PREFIX="robust-mcp-workbench-v"
PATHS="addon/FreecadRobustMCP" PATHS="addon/FreecadRobustMCPBridge"
;; ;;
macro-magnets|magnets) macro-magnets|magnets)
PREFIX="macro-cut-object-for-magnets-v" PREFIX="macro-cut-object-for-magnets-v"
@@ -531,7 +577,7 @@ status:
echo "" echo ""
# Robust MCP Bridge Workbench # Robust MCP Bridge Workbench
WORKBENCH_CHANGES=$(count_changes "robust-mcp-workbench-v" "addon/FreecadRobustMCP") WORKBENCH_CHANGES=$(count_changes "robust-mcp-workbench-v" "addon/FreecadRobustMCPBridge")
WORKBENCH_TAG=$(git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -1) WORKBENCH_TAG=$(git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -1)
if [ "$WORKBENCH_CHANGES" -gt 0 ]; then if [ "$WORKBENCH_CHANGES" -gt 0 ]; then
echo "Robust MCP Bridge Workbench: $WORKBENCH_CHANGES unreleased commit(s)" echo "Robust MCP Bridge Workbench: $WORKBENCH_CHANGES unreleased commit(s)"
@@ -584,7 +630,7 @@ draft-notes component:
;; ;;
workbench) workbench)
PREFIX="robust-mcp-workbench-v" PREFIX="robust-mcp-workbench-v"
PATHS="addon/FreecadRobustMCP" PATHS="addon/FreecadRobustMCPBridge"
COMPONENT_NAME="Robust MCP Bridge Workbench" COMPONENT_NAME="Robust MCP Bridge Workbench"
;; ;;
macro-magnets|magnets) macro-magnets|magnets)
@@ -735,25 +781,30 @@ dry-run-tag component version:
# FreeCAD Wiki Update Helpers # FreeCAD Wiki Update Helpers
# ============================================================================= # =============================================================================
# Helper to update FreeCAD wiki for a macro (copies content to clipboard and opens edit page) # Helper to update FreeCAD wiki for a component (copies content to clipboard and opens edit page)
wiki-update macro: wiki-update component:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
case "{{macro}}" in case "{{component}}" in
workbench|bridge)
WIKI_SOURCE="{{project_root}}/addon/FreecadRobustMCPBridge/wiki-source.txt"
WIKI_PAGE="Robust_MCP_Bridge_Workbench"
COMPONENT_NAME="Robust MCP Bridge Workbench"
;;
macro-magnets|magnets|cut) macro-magnets|magnets|cut)
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt" WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
WIKI_PAGE="Macro_Cut_Object_for_Magnets" WIKI_PAGE="Macro_Cut_Object_for_Magnets"
MACRO_NAME="Cut Object for Magnets" COMPONENT_NAME="Cut Object for Magnets"
;; ;;
macro-export|export|multi) macro-export|export|multi)
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt" WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
WIKI_PAGE="Macro_Multi_Export" WIKI_PAGE="Macro_Multi_Export"
MACRO_NAME="Multi Export" COMPONENT_NAME="Multi Export"
;; ;;
*) *)
echo "Unknown macro: {{macro}}" echo "Unknown component: {{component}}"
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)" echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)"
exit 1 exit 1
;; ;;
esac esac
@@ -764,7 +815,7 @@ wiki-update macro:
echo "FreeCAD Wiki Update Helper" echo "FreeCAD Wiki Update Helper"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "Macro: $MACRO_NAME" echo "Component: $COMPONENT_NAME"
echo "Wiki Page: https://wiki.freecad.org/${WIKI_PAGE}" echo "Wiki Page: https://wiki.freecad.org/${WIKI_PAGE}"
echo "" echo ""
@@ -859,29 +910,33 @@ wiki-update macro:
echo "The wiki content is in your clipboard - ready to paste!" echo "The wiki content is in your clipboard - ready to paste!"
fi fi
# Show the wiki source content for a macro (for review) # Show the wiki source content for a component (for review)
wiki-show macro: wiki-show component:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
case "{{macro}}" in case "{{component}}" in
workbench|bridge)
WIKI_SOURCE="{{project_root}}/addon/FreecadRobustMCPBridge/wiki-source.txt"
COMPONENT_NAME="Robust MCP Bridge Workbench"
;;
macro-magnets|magnets|cut) macro-magnets|magnets|cut)
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt" WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
MACRO_NAME="Cut Object for Magnets" COMPONENT_NAME="Cut Object for Magnets"
;; ;;
macro-export|export|multi) macro-export|export|multi)
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt" WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
MACRO_NAME="Multi Export" COMPONENT_NAME="Multi Export"
;; ;;
*) *)
echo "Unknown macro: {{macro}}" echo "Unknown component: {{component}}"
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)" echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)"
exit 1 exit 1
;; ;;
esac esac
echo "==========================================" echo "=========================================="
echo "Wiki Source: $MACRO_NAME" echo "Wiki Source: $COMPONENT_NAME"
echo "==========================================" echo "=========================================="
echo "File: $WIKI_SOURCE" echo "File: $WIKI_SOURCE"
echo "" echo ""
@@ -898,31 +953,36 @@ wiki-show macro:
cat "$WIKI_SOURCE" cat "$WIKI_SOURCE"
# Diff the local wiki source against the current wiki page (requires curl) # Diff the local wiki source against the current wiki page (requires curl)
wiki-diff macro: wiki-diff component:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
case "{{macro}}" in case "{{component}}" in
workbench|bridge)
WIKI_SOURCE="{{project_root}}/addon/FreecadRobustMCPBridge/wiki-source.txt"
WIKI_PAGE="Robust_MCP_Bridge_Workbench"
COMPONENT_NAME="Robust MCP Bridge Workbench"
;;
macro-magnets|magnets|cut) macro-magnets|magnets|cut)
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt" WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
WIKI_PAGE="Macro_Cut_Object_for_Magnets" WIKI_PAGE="Macro_Cut_Object_for_Magnets"
MACRO_NAME="Cut Object for Magnets" COMPONENT_NAME="Cut Object for Magnets"
;; ;;
macro-export|export|multi) macro-export|export|multi)
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt" WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
WIKI_PAGE="Macro_Multi_Export" WIKI_PAGE="Macro_Multi_Export"
MACRO_NAME="Multi Export" COMPONENT_NAME="Multi Export"
;; ;;
*) *)
echo "Unknown macro: {{macro}}" echo "Unknown component: {{component}}"
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)" echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)"
exit 1 exit 1
;; ;;
esac esac
WIKI_RAW_URL="https://wiki.freecad.org/index.php?title=${WIKI_PAGE}&action=raw" WIKI_RAW_URL="https://wiki.freecad.org/index.php?title=${WIKI_PAGE}&action=raw"
echo "Fetching current wiki content for $MACRO_NAME..." echo "Fetching current wiki content for $COMPONENT_NAME..."
echo "" echo ""
# Create temp file for wiki content # Create temp file for wiki content
@@ -945,7 +1005,7 @@ wiki-diff macro:
echo "==========================================" echo "=========================================="
head -20 "$WIKI_SOURCE" head -20 "$WIKI_SOURCE"
echo "..." echo "..."
echo "(truncated - run 'just release::wiki-show {{macro}}' to see full content)" echo "(truncated - run 'just release::wiki-show {{component}}' to see full content)"
exit 0 exit 0
fi fi
@@ -959,5 +1019,5 @@ wiki-diff macro:
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Differences found (above)" echo "Differences found (above)"
echo "Run 'just release::wiki-update {{macro}}' to update the wiki" echo "Run 'just release::wiki-update {{component}}' to update the wiki"
fi fi
+337 -68
View File
@@ -20,7 +20,7 @@ cov:
fast: fast:
uv run pytest {{project_root}}/tests/unit -m "not slow" uv run pytest {{project_root}}/tests/unit -m "not slow"
# Run only integration tests (requires running FreeCAD MCP bridge) # Run only integration tests (requires running FreeCAD Robust MCP Bridge)
integration: integration:
uv run pytest {{project_root}}/tests/integration -v uv run pytest {{project_root}}/tests/integration -v
@@ -68,51 +68,19 @@ integration-freecad-auto:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Source shared bridge helper functions
. "{{project_root}}/scripts/bridge-helpers.sh"
# Track whether we started FreeCAD (so cleanup knows to stop it) # Track whether we started FreeCAD (so cleanup knows to stop it)
STARTED_FREECAD=false 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 # Cleanup function to ensure FreeCAD is stopped
# We kill by port since the subshell approach makes PID tracking unreliable # We kill by port since the subshell approach makes PID tracking unreliable
cleanup() { cleanup() {
if [ "$STARTED_FREECAD" = true ]; then if [ "$STARTED_FREECAD" = true ]; then
echo "" echo ""
echo "Stopping FreeCAD..." echo "Stopping FreeCAD..."
# Kill processes on our ports - these are the ones we started graceful_kill_bridge_ports
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 fi
} }
@@ -128,7 +96,7 @@ integration-freecad-auto:
if curl -s --connect-timeout 1 --max-time 1 http://localhost:9875 > /dev/null 2>&1; then 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 # 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 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 "ERROR: A FreeCAD Robust MCP Bridge is already running on port 9875."
echo "" echo ""
echo "Options:" echo "Options:"
echo " 1. Use 'just testing::integration' to run tests against the existing bridge" echo " 1. Use 'just testing::integration' to run tests against the existing bridge"
@@ -155,7 +123,7 @@ integration-freecad-auto:
just freecad::run-headless 2>"$FREECAD_LOG" & just freecad::run-headless 2>"$FREECAD_LOG" &
# Give FreeCAD time to start the XML-RPC server # Give FreeCAD time to start the XML-RPC server
echo "Waiting for FreeCAD MCP bridge to start..." echo "Waiting for FreeCAD Robust MCP Bridge to start..."
sleep 5 sleep 5
# Check if the bridge is ready (verify XML-RPC ping, not just port open) # Check if the bridge is ready (verify XML-RPC ping, not just port open)
@@ -164,7 +132,7 @@ integration-freecad-auto:
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 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)) RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "ERROR: FreeCAD MCP bridge did not start within timeout" echo "ERROR: FreeCAD Robust MCP Bridge did not start within timeout"
echo "Check log file for details: $FREECAD_LOG" echo "Check log file for details: $FREECAD_LOG"
if [ -f "$FREECAD_LOG" ]; then if [ -f "$FREECAD_LOG" ]; then
echo "--- Last 20 lines of log ---" echo "--- Last 20 lines of log ---"
@@ -176,7 +144,7 @@ integration-freecad-auto:
sleep 1 sleep 1
done done
echo "FreeCAD MCP bridge is ready!" echo "FreeCAD Robust MCP Bridge is ready!"
echo "" echo ""
# Run integration tests # Run integration tests
@@ -206,40 +174,341 @@ just-all:
just-release: just-release:
uv run pytest {{project_root}}/tests/just_commands -m "just_release" -v uv run pytest {{project_root}}/tests/just_commands -m "just_release" -v
# =============================================================================
# Release Testing (Comprehensive Pre-Release Validation)
# =============================================================================
# Run all tests required before a release can be created
# This includes: unit tests, headless integration, GUI integration, Docker, and just commands
# All tests must pass for a release to proceed.
release-test:
#!/usr/bin/env bash
set -euo pipefail
echo "============================================================"
echo " RELEASE TEST SUITE"
echo " All tests must pass before creating a release"
echo "============================================================"
echo ""
# Track overall test results
TESTS_PASSED=true
FAILED_TESTS=""
# Helper function to record test failure
record_failure() {
TESTS_PASSED=false
FAILED_TESTS="${FAILED_TESTS}"$'\n'" - $1"
}
# -------------------------------------------------------------------------
# Step 1: Unit Tests with Coverage
# -------------------------------------------------------------------------
echo "============================================================"
echo " Step 1/5: Unit Tests with Coverage"
echo "============================================================"
echo ""
if just testing::cov; then
echo ""
echo "✓ Unit tests passed"
else
echo ""
echo "✗ Unit tests FAILED"
record_failure "Unit tests with coverage"
fi
echo ""
# -------------------------------------------------------------------------
# Step 2: Headless Integration Tests
# -------------------------------------------------------------------------
echo "============================================================"
echo " Step 2/5: Headless Integration Tests"
echo "============================================================"
echo ""
if just testing::integration-headless-release; then
echo ""
echo "✓ Headless integration tests passed"
else
echo ""
echo "✗ Headless integration tests FAILED"
record_failure "Headless integration tests"
fi
echo ""
# -------------------------------------------------------------------------
# Step 3: GUI Integration Tests
# -------------------------------------------------------------------------
echo "============================================================"
echo " Step 3/5: GUI Integration Tests"
echo "============================================================"
echo ""
if just testing::integration-gui-release; then
echo ""
echo "✓ GUI integration tests passed"
else
echo ""
echo "✗ GUI integration tests FAILED"
record_failure "GUI integration tests"
fi
echo ""
# -------------------------------------------------------------------------
# Step 4: Docker Integration Test
# -------------------------------------------------------------------------
echo "============================================================"
echo " Step 4/5: Docker Integration Test"
echo "============================================================"
echo ""
if just docker::test; then
echo ""
echo "✓ Docker integration test passed"
else
echo ""
echo "✗ Docker integration test FAILED"
record_failure "Docker integration test"
fi
echo ""
# -------------------------------------------------------------------------
# Step 5: Just Command Tests
# -------------------------------------------------------------------------
echo "============================================================"
echo " Step 5/5: Just Command Tests"
echo "============================================================"
echo ""
if just testing::just-all; then
echo ""
echo "✓ Just command tests passed"
else
echo ""
echo "✗ Just command tests FAILED"
record_failure "Just command tests"
fi
echo ""
# -------------------------------------------------------------------------
# Summary
# -------------------------------------------------------------------------
echo "============================================================"
echo " RELEASE TEST SUMMARY"
echo "============================================================"
echo ""
if [ "$TESTS_PASSED" = true ]; then
echo "✓ ALL TESTS PASSED - Ready for release!"
echo ""
echo "NOTE: There is no need to bump the MCP server version as it happens automatically from the release tag on git."
echo ""
echo "You can now create a release with:"
echo " just release::bump-workbench <version>"
echo " just release::bump-macro-magnets <version>"
echo " just release::bump-macro-export <version>"
echo ""
echo "NOTE: Merge all code/PRs into 'main' that are in flight for the release, like the version bumps above!"
echo ""
echo " just release::tag-mcp-server <version>"
echo " just release::tag-workbench <version>"
echo " just release::tag-macro-magnets <version>"
echo " just release::tag-macro-export <version>"
else
echo "✗ SOME TESTS FAILED - Cannot proceed with release"
echo ""
printf "Failed tests:%s\n" "$FAILED_TESTS"
echo ""
echo "Please fix the failing tests before creating a release."
exit 1
fi
# Run headless integration tests for release (isolated, starts/stops FreeCAD)
integration-headless-release:
#!/usr/bin/env bash
set -euo pipefail
# Source shared bridge helper functions
. "{{project_root}}/scripts/bridge-helpers.sh"
# Track whether we started FreeCAD (so cleanup knows to stop it)
STARTED_FREECAD=false
# Cleanup function
cleanup() {
if [ "$STARTED_FREECAD" = true ]; then
echo ""
echo "Stopping FreeCAD headless..."
graceful_kill_bridge_ports
fi
}
trap cleanup EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
echo "Headless Integration Test (Release Mode)"
echo "----------------------------------------"
echo ""
# Check for existing FreeCAD
if is_bridge_running; then
echo "ERROR: A FreeCAD Robust MCP Bridge is already running on port 9875."
echo ""
echo "For release testing, we need to start FreeCAD fresh."
echo "Please stop the existing FreeCAD instance and try again."
echo ""
echo "You can run: just testing::kill-bridge"
exit 1
fi
# Install latest workbench code to FreeCAD's Mod directory
# This ensures tests run against the current source code, not stale installed code
echo "Installing latest workbench code..."
just install::mcp-bridge-workbench
echo ""
# Start FreeCAD headless
STARTED_FREECAD=true
echo "Starting FreeCAD headless..."
FREECAD_LOG="{{project_root}}/freecad-headless-release.log"
just freecad::run-headless 2>"$FREECAD_LOG" &
# Wait for bridge to be ready
echo "Waiting for FreeCAD Robust MCP Bridge to start..."
MAX_RETRIES=60
RETRY_COUNT=0
while ! is_bridge_running; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "ERROR: FreeCAD Robust MCP Bridge did not start within timeout"
if [ -f "$FREECAD_LOG" ]; then
echo "--- Last 20 lines of log ---"
tail -20 "$FREECAD_LOG"
fi
exit 1
fi
if [ $((RETRY_COUNT % 10)) -eq 0 ]; then
echo " Waiting... ($RETRY_COUNT/$MAX_RETRIES)"
fi
sleep 1
done
echo "FreeCAD Robust MCP Bridge is ready! (headless mode)"
echo ""
# Run integration tests
TEST_EXIT_CODE=0
uv run pytest "{{project_root}}/tests/integration" -v || TEST_EXIT_CODE=$?
# Cleanup handled by trap
rm -f "$FREECAD_LOG" 2>/dev/null || true
exit $TEST_EXIT_CODE
# Run GUI integration tests for release (isolated, starts/stops FreeCAD)
integration-gui-release:
#!/usr/bin/env bash
set -euo pipefail
# Source shared bridge helper functions
. "{{project_root}}/scripts/bridge-helpers.sh"
# Track whether we started FreeCAD (so cleanup knows to stop it)
STARTED_FREECAD=false
# Cleanup function
cleanup() {
if [ "$STARTED_FREECAD" = true ]; then
echo ""
echo "Stopping FreeCAD GUI..."
graceful_kill_bridge_ports
# On macOS, also try to quit FreeCAD gracefully
if [[ "$OSTYPE" == "darwin"* ]]; then
osascript -e 'tell application "FreeCAD" to quit' 2>/dev/null || true
fi
fi
}
trap cleanup EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
echo "GUI Integration Test (Release Mode)"
echo "------------------------------------"
echo ""
# Check for existing FreeCAD
if is_bridge_running; then
echo "ERROR: A FreeCAD Robust MCP Bridge is already running on port 9875."
echo ""
echo "For release testing, we need to start FreeCAD fresh."
echo "Please stop the existing FreeCAD instance and try again."
echo ""
echo "You can run: just testing::kill-bridge"
exit 1
fi
# Install latest workbench code to FreeCAD's Mod directory
# This ensures tests run against the current source code, not stale installed code
echo "Installing latest workbench code..."
just install::mcp-bridge-workbench
echo ""
# Start FreeCAD GUI
STARTED_FREECAD=true
echo "Starting FreeCAD GUI..."
FREECAD_LOG="{{project_root}}/freecad-gui-release.log"
# Start FreeCAD GUI (this returns immediately on macOS)
just freecad::run-gui 2>"$FREECAD_LOG" || true
# Wait for bridge to be ready (GUI takes longer to start)
echo "Waiting for FreeCAD Robust MCP Bridge to start (GUI mode, may take 30-60s)..."
MAX_RETRIES=90
RETRY_COUNT=0
while ! is_bridge_running; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "ERROR: FreeCAD Robust MCP Bridge did not start within timeout"
if [ -f "$FREECAD_LOG" ]; then
echo "--- Last 20 lines of log ---"
tail -20 "$FREECAD_LOG"
fi
exit 1
fi
if [ $((RETRY_COUNT % 10)) -eq 0 ]; then
echo " Waiting... ($RETRY_COUNT/$MAX_RETRIES)"
fi
sleep 1
done
echo "FreeCAD Robust MCP Bridge is ready! (GUI mode)"
echo ""
# Run integration tests
TEST_EXIT_CODE=0
uv run pytest "{{project_root}}/tests/integration" -v || TEST_EXIT_CODE=$?
# Cleanup handled by trap
rm -f "$FREECAD_LOG" 2>/dev/null || true
exit $TEST_EXIT_CODE
# ============================================================================= # =============================================================================
# Bridge Management # Bridge Management
# ============================================================================= # =============================================================================
# Kill any zombie FreeCAD MCP bridge processes on the default ports # Kill any zombie FreeCAD Robust MCP Bridge processes on the default ports
kill-bridge: kill-bridge:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Source shared bridge helper functions
. "{{project_root}}/scripts/bridge-helpers.sh"
echo "Killing any processes on MCP bridge ports (9875, 9876)..." echo "Killing any processes on MCP bridge ports (9875, 9876)..."
force_kill_bridge_ports
# 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." echo "Done."
+1 -1
View File
@@ -1,4 +1,4 @@
# FreeCAD MCP Server - Development Workflow Commands # FreeCAD Robust MCP Suite - Development Workflow Commands
# https://just.systems/ # https://just.systems/
# #
# Commands are organized into modules. Run `just` to see top-level commands, # Commands are organized into modules. Run `just` to see top-level commands,
@@ -21,8 +21,8 @@ Usage:
__Name__ = "Cut Object for Magnets" __Name__ = "Cut Object for Magnets"
__Comment__ = "Cut an object along a plane and add aligned magnet holes with surface collision detection" __Comment__ = "Cut an object along a plane and add aligned magnet holes with surface collision detection"
__Author__ = "Sean P. Kane" __Author__ = "Sean P. Kane"
__Version__ = "0.5.0-beta" __Version__ = "0.6.0"
__Date__ = "2026-01-05" __Date__ = "2026-01-12"
__License__ = "MIT" __License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more" __Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme" __Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
@@ -2,10 +2,12 @@
![Macro Icon](CutObjectForMagnets.svg) ![Macro Icon](CutObjectForMagnets.svg)
**Version:** 0.5.0-beta **Version:** 0.6.0
**FreeCAD Version:** 0.19 or later **FreeCAD Version:** 0.19 or later
**License:** MIT **License:** MIT
> **Documentation:** [https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/)
## Overview ## Overview
This FreeCAD macro intelligently cuts 3D objects along a plane and automatically places magnet holes (for magnets, dowels, pins, etc.) with built-in surface penetration detection. Unlike simple cutting tools, this macro ensures magnet holes won't accidentally break through the outer surface of your object. This FreeCAD macro intelligently cuts 3D objects along a plane and automatically places magnet holes (for magnets, dowels, pins, etc.) with built-in surface penetration detection. Unlike simple cutting tools, this macro ensures magnet holes won't accidentally break through the outer surface of your object.
@@ -7,8 +7,8 @@
|Icon=Macro_Cut_Object_For_Magnets.png |Icon=Macro_Cut_Object_For_Magnets.png
|Description=Cut an object along a plane and add aligned magnet holes with surface collision detection. Creates two parts with perfectly aligned holes for embedding magnets that allow the parts to snap together. |Description=Cut an object along a plane and add aligned magnet holes with surface collision detection. Creates two parts with perfectly aligned holes for embedding magnets that allow the parts to snap together.
|Author=Sean P. Kane |Author=Sean P. Kane
|Version=0.5.0-beta |Version=0.6.0
|Date=2026-01-05 |Date=2026-01-12
|FCVersion=0.19+ |FCVersion=0.19+
|Download=[https://wiki.freecad.org/images/thumb/e/e3/Macro_Cut_Object_For_Magnets.png/48px-Macro_Cut_Object_For_Magnets.png ToolBar Icon] |Download=[https://wiki.freecad.org/images/thumb/e/e3/Macro_Cut_Object_For_Magnets.png/48px-Macro_Cut_Object_For_Magnets.png ToolBar Icon]
|SeeAlso=[[Part_Slice|Part Slice]], [[PartDesign_Hole|PartDesign Hole]] |SeeAlso=[[Part_Slice|Part Slice]], [[PartDesign_Hole|PartDesign Hole]]
@@ -142,7 +142,6 @@ This image shows a vase object in its original form, and after being cut multipl
<!--T:22--> <!--T:22-->
The full source code is hosted on GitHub: The full source code is hosted on GitHub:
* [https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro CutObjectForMagnets.FCMacro on GitHub] * [https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro CutObjectForMagnets.FCMacro on GitHub]
* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository (freecad-robust-mcp-and-more)]
==Script== <!--T:23--> ==Script== <!--T:23-->
@@ -158,6 +157,8 @@ ToolBar Icon [[Image:Macro_Cut_Object_For_Magnets.png]]
==Links== <!--T:25--> ==Links== <!--T:25-->
<!--T:26--> <!--T:26-->
* [https://spkane.github.io/freecad-robust-mcp-and-more/ Full Documentation] - Complete guides and tutorials
* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository] - Source code and issue tracker
* [[Part_Slice|Part Slice]] - FreeCAD's built-in slice tool * [[Part_Slice|Part Slice]] - FreeCAD's built-in slice tool
* [[PartDesign_Hole|PartDesign Hole]] - Parametric hole feature documentation * [[PartDesign_Hole|PartDesign Hole]] - Parametric hole feature documentation
* [[PartDesign_Body|PartDesign Body]] - Body container documentation * [[PartDesign_Body|PartDesign Body]] - Body container documentation
+2 -2
View File
@@ -22,8 +22,8 @@ Usage:
__Name__ = "Multi Export" __Name__ = "Multi Export"
__Comment__ = "Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously" __Comment__ = "Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously"
__Author__ = "Sean P. Kane" __Author__ = "Sean P. Kane"
__Version__ = "0.5.0-beta" __Version__ = "0.6.0"
__Date__ = "2026-01-05" __Date__ = "2026-01-12"
__License__ = "MIT" __License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more" __Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme" __Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
+3 -1
View File
@@ -2,10 +2,12 @@
![Macro Icon](MultiExport.svg) ![Macro Icon](MultiExport.svg)
**Version:** 0.5.0-beta **Version:** 0.6.0
**FreeCAD Version:** 0.19 or later **FreeCAD Version:** 0.19 or later
**License:** MIT **License:** MIT
> **Documentation:** [https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/)
## Overview ## Overview
This FreeCAD macro exports selected objects to multiple file formats simultaneously with a single click. It features a user-friendly dialog for selecting export formats, configuring output options, and previewing the files that will be created. This FreeCAD macro exports selected objects to multiple file formats simultaneously with a single click. It features a user-friendly dialog for selecting export formats, configuring output options, and previewing the files that will be created.
+4 -3
View File
@@ -7,8 +7,8 @@
|Icon=Macro_Multi_Export.png |Icon=Macro_Multi_Export.png
|Description=Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously with a user-friendly dialog for format selection and output configuration. |Description=Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously with a user-friendly dialog for format selection and output configuration.
|Author=Sean P. Kane |Author=Sean P. Kane
|Version=0.5.0-beta |Version=0.6.0
|Date=2026-01-05 |Date=2026-01-12
|FCVersion=0.21+ |FCVersion=0.21+
|Download=[https://wiki.freecad.org/images/thumb/1/19/Macro_Multi_Export.png/48px-Macro_Multi_Export.png ToolBar Icon] |Download=[https://wiki.freecad.org/images/thumb/1/19/Macro_Multi_Export.png/48px-Macro_Multi_Export.png ToolBar Icon]
|SeeAlso=[[Std_Export|Std Export]], [[Import_Export|Import Export]] |SeeAlso=[[Std_Export|Std Export]], [[Import_Export|Import Export]]
@@ -76,7 +76,6 @@ This macro provides a convenient way to export selected FreeCAD objects to multi
<!--T:13--> <!--T:13-->
The full source code is hosted on GitHub: The full source code is hosted on GitHub:
* [https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Multi_Export/MultiExport.FCMacro MultiExport.FCMacro on GitHub] * [https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Multi_Export/MultiExport.FCMacro MultiExport.FCMacro on GitHub]
* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository (freecad-robust-mcp-and-more)]
==Script== <!--T:14--> ==Script== <!--T:14-->
@@ -92,6 +91,8 @@ ToolBar Icon [[Image:Macro_Multi_Export.png]]
==Links== <!--T:16--> ==Links== <!--T:16-->
<!--T:17--> <!--T:17-->
* [https://spkane.github.io/freecad-robust-mcp-and-more/ Full Documentation] - Complete guides and tutorials
* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository] - Source code and issue tracker
* [[Std_Export|Std Export]] - FreeCAD's built-in export function * [[Std_Export|Std Export]] - FreeCAD's built-in export function
* [[Import_Export|Import Export]] - Overview of FreeCAD import/export formats * [[Import_Export|Import Export]] - Overview of FreeCAD import/export formats
+6 -2
View File
@@ -1,5 +1,5 @@
site_name: FreeCAD Robust MCP Server site_name: FreeCAD Robust MCP Suite
site_description: Robust MCP Server for FreeCAD integration with AI assistants site_description: FreeCAD Robust MCP Suite - MCP Server, Bridge Workbench, and Macros for AI assistant integration
site_url: https://github.com/spkane/freecad-robust-mcp-and-more site_url: https://github.com/spkane/freecad-robust-mcp-and-more
repo_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 repo_name: spkane/freecad-robust-mcp-and-more
@@ -106,6 +106,10 @@ plugins:
j2_block_end_string: "@%}" j2_block_end_string: "@%}"
extra: extra:
# Version selector for mike-managed versioned docs
version:
provider: mike
default: latest
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/spkane/freecad-robust-mcp-and-more link: https://github.com/spkane/freecad-robust-mcp-and-more
+12 -12
View File
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata"> <package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>FreeCAD MCP and More</name> <name>FreeCAD Robust MCP Suite</name>
<description>A collection of FreeCAD macros including an MCP (Model Context Protocol) bridge for AI assistant integration, multi-format export utilities, and a tool for cutting objects with aligned magnet holes for 3D printing.</description> <description>A collection of FreeCAD macros plus a robust MCP (Model Context Protocol) bridge workbench for AI assistant integration.</description>
<icon>addon/FreecadRobustMCP/FreecadRobustMCP.svg</icon> <icon>addon/FreecadRobustMCPBridge/FreecadRobustMCPBridge.svg</icon>
<!-- Note: Each component has its own version below. This top-level date is for reference only. --> <!-- Note: Each component has its own version below. This top-level date is for reference only. -->
<date>2026-01-07</date> <date>2026-01-07</date>
@@ -37,19 +37,19 @@
<workbench> <workbench>
<name>Robust MCP Bridge</name> <name>Robust MCP Bridge</name>
<version>0.5.0-beta</version> <version>0.6.0</version>
<date>2026-01-07</date> <date>2026-01-12</date>
<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> <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> <classname>FreecadRobustMCPBridgeWorkbench</classname>
<subdirectory>./addon/FreecadRobustMCP/</subdirectory> <subdirectory>./addon/FreecadRobustMCPBridge/</subdirectory>
<icon>FreecadRobustMCP.svg</icon> <icon>FreecadRobustMCPBridge.svg</icon>
<freecadmin>0.21</freecadmin> <freecadmin>0.21</freecadmin>
</workbench> </workbench>
<macro> <macro>
<name>Multi Export</name> <name>Multi Export</name>
<version>0.5.0-beta</version> <version>0.6.0</version>
<date>2026-01-07</date> <date>2026-01-12</date>
<description>Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously with configurable mesh options.</description> <description>Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously with configurable mesh options.</description>
<subdirectory>./macros/Multi_Export/</subdirectory> <subdirectory>./macros/Multi_Export/</subdirectory>
<file>MultiExport.FCMacro</file> <file>MultiExport.FCMacro</file>
@@ -58,8 +58,8 @@
<macro> <macro>
<name>Cut Object for Magnets</name> <name>Cut Object for Magnets</name>
<version>0.5.0-beta</version> <version>0.6.0</version>
<date>2026-01-07</date> <date>2026-01-12</date>
<description>Cut an object along a plane and add aligned magnet holes with surface collision detection. Perfect for creating 3D printed parts that snap together with embedded magnets.</description> <description>Cut an object along a plane and add aligned magnet holes with surface collision detection. Perfect for creating 3D printed parts that snap together with embedded magnets.</description>
<subdirectory>./macros/Cut_Object_for_Magnets/</subdirectory> <subdirectory>./macros/Cut_Object_for_Magnets/</subdirectory>
<file>CutObjectForMagnets.FCMacro</file> <file>CutObjectForMagnets.FCMacro</file>
+1
View File
@@ -86,6 +86,7 @@ dev = [
"mkdocs-redirects>=1.2.0", "mkdocs-redirects>=1.2.0",
"mkdocs-glightbox>=0.4.0", "mkdocs-glightbox>=0.4.0",
"mkdocs-macros-plugin>=1.0.0", "mkdocs-macros-plugin>=1.0.0",
"mike>=2.1.0",
"md-toc>=9.0.0", "md-toc>=9.0.0",
"codespell>=2.3.0", "codespell>=2.3.0",
# Publishing # Publishing
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Shared helper functions for FreeCAD MCP Bridge management
# Source this file in Just recipes: . ./scripts/bridge-helpers.sh
# 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
else
echo "Warning: Neither lsof nor fuser available, cannot kill port $port"
fi
}
# Check if the MCP bridge is running and responsive
# Returns 0 (true) if bridge is running, 1 (false) otherwise
is_bridge_running() {
local port=${1:-9875} # Default to port 9875
curl -s --connect-timeout 1 --max-time 1 "http://localhost:$port" > /dev/null 2>&1 && \
uv run python -c "import socket; socket.setdefaulttimeout(2); import xmlrpc.client; print(xmlrpc.client.ServerProxy('http://localhost:$port').ping())" 2>/dev/null | grep -q "pong"
}
# Force kill any processes on the default MCP bridge ports
# Usage: force_kill_bridge_ports
force_kill_bridge_ports() {
kill_port 9875 -9
kill_port 9876 -9
}
# Graceful shutdown of bridge ports (SIGTERM first, then SIGKILL)
# Usage: graceful_kill_bridge_ports
graceful_kill_bridge_ports() {
kill_port 9875
kill_port 9876
sleep 1
kill_port 9875 -9
kill_port 9876 -9
}
+2 -2
View File
@@ -4,7 +4,7 @@ This bridge connects to a FreeCAD instance running an XML-RPC server,
allowing remote control while FreeCAD has its GUI open. allowing remote control while FreeCAD has its GUI open.
Compatible with neka-nat/freecad-mcp XML-RPC protocol, providing Compatible with neka-nat/freecad-mcp XML-RPC protocol, providing
interoperability with existing FreeCAD MCP addons. interoperability with existing FreeCAD Robust MCP addons.
Design inspired by neka-nat/freecad-mcp (MIT License): Design inspired by neka-nat/freecad-mcp (MIT License):
- Uses neka-nat's proven XML-RPC protocol (port 9875) - Uses neka-nat's proven XML-RPC protocol (port 9875)
@@ -114,7 +114,7 @@ class XmlRpcBridge(FreecadBridge):
================================================================================ ================================================================================
CONNECTION REFUSED: Cannot connect to FreeCAD at {self._server_url} CONNECTION REFUSED: Cannot connect to FreeCAD at {self._server_url}
The FreeCAD MCP bridge server is not running. To fix this: The FreeCAD Robust MCP Bridge server is not running. To fix this:
1. Start FreeCAD (the GUI application) 1. Start FreeCAD (the GUI application)
+3 -3
View File
@@ -1,4 +1,4 @@
"""FreeCAD MCP prompts for common CAD tasks. """FreeCAD Robust MCP prompts for common CAD tasks.
This module provides reusable prompt templates that help Claude This module provides reusable prompt templates that help Claude
understand FreeCAD concepts and guide users through complex tasks. understand FreeCAD concepts and guide users through complex tasks.
@@ -655,12 +655,12 @@ p.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
@mcp.prompt() @mcp.prompt()
async def troubleshooting() -> str: async def troubleshooting() -> str:
"""General troubleshooting guide for FreeCAD MCP. """General troubleshooting guide for FreeCAD Robust MCP.
Returns: Returns:
Troubleshooting guidance. Troubleshooting guidance.
""" """
return """# FreeCAD MCP Troubleshooting Guide return """# FreeCAD Robust MCP Troubleshooting Guide
## Connection Issues ## Connection Issues
+2 -2
View File
@@ -1,4 +1,4 @@
"""FreeCAD MCP resources for exposing FreeCAD state. """FreeCAD Robust MCP resources for exposing FreeCAD state.
This module provides MCP resources that expose FreeCAD's current state This module provides MCP resources that expose FreeCAD's current state
as read-only data. Resources are URI-addressable data that Claude can as read-only data. Resources are URI-addressable data that Claude can
@@ -836,7 +836,7 @@ def register_resources(mcp: Any, get_bridge: Any) -> None:
"prompts": [ "prompts": [
{ {
"name": "freecad-help", "name": "freecad-help",
"description": "Get help on FreeCAD MCP capabilities", "description": "Get help on FreeCAD Robust MCP capabilities",
}, },
{ {
"name": "create-parametric-part", "name": "create-parametric-part",
+4 -4
View File
@@ -318,7 +318,7 @@ Examples:
FREECAD_SOCKET_HOST=192.168.1.100 freecad-mcp FREECAD_SOCKET_HOST=192.168.1.100 freecad-mcp
Prerequisites: Prerequisites:
The FreeCAD MCP Bridge must be running before starting this server. The FreeCAD Robust MCP Bridge must be running before starting this server.
Start it via: Start it via:
- FreeCAD GUI: Install Robust MCP Bridge workbench, enable auto-start - FreeCAD GUI: Install Robust MCP Bridge workbench, enable auto-start
- Headless: just freecad::run-headless - Headless: just freecad::run-headless
@@ -411,9 +411,9 @@ def main() -> None:
# Set up logging # Set up logging
logging.getLogger().setLevel(config.log_level) logging.getLogger().setLevel(config.log_level)
# Print instance ID to stdout for test automation to capture # Print instance ID to stderr for test automation to capture
# This is printed before logging to ensure it's easily parseable # Must use stderr because stdout is reserved for JSON-RPC in stdio mode
print(f"FREECAD_MCP_INSTANCE_ID={INSTANCE_ID}", file=sys.stdout, flush=True) print(f"FREECAD_MCP_INSTANCE_ID={INSTANCE_ID}", file=sys.stderr, flush=True)
logger.info("Starting FreeCAD Robust MCP Server") logger.info("Starting FreeCAD Robust MCP Server")
logger.info("Instance ID: %s", INSTANCE_ID) logger.info("Instance ID: %s", INSTANCE_ID)
+1 -1
View File
@@ -1 +1 @@
"""Test suite for FreeCAD MCP Server.""" """Test suite for FreeCAD Robust MCP Suite."""
+2 -2
View File
@@ -223,10 +223,10 @@ ls -la /tmp/screenshot.png /tmp/gui_test.FCStd 2>/dev/null || echo "Files not cr
echo "" echo ""
echo "=== Test 5: Start FreeCAD GUI with MCP bridge ===" echo "=== Test 5: Start FreeCAD GUI with MCP bridge ==="
if [ -f "/workspace/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py" ]; then if [ -f "/workspace/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py" ]; then
echo "Starting FreeCAD with MCP bridge..." echo "Starting FreeCAD with MCP bridge..."
freecad /workspace/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 & freecad /workspace/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 &
FREECAD_PID=$! FREECAD_PID=$!
echo "FreeCAD PID: $FREECAD_PID" echo "FreeCAD PID: $FREECAD_PID"
+11 -11
View File
@@ -1,11 +1,11 @@
"""Pytest configuration for integration tests. """Pytest configuration for integration tests.
This module handles connection checking and provides hard error behavior This module handles connection checking and provides hard error behavior
when the FreeCAD MCP bridge is not available. Connection failures are when the FreeCAD Robust MCP Bridge is not available. Connection failures are
treated as test errors, not skips. treated as test errors, not skips.
Instance ID Verification: Instance ID Verification:
The FreeCAD MCP bridge generates a unique instance ID at startup which is The FreeCAD Robust MCP Bridge generates a unique instance ID at startup which is
printed to stdout. Tests can capture this ID and verify they're connected printed to stdout. Tests can capture this ID and verify they're connected
to the expected instance using the `bridge_instance_id` fixture or by to the expected instance using the `bridge_instance_id` fixture or by
calling proxy.get_instance_id(). calling proxy.get_instance_id().
@@ -28,7 +28,7 @@ _connection_checked: bool = False
def _check_bridge_connection() -> tuple[bool, str | None, str | None]: def _check_bridge_connection() -> tuple[bool, str | None, str | None]:
"""Check if the FreeCAD MCP bridge is available and get its instance ID. """Check if the FreeCAD Robust MCP Bridge is available and get its instance ID.
Returns: Returns:
Tuple of (is_available, error_message, instance_id) Tuple of (is_available, error_message, instance_id)
@@ -65,17 +65,17 @@ _result_ = {"gui_up": bool(FreeCAD.GuiUp)}
_gui_available = False _gui_available = False
else: else:
_bridge_available = False _bridge_available = False
_bridge_error = "FreeCAD MCP bridge not responding to ping" _bridge_error = "FreeCAD Robust MCP Bridge not responding to ping"
_bridge_instance_id = None _bridge_instance_id = None
_gui_available = None _gui_available = None
except ConnectionRefusedError: except ConnectionRefusedError:
_bridge_available = False _bridge_available = False
_bridge_error = "Connection refused - FreeCAD MCP bridge not running" _bridge_error = "Connection refused - FreeCAD Robust MCP Bridge not running"
_bridge_instance_id = None _bridge_instance_id = None
_gui_available = None _gui_available = None
except Exception as e: except Exception as e:
_bridge_available = False _bridge_available = False
_bridge_error = f"Cannot connect to FreeCAD MCP bridge: {e}" _bridge_error = f"Cannot connect to FreeCAD Robust MCP Bridge: {e}"
_bridge_instance_id = None _bridge_instance_id = None
_gui_available = None _gui_available = None
@@ -182,7 +182,7 @@ def pytest_collection_modifyitems(
_connection_checked = True _connection_checked = True
pytest.fail( pytest.fail(
f"\n\n{'=' * 60}\n" f"\n\n{'=' * 60}\n"
f"INTEGRATION TEST ERROR: FreeCAD MCP bridge not available\n" f"INTEGRATION TEST ERROR: FreeCAD Robust MCP Bridge not available\n"
f"{'=' * 60}\n\n" f"{'=' * 60}\n\n"
f"Error: {error}\n\n" f"Error: {error}\n\n"
f"To run integration tests, start FreeCAD with the MCP bridge:\n" f"To run integration tests, start FreeCAD with the MCP bridge:\n"
@@ -209,7 +209,7 @@ def pytest_terminal_summary(
return return
# Build the summary message # Build the summary message
terminalreporter.write_sep("=", "FreeCAD MCP Bridge Status") terminalreporter.write_sep("=", "FreeCAD Robust MCP Bridge Status")
if _bridge_available: if _bridge_available:
mode = "GUI" if _gui_available else "Headless" mode = "GUI" if _gui_available else "Headless"
@@ -227,21 +227,21 @@ def pytest_terminal_summary(
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def xmlrpc_proxy() -> xmlrpc.client.ServerProxy: def xmlrpc_proxy() -> xmlrpc.client.ServerProxy:
"""Create XML-RPC proxy to FreeCAD MCP bridge. """Create XML-RPC proxy to FreeCAD Robust MCP Bridge.
This fixture is shared across all integration test modules. This fixture is shared across all integration test modules.
The connection check has already been performed during collection. The connection check has already been performed during collection.
""" """
is_available, error, _ = _check_bridge_connection() is_available, error, _ = _check_bridge_connection()
if not is_available: if not is_available:
pytest.skip(error or "FreeCAD MCP bridge not available") pytest.skip(error or "FreeCAD Robust MCP Bridge not available")
return xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True) return xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def bridge_instance_id() -> str | None: def bridge_instance_id() -> str | None:
"""Get the instance ID of the connected FreeCAD MCP bridge. """Get the instance ID of the connected FreeCAD Robust MCP Bridge.
This fixture returns the unique instance ID that was generated when This fixture returns the unique instance ID that was generated when
the bridge started. Use this to verify you're connected to the expected the bridge started. Use this to verify you're connected to the expected
@@ -12,7 +12,7 @@ Test Organization:
- TestBooleanHoleFallback: Tests for the boolean hole creation method - TestBooleanHoleFallback: Tests for the boolean hole creation method
- TestEdgeCases: Edge case tests (single hole, many holes) - TestEdgeCases: Edge case tests (single hole, many holes)
Note: These tests require a running FreeCAD MCP bridge. Note: These tests require a running FreeCAD Robust MCP Bridge.
Start it with: just freecad::run-gui or just freecad::run-headless Start it with: just freecad::run-gui or just freecad::run-headless
Note: PartDesign::Hole has a CADKernelError bug in some FreeCAD headless Note: PartDesign::Hole has a CADKernelError bug in some FreeCAD headless
+1 -1
View File
@@ -1,4 +1,4 @@
"""Integration tests for FreeCAD MCP GUI mode. """Integration tests for FreeCAD Robust MCP GUI mode.
These tests verify that the MCP bridge works correctly when FreeCAD is running These tests verify that the MCP bridge works correctly when FreeCAD is running
in GUI mode (with full graphical interface). They test GUI-specific features in GUI mode (with full graphical interface). They test GUI-specific features
+1 -1
View File
@@ -1,4 +1,4 @@
"""Integration tests for FreeCAD MCP bridge functionality. """Integration tests for FreeCAD Robust MCP Bridge functionality.
These tests verify that the MCP bridge works correctly with FreeCAD, including These tests verify that the MCP bridge works correctly with FreeCAD, including
object creation, manipulation, and export functionality. Most tests work in object creation, manipulation, and export functionality. Most tests work in
+1 -1
View File
@@ -6,7 +6,7 @@ These tests verify the MultiExporter class functionality including:
- Exporting to multiple formats simultaneously - Exporting to multiple formats simultaneously
- Handling mesh tolerance settings - Handling mesh tolerance settings
Note: These tests require a running FreeCAD MCP bridge. Note: These tests require a running FreeCAD Robust MCP Bridge.
Start it with: just freecad::run-gui or just freecad::run-headless Start it with: just freecad::run-gui or just freecad::run-headless
To run these tests: To run these tests:
+358
View File
@@ -0,0 +1,358 @@
"""Thread safety tests for FreeCAD Robust MCP Bridge.
These tests verify that code execution happens on the correct thread
depending on FreeCAD's mode (GUI vs headless).
CRITICAL: In GUI mode, code MUST execute on the main Qt thread.
Executing Qt operations from background threads causes crashes.
This was discovered when Init.py started the bridge before FreeCAD.GuiUp
was True, causing the bridge to use a background thread even in GUI mode.
The tests in this module cover:
- Thread context verification (main thread vs background thread)
- Queue processor mode detection and validation
- Safe execution of GUI operations (document creation, view operations)
See Also:
addon/FreecadRobustMCPBridge/Init.py: The fix for the GUI startup race condition
addon/FreecadRobustMCPBridge/freecad_mcp_bridge/bridge_utils.py: GuiWaiter helper
"""
from __future__ import annotations
import logging
import uuid
import xmlrpc.client
from typing import Any
import pytest
logger = logging.getLogger(__name__)
def _check_gui_available() -> bool:
"""Check if FreeCAD GUI is available via the bridge.
This function runs at test collection time to determine which tests to skip.
Connection errors and timeouts are expected when FreeCAD isn't running
and indicate GUI unavailability.
"""
try:
proxy = xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True)
result: dict[str, Any] = proxy.execute( # type: ignore[assignment]
"""
import FreeCAD
_result_ = {"gui_up": bool(FreeCAD.GuiUp)}
"""
)
if result.get("success") and result.get("result"):
return result["result"].get("gui_up", False)
except (OSError, xmlrpc.client.Fault, TimeoutError) as e:
# Expected when bridge isn't running - log at debug level
logger.debug("Bridge GUI check failed (expected if not running): %s", e)
except Exception as e:
# Unexpected exceptions - log as warning for debugging
logger.warning("Unexpected error checking GUI availability: %s", e)
return False
def _check_headless_mode() -> bool:
"""Check if FreeCAD is running in headless mode.
Returns True if GUI is not available (either headless mode or bridge unreachable).
See _check_gui_available() for details on exception handling.
"""
return not _check_gui_available()
# Skip markers for mode-specific tests
# These are evaluated at test collection time, so _check_gui_available() runs early.
# If the bridge isn't reachable, GUI tests are skipped and headless tests run.
requires_gui = pytest.mark.skipif(
_check_headless_mode(),
reason="Test requires FreeCAD GUI mode (running in headless mode)",
)
requires_headless = pytest.mark.skipif(
_check_gui_available(),
reason="Test requires FreeCAD headless mode (running in GUI mode)",
)
class TestThreadSafety:
"""Tests to verify thread-safe code execution in FreeCAD MCP Bridge.
These tests ensure that Python code executed via the MCP bridge runs on
the appropriate thread depending on FreeCAD's mode:
- GUI mode: Code must execute on the main Qt thread to safely perform
Qt operations (document creation, view manipulation, etc.)
- Headless mode: Code runs on a background thread since there's no
Qt event loop
The bridge uses a queue processor that chooses its execution strategy
based on FreeCAD.GuiUp at startup time. If the bridge starts before
GuiUp is True, it incorrectly uses a background thread even in GUI mode,
causing crashes when Qt operations are attempted.
"""
def test_execution_thread_info(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that we can get thread information from executed code.
This test verifies the thread context regardless of mode.
"""
code = """
import threading
import FreeCAD
_result_ = {
"gui_up": FreeCAD.GuiUp,
"is_main_thread": threading.current_thread() is threading.main_thread(),
"thread_name": threading.current_thread().name,
}
"""
result: dict[str, Any] = xmlrpc_proxy.execute(code) # type: ignore[assignment]
assert result["success"], f"Execution failed: {result.get('stderr', '')}"
assert result["result"] is not None
thread_info = result["result"]
assert "gui_up" in thread_info
assert "is_main_thread" in thread_info
assert "thread_name" in thread_info
@requires_gui
def test_gui_mode_executes_on_main_thread(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""CRITICAL: In GUI mode, code MUST execute on the main thread.
If this test fails, it indicates the bridge started before
FreeCAD.GuiUp was True, causing it to use a background thread
for queue processing instead of a Qt timer.
This bug caused SIGABRT crashes when creating documents or
doing any Qt operations from the background thread.
See: addon/FreecadRobustMCPBridge/Init.py for the fix.
"""
code = """
import threading
import FreeCAD
_result_ = {
"gui_up": FreeCAD.GuiUp,
"is_main_thread": threading.current_thread() is threading.main_thread(),
"thread_name": threading.current_thread().name,
}
"""
result: dict[str, Any] = xmlrpc_proxy.execute(code) # type: ignore[assignment]
assert result["success"], f"Execution failed: {result.get('stderr', '')}"
assert result["result"] is not None
thread_info = result["result"]
# Verify GUI is up
assert thread_info["gui_up"], "FreeCAD should be in GUI mode for this test"
# CRITICAL: Code must execute on main thread in GUI mode
# The is_main_thread check is the authoritative test - thread names can vary
# by Python version and platform, but main thread identity is reliable.
assert thread_info["is_main_thread"], (
f"CRITICAL BUG: In GUI mode, code is executing on background thread "
f"'{thread_info['thread_name']}' instead of main thread!\n"
f"This will cause crashes when doing Qt operations.\n"
f"Check that Init.py waits for FreeCAD.GuiUp before starting the bridge."
)
@requires_gui
def test_gui_mode_can_create_document_safely(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that document creation works in GUI mode.
Document creation involves Qt operations internally. If the bridge
is executing on a background thread, this will crash FreeCAD.
"""
# Use unique document name to avoid collisions with parallel tests
doc_name = f"TestThreadSafety_{uuid.uuid4().hex[:8]}"
code = f"""
import FreeCAD
doc_name = {doc_name!r}
doc = None
created = False
try:
# Create a temporary document
doc = FreeCAD.newDocument(doc_name)
# Verify it was created
created = doc is not None and doc.Name == doc_name
finally:
# Clean up - always close document if it exists
if doc_name in [d.Name for d in FreeCAD.listDocuments().values()]:
FreeCAD.closeDocument(doc_name)
_result_ = {{"success": created, "doc_name": doc_name}}
"""
result: dict[str, Any] = xmlrpc_proxy.execute(code) # type: ignore[assignment]
# If we get here without crash, the test passed
assert result["success"], f"Execution failed: {result.get('stderr', '')}"
assert result["result"] is not None
assert result["result"]["success"], "Failed to create document"
@requires_headless
def test_headless_mode_uses_background_thread(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""In headless mode, the bridge uses a background thread for queue processing.
This is expected behavior in headless mode since there's no Qt event loop.
"""
code = """
import threading
import FreeCAD
_result_ = {
"gui_up": FreeCAD.GuiUp,
"is_main_thread": threading.current_thread() is threading.main_thread(),
"thread_name": threading.current_thread().name,
}
"""
result: dict[str, Any] = xmlrpc_proxy.execute(code) # type: ignore[assignment]
assert result["success"], f"Execution failed: {result.get('stderr', '')}"
assert result["result"] is not None
thread_info = result["result"]
# Verify headless mode
assert not thread_info["gui_up"], (
"FreeCAD should be in headless mode for this test"
)
# In headless mode, background thread is expected
assert not thread_info["is_main_thread"], (
"In headless mode, expected background thread for queue processing"
)
@requires_gui
def test_gui_mode_view_operations_safe(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that view operations work safely in GUI mode.
View operations require the GUI and must be on the main thread.
"""
# Use unique document name to avoid collisions with parallel tests
doc_name = f"TestViewOps_{uuid.uuid4().hex[:8]}"
code = f"""
import FreeCAD
import FreeCADGui
doc_name = {doc_name!r}
has_view = False
visible = None
try:
# Create a document
doc = FreeCAD.newDocument(doc_name)
# Create an object
box = doc.addObject("Part::Box", "TestBox")
doc.recompute()
# Try to access view properties (will fail on background thread)
view_obj = box.ViewObject
has_view = view_obj is not None
# Check visibility
visible = view_obj.Visibility if has_view else None
finally:
# Clean up - always close document if it exists
if doc_name in [d.Name for d in FreeCAD.listDocuments().values()]:
FreeCAD.closeDocument(doc_name)
_result_ = {{
"has_view_object": has_view,
"visibility": visible,
}}
"""
result: dict[str, Any] = xmlrpc_proxy.execute(code) # type: ignore[assignment]
assert result["success"], f"Execution failed: {result.get('stderr', '')}"
assert result["result"] is not None
assert result["result"]["has_view_object"], "Should have ViewObject in GUI mode"
assert result["result"]["visibility"] is True, (
"Object should be visible by default"
)
class TestBridgeQueueProcessor:
"""Tests for the bridge's queue processor mode detection.
The queue processor is responsible for executing Python code in FreeCAD.
Its mode (Qt timer vs background thread) must match FreeCAD's GUI state:
- FreeCAD.GuiUp = True Queue processor uses Qt timer (main thread)
- FreeCAD.GuiUp = False Queue processor uses background thread
A mismatch between these indicates a race condition bug where the bridge
started before FreeCAD.GuiUp was properly set, which was the root cause
of SIGABRT crashes in GUI mode.
"""
def test_queue_processor_mode_matches_gui_state(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Verify the queue processor mode matches FreeCAD's GUI state.
The bridge should use:
- Qt timer (main thread) when FreeCAD.GuiUp is True
- Background thread when FreeCAD.GuiUp is False
A mismatch indicates the bridge started before FreeCAD.GuiUp
was set, which is the root cause of the GUI crash bug.
"""
code = """
import threading
import FreeCAD
gui_up = FreeCAD.GuiUp
is_main_thread = threading.current_thread() is threading.main_thread()
thread_name = threading.current_thread().name
# Expected behavior:
# - GUI mode (GuiUp=True) -> Main thread (Qt timer)
# - Headless mode (GuiUp=False) -> Background thread
mode_correct = (gui_up and is_main_thread) or (not gui_up and not is_main_thread)
_result_ = {
"gui_up": gui_up,
"is_main_thread": is_main_thread,
"thread_name": thread_name,
"mode_correct": mode_correct,
}
"""
result: dict[str, Any] = xmlrpc_proxy.execute(code) # type: ignore[assignment]
assert result["success"], f"Execution failed: {result.get('stderr', '')}"
assert result["result"] is not None
info = result["result"]
assert info["mode_correct"], (
f"Queue processor mode mismatch!\n"
f" FreeCAD.GuiUp: {info['gui_up']}\n"
f" is_main_thread: {info['is_main_thread']}\n"
f" thread_name: {info['thread_name']}\n"
f"Expected: GUI mode uses main thread, headless uses background thread.\n"
f"This mismatch indicates Init.py started the bridge before "
f"FreeCAD.GuiUp was True."
)
+43
View File
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
class TestDocumentationSyntax: class TestDocumentationSyntax:
"""Syntax validation tests for documentation commands.""" """Syntax validation tests for documentation commands."""
# Basic documentation commands
DOC_COMMANDS: ClassVar[list[str]] = [ DOC_COMMANDS: ClassVar[list[str]] = [
"documentation::build", "documentation::build",
"documentation::build-strict", "documentation::build-strict",
@@ -23,6 +24,19 @@ class TestDocumentationSyntax:
"documentation::open", "documentation::open",
] ]
# Versioned documentation commands (mike-based)
VERSIONED_DOC_COMMANDS: ClassVar[list[str]] = [
"documentation::list-versions",
"documentation::serve-versioned",
]
# Commands that require arguments
DOC_COMMANDS_WITH_ARGS: ClassVar[list[tuple[str, str]]] = [
("documentation::deploy-version", "1.0.0"),
("documentation::deploy-latest", "1.0.0"),
("documentation::delete-version", "1.0.0"),
]
@pytest.mark.just_syntax @pytest.mark.just_syntax
@pytest.mark.parametrize("command", DOC_COMMANDS) @pytest.mark.parametrize("command", DOC_COMMANDS)
def test_documentation_command_syntax(self, just: JustRunner, command: str) -> None: def test_documentation_command_syntax(self, just: JustRunner, command: str) -> None:
@@ -30,6 +44,28 @@ class TestDocumentationSyntax:
result = just.dry_run(command) result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}" assert result.success, f"Syntax error in '{command}': {result.stderr}"
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", VERSIONED_DOC_COMMANDS)
def test_versioned_doc_command_syntax(self, just: JustRunner, command: str) -> None:
"""Versioned documentation command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
@pytest.mark.just_syntax
def test_deploy_dev_syntax(self, just: JustRunner) -> None:
"""Deploy-dev command should have valid syntax."""
result = just.dry_run("documentation::deploy-dev")
assert result.success, f"Syntax error in 'deploy-dev': {result.stderr}"
@pytest.mark.just_syntax
@pytest.mark.parametrize("command,arg", DOC_COMMANDS_WITH_ARGS)
def test_doc_command_with_args_syntax(
self, just: JustRunner, command: str, arg: str
) -> None:
"""Documentation commands with arguments should have valid syntax."""
result = just.dry_run(command, arg)
assert result.success, f"Syntax error in '{command} {arg}': {result.stderr}"
class TestDocumentationRuntime: class TestDocumentationRuntime:
"""Runtime tests for documentation commands.""" """Runtime tests for documentation commands."""
@@ -45,3 +81,10 @@ class TestDocumentationRuntime:
"""Documentation build-strict should run successfully.""" """Documentation build-strict should run successfully."""
result = just.run("documentation::build-strict", timeout=120) result = just.run("documentation::build-strict", timeout=120)
assert result.success, f"Documentation build-strict failed: {result.stderr}" assert result.success, f"Documentation build-strict failed: {result.stderr}"
# Note: list-versions, serve-versioned, deploy-*, and delete-version commands
# are not tested at runtime because they:
# 1. Require a gh-pages branch to exist (list-versions, serve-versioned)
# 2. Modify git state by creating/updating gh-pages branch (deploy-*)
# 3. Delete deployed versions (delete-version)
# These commands are validated via syntax tests only.
+1 -1
View File
@@ -41,7 +41,7 @@ class TestMCPSyntax:
class TestMCPRuntime: class TestMCPRuntime:
"""Runtime tests for MCP commands. """Runtime tests for MCP commands.
Note: Most MCP commands require FreeCAD MCP bridge to be running. Note: Most MCP commands require FreeCAD Robust MCP Bridge to be running.
The check command can run without FreeCAD and will fail gracefully. The check command can run without FreeCAD and will fail gracefully.
""" """
+5 -4
View File
@@ -170,7 +170,8 @@ class TestReleaseBumpCommands:
"""Backup files before bump tests and restore after.""" """Backup files before bump tests and restore after."""
# Files that bump commands modify # Files that bump commands modify
files_to_backup = [ files_to_backup = [
PROJECT_ROOT / "addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py", PROJECT_ROOT
/ "addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py",
PROJECT_ROOT / "package.xml", PROJECT_ROOT / "package.xml",
PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro", PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro",
PROJECT_ROOT PROJECT_ROOT
@@ -184,13 +185,13 @@ class TestReleaseBumpCommands:
backups: dict[Path, str] = {} backups: dict[Path, str] = {}
for file_path in files_to_backup: for file_path in files_to_backup:
if file_path.exists(): if file_path.exists():
backups[file_path] = file_path.read_text() backups[file_path] = file_path.read_text(encoding="utf-8")
yield yield
# Restore all files # Restore all files
for file_path, content in backups.items(): for file_path, content in backups.items():
file_path.write_text(content) file_path.write_text(content, encoding="utf-8")
@pytest.mark.just_runtime @pytest.mark.just_runtime
@pytest.mark.just_release @pytest.mark.just_release
@@ -204,7 +205,7 @@ class TestReleaseBumpCommands:
# Verify version was updated # Verify version was updated
init_file = ( init_file = (
PROJECT_ROOT / "addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py" PROJECT_ROOT / "addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py"
) )
content = init_file.read_text() content = init_file.read_text()
assert "99.99.99-test" in content assert "99.99.99-test" in content
+7
View File
@@ -28,6 +28,13 @@ class TestTestingSyntax:
"testing::all", "testing::all",
"testing::watch", "testing::watch",
"testing::integration-freecad-auto", "testing::integration-freecad-auto",
"testing::just-syntax",
"testing::just-runtime",
"testing::just-all",
"testing::just-release",
"testing::release-test",
"testing::integration-headless-release",
"testing::integration-gui-release",
"testing::kill-bridge", "testing::kill-bridge",
] ]
+1 -1
View File
@@ -1 +1 @@
"""Unit tests for FreeCAD MCP Server.""" """Unit tests for FreeCAD Robust MCP Suite."""
+40 -38
View File
@@ -10,54 +10,56 @@ from pathlib import Path
import pytest import pytest
# Get the addon directory path # Get the addon directory path
ADDON_DIR = Path(__file__).parent.parent.parent.parent / "addon" / "FreecadRobustMCP" ADDON_DIR = (
Path(__file__).parent.parent.parent.parent / "addon" / "FreecadRobustMCPBridge"
)
class TestAddonFileStructure: class TestAddonFileStructure:
"""Tests for addon file structure.""" """Tests for addon file structure."""
def test_addon_directory_exists(self): def test_addon_directory_exists(self) -> None:
"""The addon directory should exist.""" """The addon directory should exist."""
assert ADDON_DIR.exists(), f"Addon directory not found: {ADDON_DIR}" assert ADDON_DIR.exists(), f"Addon directory not found: {ADDON_DIR}"
assert ADDON_DIR.is_dir(), f"Addon path is not a directory: {ADDON_DIR}" assert ADDON_DIR.is_dir(), f"Addon path is not a directory: {ADDON_DIR}"
def test_init_py_exists(self): def test_init_py_exists(self) -> None:
"""Init.py should exist in the addon directory.""" """Init.py should exist in the addon directory."""
init_file = ADDON_DIR / "Init.py" init_file = ADDON_DIR / "Init.py"
assert init_file.exists(), f"Init.py not found: {init_file}" assert init_file.exists(), f"Init.py not found: {init_file}"
def test_initgui_py_exists(self): def test_initgui_py_exists(self) -> None:
"""InitGui.py should exist in the addon directory.""" """InitGui.py should exist in the addon directory."""
initgui_file = ADDON_DIR / "InitGui.py" initgui_file = ADDON_DIR / "InitGui.py"
assert initgui_file.exists(), f"InitGui.py not found: {initgui_file}" assert initgui_file.exists(), f"InitGui.py not found: {initgui_file}"
def test_icon_exists(self): def test_icon_exists(self) -> None:
"""The workbench icon should exist.""" """The workbench icon should exist."""
icon_file = ADDON_DIR / "FreecadRobustMCP.svg" icon_file = ADDON_DIR / "FreecadRobustMCPBridge.svg"
assert icon_file.exists(), f"Icon not found: {icon_file}" assert icon_file.exists(), f"Icon not found: {icon_file}"
def test_bridge_module_exists(self): def test_bridge_module_exists(self) -> None:
"""The bridge module directory should exist.""" """The bridge module directory should exist."""
bridge_dir = ADDON_DIR / "freecad_mcp_bridge" bridge_dir = ADDON_DIR / "freecad_mcp_bridge"
assert bridge_dir.exists(), f"Bridge module not found: {bridge_dir}" assert bridge_dir.exists(), f"Bridge module not found: {bridge_dir}"
assert bridge_dir.is_dir(), f"Bridge path is not a directory: {bridge_dir}" assert bridge_dir.is_dir(), f"Bridge path is not a directory: {bridge_dir}"
def test_bridge_init_exists(self): def test_bridge_init_exists(self) -> None:
"""The bridge module __init__.py should exist.""" """The bridge module __init__.py should exist."""
init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py" init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py"
assert init_file.exists(), f"Bridge __init__.py not found: {init_file}" assert init_file.exists(), f"Bridge __init__.py not found: {init_file}"
def test_bridge_server_exists(self): def test_bridge_server_exists(self) -> None:
"""The bridge server.py should exist.""" """The bridge server.py should exist."""
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py" server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
assert server_file.exists(), f"Bridge server.py not found: {server_file}" assert server_file.exists(), f"Bridge server.py not found: {server_file}"
def test_blocking_bridge_exists(self): def test_blocking_bridge_exists(self) -> None:
"""The blocking_bridge.py should exist for blocking server mode.""" """The blocking_bridge.py should exist for blocking server mode."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py" blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
assert blocking_file.exists(), f"blocking_bridge.py not found: {blocking_file}" assert blocking_file.exists(), f"blocking_bridge.py not found: {blocking_file}"
def test_bridge_utils_exists(self): def test_bridge_utils_exists(self) -> None:
"""The bridge_utils.py should exist for shared utilities.""" """The bridge_utils.py should exist for shared utilities."""
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py" utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
assert utils_file.exists(), f"bridge_utils.py not found: {utils_file}" assert utils_file.exists(), f"bridge_utils.py not found: {utils_file}"
@@ -66,39 +68,39 @@ class TestAddonFileStructure:
class TestAddonPythonSyntax: class TestAddonPythonSyntax:
"""Tests to verify Python files have valid syntax.""" """Tests to verify Python files have valid syntax."""
def test_init_py_valid_syntax(self): def test_init_py_valid_syntax(self) -> None:
"""Init.py should have valid Python syntax.""" """Init.py should have valid Python syntax."""
init_file = ADDON_DIR / "Init.py" init_file = ADDON_DIR / "Init.py"
code = init_file.read_text() code = init_file.read_text()
# This will raise SyntaxError if invalid # This will raise SyntaxError if invalid
ast.parse(code) ast.parse(code)
def test_initgui_py_valid_syntax(self): def test_initgui_py_valid_syntax(self) -> None:
"""InitGui.py should have valid Python syntax.""" """InitGui.py should have valid Python syntax."""
initgui_file = ADDON_DIR / "InitGui.py" initgui_file = ADDON_DIR / "InitGui.py"
code = initgui_file.read_text() code = initgui_file.read_text()
# This will raise SyntaxError if invalid # This will raise SyntaxError if invalid
ast.parse(code) ast.parse(code)
def test_bridge_init_valid_syntax(self): def test_bridge_init_valid_syntax(self) -> None:
"""Bridge __init__.py should have valid Python syntax.""" """Bridge __init__.py should have valid Python syntax."""
init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py" init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py"
code = init_file.read_text() code = init_file.read_text()
ast.parse(code) ast.parse(code)
def test_bridge_server_valid_syntax(self): def test_bridge_server_valid_syntax(self) -> None:
"""Bridge server.py should have valid Python syntax.""" """Bridge server.py should have valid Python syntax."""
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py" server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
code = server_file.read_text() code = server_file.read_text()
ast.parse(code) ast.parse(code)
def test_blocking_bridge_valid_syntax(self): def test_blocking_bridge_valid_syntax(self) -> None:
"""blocking_bridge.py should have valid Python syntax.""" """blocking_bridge.py should have valid Python syntax."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py" blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
code = blocking_file.read_text() code = blocking_file.read_text()
ast.parse(code) ast.parse(code)
def test_bridge_utils_valid_syntax(self): def test_bridge_utils_valid_syntax(self) -> None:
"""bridge_utils.py should have valid Python syntax.""" """bridge_utils.py should have valid Python syntax."""
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py" utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
code = utils_file.read_text() code = utils_file.read_text()
@@ -108,59 +110,59 @@ class TestAddonPythonSyntax:
class TestAddonMetadata: class TestAddonMetadata:
"""Tests for addon metadata and content.""" """Tests for addon metadata and content."""
def test_init_py_has_freecad_import(self): def test_init_py_has_freecad_import(self) -> None:
"""Init.py should import FreeCAD.""" """Init.py should import FreeCAD."""
init_file = ADDON_DIR / "Init.py" init_file = ADDON_DIR / "Init.py"
code = init_file.read_text() code = init_file.read_text()
assert "import FreeCAD" in code assert "import FreeCAD" in code
def test_initgui_py_has_workbench_class(self): def test_initgui_py_has_workbench_class(self) -> None:
"""InitGui.py should define the workbench class.""" """InitGui.py should define the workbench class."""
initgui_file = ADDON_DIR / "InitGui.py" initgui_file = ADDON_DIR / "InitGui.py"
code = initgui_file.read_text() code = initgui_file.read_text()
assert "FreecadRobustMCPWorkbench" in code assert "FreecadRobustMCPBridgeWorkbench" in code
assert "Gui.Workbench" in code or "Workbench" in code assert "Gui.Workbench" in code or "Workbench" in code
def test_initgui_py_has_commands(self): def test_initgui_py_has_commands(self) -> None:
"""InitGui.py should define start/stop commands.""" """InitGui.py should define start/stop commands."""
initgui_file = ADDON_DIR / "InitGui.py" initgui_file = ADDON_DIR / "InitGui.py"
code = initgui_file.read_text() code = initgui_file.read_text()
assert "StartMCPBridgeCommand" in code assert "StartMCPBridgeCommand" in code
assert "StopMCPBridgeCommand" in code assert "StopMCPBridgeCommand" in code
def test_initgui_py_registers_workbench(self): def test_initgui_py_registers_workbench(self) -> None:
"""InitGui.py should register the workbench.""" """InitGui.py should register the workbench."""
initgui_file = ADDON_DIR / "InitGui.py" initgui_file = ADDON_DIR / "InitGui.py"
code = initgui_file.read_text() code = initgui_file.read_text()
assert "Gui.addWorkbench" in code assert "Gui.addWorkbench" in code
def test_bridge_server_has_plugin_class(self): def test_bridge_server_has_plugin_class(self) -> None:
"""Bridge server.py should have FreecadMCPPlugin class.""" """Bridge server.py should have FreecadMCPPlugin class."""
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py" server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
code = server_file.read_text() code = server_file.read_text()
assert "class FreecadMCPPlugin" in code assert "class FreecadMCPPlugin" in code
def test_blocking_bridge_imports_plugin(self): def test_blocking_bridge_imports_plugin(self) -> None:
"""blocking_bridge.py should import FreecadMCPPlugin.""" """blocking_bridge.py should import FreecadMCPPlugin."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py" blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
code = blocking_file.read_text() code = blocking_file.read_text()
assert "FreecadMCPPlugin" in code assert "FreecadMCPPlugin" in code
def test_blocking_bridge_has_run_forever(self): def test_blocking_bridge_has_run_forever(self) -> None:
"""blocking_bridge.py should call run_forever for blocking execution.""" """blocking_bridge.py should call run_forever for blocking execution."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py" blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
code = blocking_file.read_text() code = blocking_file.read_text()
assert "run_forever" in code assert "run_forever" in code
def test_bridge_utils_has_get_running_plugin(self): def test_bridge_utils_has_get_running_plugin(self) -> None:
"""bridge_utils.py should have get_running_plugin function.""" """bridge_utils.py should have get_running_plugin function."""
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py" utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
code = utils_file.read_text() code = utils_file.read_text()
assert "def get_running_plugin" in code assert "def get_running_plugin" in code
def test_icon_is_valid_svg(self): def test_icon_is_valid_svg(self) -> None:
"""The icon should be a valid SVG file.""" """The icon should be a valid SVG file."""
icon_file = ADDON_DIR / "FreecadRobustMCP.svg" icon_file = ADDON_DIR / "FreecadRobustMCPBridge.svg"
content = icon_file.read_text() content = icon_file.read_text()
assert content.startswith("<?xml") or content.startswith("<svg") assert content.startswith("<?xml") or content.startswith("<svg")
assert "<svg" in content assert "<svg" in content
@@ -170,9 +172,9 @@ class TestAddonMetadata:
class TestAddonIconSize: class TestAddonIconSize:
"""Tests for addon icon size requirements.""" """Tests for addon icon size requirements."""
def test_icon_size_under_10kb(self): def test_icon_size_under_10kb(self) -> None:
"""The icon file should be under 10KB (FreeCAD requirement).""" """The icon file should be under 10KB (FreeCAD requirement)."""
icon_file = ADDON_DIR / "FreecadRobustMCP.svg" icon_file = ADDON_DIR / "FreecadRobustMCPBridge.svg"
size_bytes = icon_file.stat().st_size size_bytes = icon_file.stat().st_size
size_kb = size_bytes / 1024 size_kb = size_bytes / 1024
assert size_kb <= 10, f"Icon is {size_kb:.2f}KB, must be <= 10KB" assert size_kb <= 10, f"Icon is {size_kb:.2f}KB, must be <= 10KB"
@@ -182,23 +184,23 @@ class TestPackageXml:
"""Tests for package.xml workbench entry.""" """Tests for package.xml workbench entry."""
@pytest.fixture @pytest.fixture
def package_xml(self): def package_xml(self) -> str:
"""Load package.xml content.""" """Load package.xml content."""
package_file = ADDON_DIR.parent.parent / "package.xml" package_file = ADDON_DIR.parent.parent / "package.xml"
return package_file.read_text() return package_file.read_text()
def test_workbench_entry_exists(self, package_xml): def test_workbench_entry_exists(self, package_xml: str) -> None:
"""package.xml should have a workbench entry.""" """package.xml should have a workbench entry."""
assert "<workbench>" in package_xml assert "<workbench>" in package_xml
def test_workbench_classname(self, package_xml): def test_workbench_classname(self, package_xml: str) -> None:
"""package.xml should reference the correct workbench classname.""" """package.xml should reference the correct workbench classname."""
assert "<classname>FreecadRobustMCPWorkbench</classname>" in package_xml assert "<classname>FreecadRobustMCPBridgeWorkbench</classname>" in package_xml
def test_workbench_subdirectory(self, package_xml): def test_workbench_subdirectory(self, package_xml: str) -> None:
"""package.xml should reference the correct subdirectory.""" """package.xml should reference the correct subdirectory."""
assert "./addon/FreecadRobustMCP/" in package_xml assert "./addon/FreecadRobustMCPBridge/" in package_xml
def test_workbench_icon(self, package_xml): def test_workbench_icon(self, package_xml: str) -> None:
"""package.xml should reference the workbench icon.""" """package.xml should reference the workbench icon."""
assert "<icon>FreecadRobustMCP.svg</icon>" in package_xml assert "<icon>FreecadRobustMCPBridge.svg</icon>" in package_xml
+42 -2
View File
@@ -1,4 +1,4 @@
"""Tests for MCP resources module.""" """Tests for FreeCAD Robust MCP resources."""
import json import json
from collections.abc import Callable from collections.abc import Callable
@@ -17,7 +17,7 @@ from freecad_mcp.bridge.base import (
class TestFreecadResources: class TestFreecadResources:
"""Tests for FreeCAD MCP resources.""" """Tests for FreeCAD Robust MCP resources."""
@pytest.fixture @pytest.fixture
def mock_mcp(self) -> MagicMock: def mock_mcp(self) -> MagicMock:
@@ -437,3 +437,43 @@ class TestFreecadResources:
# Should have prompts section # Should have prompts section
assert "prompts" in data assert "prompts" in data
@pytest.mark.asyncio
async def test_resource_capabilities_includes_all_resources(
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
) -> None:
"""freecad://capabilities should include all registered resources.
This test ensures the capabilities resource stays in sync when new
resources are added. Per CLAUDE.md: "When adding new MCP tools or
resources, you MUST also update the freecad://capabilities resource."
"""
resource_capabilities = register_resources["freecad://capabilities"]
result = await resource_capabilities()
data = json.loads(result)
# Get all registered resource URIs (excluding capabilities itself)
registered_uris = {
uri for uri in register_resources if uri != "freecad://capabilities"
}
# Get URIs listed in the capabilities response (filter out None values)
capability_uris = {
r.get("uri") for r in data.get("resources", []) if r.get("uri") is not None
}
# All registered resources should be listed in capabilities
missing_resources = registered_uris - capability_uris
assert not missing_resources, (
f"Resources registered but not in capabilities: {missing_resources}. "
f"Update resource_capabilities() in src/freecad_mcp/resources/freecad.py"
)
# Reverse check: capabilities should not list stale/phantom resources
# that are no longer registered (excluding capabilities itself)
stale_resources = capability_uris - registered_uris - {"freecad://capabilities"}
assert not stale_resources, (
f"Stale resources in capabilities (not registered): {stale_resources}. "
f"Remove these from resource_capabilities() in "
f"src/freecad_mcp/resources/freecad.py"
)
+209 -1
View File
@@ -246,9 +246,17 @@ class TestMain:
server_module.main() server_module.main()
# Check that instance ID was printed # Check that instance ID was printed to stderr (not stdout, to avoid
# corrupting JSON-RPC in stdio mode)
print_calls = [str(call) for call in mock_print.call_args_list] print_calls = [str(call) for call in mock_print.call_args_list]
assert any("FREECAD_MCP_INSTANCE_ID=" in call for call in print_calls) assert any("FREECAD_MCP_INSTANCE_ID=" in call for call in print_calls)
# Verify it was printed to stderr
instance_id_call = next(
call
for call in mock_print.call_args_list
if "FREECAD_MCP_INSTANCE_ID=" in str(call)
)
assert instance_id_call.kwargs.get("file") == sys.stderr
def test_main_http_transport(self): def test_main_http_transport(self):
"""Main should start HTTP transport when configured.""" """Main should start HTTP transport when configured."""
@@ -295,3 +303,203 @@ class TestMain:
# Should call run without transport arguments (stdio is default) # Should call run without transport arguments (stdio is default)
mock_run.assert_called_once_with() mock_run.assert_called_once_with()
class TestStdioProtocolCleanliness:
"""Tests to ensure stdio mode produces clean JSON-RPC output.
These tests verify that stdout contains ONLY valid JSON-RPC messages,
with no debug output, print statements, or other text that would corrupt
the MCP protocol. This is critical for compatibility with MCP clients
like Claude Desktop.
The bug this catches: Any print() to stdout (instead of stderr) will
cause MCP clients to fail with JSON parse errors like:
"Unexpected token 'F', "FREECAD_MC"... is not valid JSON"
"""
def test_no_stdout_before_jsonrpc(self):
"""Verify no stray output appears on stdout before JSON-RPC messages.
This test spawns the MCP server as a subprocess and validates that
ALL stdout output is valid JSON-RPC. Any non-JSON output on stdout
will corrupt the MCP protocol.
"""
import json
import os
import subprocess
import time
# Start the MCP server process
# Use a non-existent FreeCAD host so it won't actually connect
proc = subprocess.Popen( # noqa: S603
[
sys.executable,
"-m",
"freecad_mcp.server",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
**dict(os.environ),
"FREECAD_MODE": "xmlrpc",
"FREECAD_XMLRPC_PORT": "59999", # Non-existent port
"FREECAD_SOCKET_HOST": "localhost",
},
)
try:
# Ensure pipes are available
assert proc.stdin is not None
assert proc.stdout is not None
# Send a minimal MCP initialize request
init_request = {
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0.0"},
},
}
request_bytes = json.dumps(init_request).encode() + b"\n"
proc.stdin.write(request_bytes)
proc.stdin.flush()
# Give the server a moment to respond
time.sleep(0.5)
# Set stdout to non-blocking mode
os.set_blocking(proc.stdout.fileno(), False)
# Read any available stdout
stdout_data = b""
try:
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
stdout_data += chunk
except (BlockingIOError, TypeError):
pass # No more data available
# Validate that ALL stdout is valid JSON-RPC
# Each line should be a valid JSON object
stdout_text = stdout_data.decode("utf-8", errors="replace")
lines = [line.strip() for line in stdout_text.split("\n") if line.strip()]
for line in lines:
try:
parsed = json.loads(line)
# Should be a JSON-RPC message (has jsonrpc field)
assert "jsonrpc" in parsed, (
f"stdout contains JSON but not JSON-RPC: {line[:100]}"
)
except json.JSONDecodeError as e:
pytest.fail(
f"stdout contains non-JSON output which corrupts MCP protocol!\n"
f"Invalid line: {line[:200]!r}\n"
f"JSON error: {e}\n\n"
f"All stdout lines:\n{stdout_text[:1000]}"
)
finally:
# Clean up the process
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
def test_instance_id_on_stderr_not_stdout(self) -> None:
"""Verify FREECAD_MCP_INSTANCE_ID is printed to stderr, not stdout.
The instance ID must go to stderr because stdout is reserved for
JSON-RPC messages in stdio mode.
"""
import os
import subprocess
import time
proc = subprocess.Popen( # noqa: S603
[
sys.executable,
"-m",
"freecad_mcp.server",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
**dict(os.environ),
"FREECAD_MODE": "xmlrpc",
"FREECAD_XMLRPC_PORT": "59999",
"FREECAD_SOCKET_HOST": "localhost",
},
)
try:
# Ensure pipes are available
assert proc.stdout is not None
assert proc.stderr is not None
# Set pipes to non-blocking mode
os.set_blocking(proc.stdout.fileno(), False)
os.set_blocking(proc.stderr.fileno(), False)
# Poll for stderr content with timeout (CI systems can be slower)
stderr_data = b""
max_wait = 5.0 # 5 second timeout
poll_interval = 0.1
elapsed = 0.0
while elapsed < max_wait:
try:
chunk = proc.stderr.read(4096)
if chunk:
stderr_data += chunk
# Check if we got the instance ID
if b"FREECAD_MCP_INSTANCE_ID=" in stderr_data:
break
except (BlockingIOError, TypeError):
pass # No data available yet
time.sleep(poll_interval)
elapsed += poll_interval
stderr_text = stderr_data.decode("utf-8", errors="replace")
# Instance ID should be in stderr
assert "FREECAD_MCP_INSTANCE_ID=" in stderr_text, (
f"Instance ID not found in stderr after {max_wait}s.\n"
f"stderr: {stderr_text[:500]}"
)
# Read stdout (should NOT contain instance ID)
stdout_data = b""
try:
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
stdout_data += chunk
except (BlockingIOError, TypeError):
pass # No more data available
stdout_text = stdout_data.decode("utf-8", errors="replace")
# Instance ID should NOT be in stdout
assert "FREECAD_MCP_INSTANCE_ID=" not in stdout_text, (
f"Instance ID incorrectly appears in stdout, corrupting MCP protocol!\n"
f"stdout: {stdout_text[:500]}"
)
finally:
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
Generated
+48
View File
@@ -594,6 +594,7 @@ dev = [
{ name = "commitizen" }, { name = "commitizen" },
{ name = "detect-secrets" }, { name = "detect-secrets" },
{ name = "md-toc" }, { name = "md-toc" },
{ name = "mike" },
{ name = "mkdocs" }, { name = "mkdocs" },
{ name = "mkdocs-git-revision-date-localized-plugin" }, { name = "mkdocs-git-revision-date-localized-plugin" },
{ name = "mkdocs-glightbox" }, { name = "mkdocs-glightbox" },
@@ -623,6 +624,7 @@ requires-dist = [
{ name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.5.0" }, { name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.5.0" },
{ name = "mcp", specifier = ">=1.25.0" }, { name = "mcp", specifier = ">=1.25.0" },
{ name = "md-toc", marker = "extra == 'dev'", specifier = ">=9.0.0" }, { name = "md-toc", marker = "extra == 'dev'", specifier = ">=9.0.0" },
{ name = "mike", marker = "extra == 'dev'", specifier = ">=2.1.0" },
{ name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.0" }, { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.0" },
{ name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'dev'", specifier = ">=1.2.0" }, { name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'dev'", specifier = ">=1.2.0" },
{ name = "mkdocs-glightbox", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "mkdocs-glightbox", marker = "extra == 'dev'", specifier = ">=0.4.0" },
@@ -799,6 +801,15 @@ wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
] ]
[[package]]
name = "importlib-resources"
version = "6.5.2"
source = { registry = "https://pkgs.safetycli.com/repository/techlabssh/pypi/simple/" }
sdist = { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" }
wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@@ -1147,6 +1158,25 @@ wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
] ]
[[package]]
name = "mike"
version = "2.1.3"
source = { registry = "https://pkgs.safetycli.com/repository/techlabssh/pypi/simple/" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "importlib-resources" },
{ name = "jinja2" },
{ name = "mkdocs" },
{ name = "pyparsing" },
{ name = "pyyaml" },
{ name = "pyyaml-env-tag" },
{ name = "verspec" },
]
sdist = { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/ab/f7/2933f1a1fb0e0f077d5d6a92c6c7f8a54e6128241f116dff4df8b6050bbf/mike-2.1.3.tar.gz", hash = "sha256:abd79b8ea483fb0275b7972825d3082e5ae67a41820f8d8a0dc7a3f49944e810", size = 38119, upload-time = "2024-08-13T05:02:14.167Z" }
wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/fd/1a/31b7cd6e4e7a02df4e076162e9783620777592bea9e4bb036389389af99d/mike-2.1.3-py3-none-any.whl", hash = "sha256:d90c64077e84f06272437b464735130d380703a76a5738b152932884c60c062a", size = 33754, upload-time = "2024-08-13T05:02:12.515Z" },
]
[[package]] [[package]]
name = "mkdocs" name = "mkdocs"
version = "1.6.1" version = "1.6.1"
@@ -1699,6 +1729,15 @@ wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" }, { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" },
] ]
[[package]]
name = "pyparsing"
version = "3.3.1"
source = { registry = "https://pkgs.safetycli.com/repository/techlabssh/pypi/simple/" }
sdist = { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" }
wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" },
]
[[package]] [[package]]
name = "pyproject-hooks" name = "pyproject-hooks"
version = "1.2.0" version = "1.2.0"
@@ -2583,6 +2622,15 @@ wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
] ]
[[package]]
name = "verspec"
version = "0.1.0"
source = { registry = "https://pkgs.safetycli.com/repository/techlabssh/pypi/simple/" }
sdist = { url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" }
wheels = [
{ url = "https://pkgs.safetycli.com/package/techlabssh/pypi/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" },
]
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.36.0" version = "20.36.0"