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
@@ -64,7 +64,7 @@ reviews:
|
||||
- Proper docstrings for tool discovery
|
||||
- Consistent error handling patterns
|
||||
- GUI-safe checks (FreeCAD.GuiUp) for view operations
|
||||
- path: "addon/FreecadRobustMCP/**/*.py"
|
||||
- path: "addon/FreecadRobustMCPBridge/**/*.py"
|
||||
instructions: >
|
||||
This code runs inside FreeCAD's Python environment as a workbench addon.
|
||||
It cannot import packages from the project's virtualenv (mcp, pydantic).
|
||||
@@ -76,7 +76,7 @@ reviews:
|
||||
- path: "tests/**/*.py"
|
||||
instructions: >
|
||||
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"
|
||||
instructions: >
|
||||
GitHub Actions workflows. Check for:
|
||||
|
||||
@@ -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
|
||||
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
|
||||
PKG_VERSION=$(awk -v name="$MACRO_NAME" '
|
||||
/<macro>/ { in_macro=1 }
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
- "src/freecad_mcp/**/*.py"
|
||||
- "macros/**/*.FCMacro"
|
||||
- "macros/**/*.py"
|
||||
- "addon/FreecadRobustMCP/**/*.py"
|
||||
- "addon/FreecadRobustMCPBridge/**/*.py"
|
||||
- "tests/integration/**/*.py"
|
||||
- ".github/workflows/macro-test.yaml"
|
||||
- ".github/actions/setup-freecad/**"
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- "src/freecad_mcp/**/*.py"
|
||||
- "macros/**/*.FCMacro"
|
||||
- "macros/**/*.py"
|
||||
- "addon/FreecadRobustMCP/**/*.py"
|
||||
- "addon/FreecadRobustMCPBridge/**/*.py"
|
||||
- "tests/integration/**/*.py"
|
||||
- ".github/workflows/macro-test.yaml"
|
||||
- ".github/actions/setup-freecad/**"
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
# Start FreeCAD headless with MCP bridge in background
|
||||
# Uses the workbench addon's blocking bridge script
|
||||
# Use setsid to create a new process group for reliable cleanup
|
||||
setsid freecadcmd addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 &
|
||||
setsid freecadcmd addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 &
|
||||
FREECAD_PID=$!
|
||||
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
|
||||
# GUI features are available since we're using 'freecad' not 'freecadcmd'
|
||||
# Use setsid to create a new process group for reliable cleanup
|
||||
setsid freecad addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_gui.log 2>&1 &
|
||||
setsid freecad addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_gui.log 2>&1 &
|
||||
FREECAD_PID=$!
|
||||
echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV"
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
echo "Verifying version in source files matches tag: $VERSION"
|
||||
|
||||
# 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)
|
||||
if [ "$INIT_VERSION" != "$VERSION" ]; then
|
||||
echo "ERROR: Version mismatch in $INIT_FILE"
|
||||
@@ -68,6 +68,21 @@ jobs:
|
||||
fi
|
||||
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
|
||||
PKG_VERSION=$(awk '/<workbench>/,/<\/workbench>/' package.xml | grep -o '<version>[^<]*</version>' | head -1 | sed 's/<[^>]*>//g')
|
||||
if [ "$PKG_VERSION" != "$VERSION" ]; then
|
||||
@@ -89,7 +104,7 @@ jobs:
|
||||
mkdir -p "staging/freecad-mcp-workbench-${VERSION}"
|
||||
|
||||
# Copy workbench files
|
||||
cp -r addon/FreecadRobustMCP/* "staging/freecad-mcp-workbench-${VERSION}/"
|
||||
cp -r addon/FreecadRobustMCPBridge/* "staging/freecad-mcp-workbench-${VERSION}/"
|
||||
|
||||
# Copy LICENSE
|
||||
cp LICENSE "staging/freecad-mcp-workbench-${VERSION}/"
|
||||
@@ -114,9 +129,9 @@ jobs:
|
||||
|
||||
Copy the contents of this archive to your FreeCAD Mod directory:
|
||||
|
||||
- **macOS**: \`~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCP/\`
|
||||
- **Linux**: \`~/.local/share/FreeCAD/Mod/FreecadRobustMCP/\`
|
||||
- **Windows**: \`%APPDATA%/FreeCAD/Mod/FreecadRobustMCP/\`
|
||||
- **macOS**: \`~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCPBridge/\`
|
||||
- **Linux**: \`~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/\`
|
||||
- **Windows**: \`%APPDATA%/FreeCAD/Mod/FreecadRobustMCPBridge/\`
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# This configuration extends the default rules with project-specific settings.
|
||||
|
||||
title = "FreeCAD MCP Gitleaks Configuration"
|
||||
title = "FreeCAD Robust MCP Gitleaks Configuration"
|
||||
|
||||
[extend]
|
||||
# Extend the default gitleaks configuration
|
||||
|
||||
@@ -127,5 +127,5 @@
|
||||
}
|
||||
],
|
||||
"results": {},
|
||||
"generated_at": "2026-01-10T20:47:49Z"
|
||||
"generated_at": "2026-01-12T16:32:14Z"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ This is a multi-component project. Each component has its own versioning and rel
|
||||
|
||||
### 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
|
||||
|
||||
### Multi Export Macro
|
||||
|
||||
@@ -204,6 +204,8 @@ just testing::all # Run all tests including integration
|
||||
# Documentation commands
|
||||
just documentation::build # Build documentation
|
||||
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
|
||||
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` |
|
||||
| `testing` | Test execution | `unit`, `cov`, `integration-freecad-auto`, `watch` |
|
||||
| `docker` | Docker build and run commands | `build`, `build-multi`, `run`, `clean-all` |
|
||||
| `documentation` | Documentation building | `build`, `serve`, `open` |
|
||||
| `documentation` | Documentation building and deployment | `build`, `serve`, `serve-versioned`, `list-versions`|
|
||||
| `dev` | Development utilities | `install-deps`, `update-deps`, `clean` |
|
||||
| `release` | Release and tagging | `status`, `tag-mcp-server`, `delete-tag` |
|
||||
| `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`:
|
||||
|
||||
```yaml
|
||||
project_name: FreeCAD MCP Server
|
||||
project_name: FreeCAD Robust MCP Suite
|
||||
xmlrpc_port: 9875
|
||||
socket_port: 9876
|
||||
```
|
||||
|
||||
**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
|
||||
@@ -717,7 +747,7 @@ project-root/
|
||||
│ │ └── test.yaml # Unit/integration tests
|
||||
│ └── dependabot.yaml # Dependency updates
|
||||
├── addon/ # FreeCAD addon (workbench)
|
||||
│ └── FreecadRobustMCP/ # Robust MCP Bridge workbench
|
||||
│ └── FreecadRobustMCPBridge/ # Robust MCP Bridge workbench
|
||||
│ ├── freecad_mcp_bridge/ # Bridge Python package
|
||||
│ ├── Init.py # FreeCAD workbench init
|
||||
│ ├── InitGui.py # FreeCAD GUI init
|
||||
@@ -990,6 +1020,60 @@ else:
|
||||
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
|
||||
|
||||
**WRONG** - Do not use Qt availability to detect GUI mode:
|
||||
@@ -1236,13 +1320,14 @@ This project uses component-specific release workflows along with CI/CD pipeline
|
||||
|
||||
### CI Workflows
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
| ------------------ | -------------------- | ---------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `docker.yaml` | Push, PR | Builds Docker image to verify Dockerfile works |
|
||||
| `macro-test.yaml` | Push, PR | Tests FreeCAD macros in headless Docker environment |
|
||||
| `codeql.yaml` | Push, PR, scheduled | GitHub CodeQL security analysis |
|
||||
| Workflow | Trigger | Purpose |
|
||||
| ------------------ | ------------------------------ | --------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `docker.yaml` | Push, PR | Builds Docker image to verify Dockerfile works |
|
||||
| `macro-test.yaml` | Push, PR | Tests FreeCAD macros in headless Docker environment |
|
||||
| `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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# FreeCAD Tools and Robust MCP Server
|
||||
# FreeCAD Robust MCP Suite
|
||||
|
||||
[](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml)
|
||||
[](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml)
|
||||
@@ -9,6 +9,7 @@
|
||||
[](https://pypi.org/project/freecad-robust-mcp/)
|
||||
|
||||
[](https://hub.docker.com/r/spkane/freecad-robust-mcp)
|
||||
[](https://spkane.github.io/freecad-robust-mcp-and-more/)
|
||||
[](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.
|
||||
@@ -19,7 +20,7 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that
|
||||
|
||||
<!--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)
|
||||
- [Features](#features)
|
||||
- [Requirements](#requirements)
|
||||
@@ -106,6 +107,7 @@ This section covers installation and usage for end users who want to use the Rob
|
||||
|
||||
| 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 |
|
||||
| [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 |
|
||||
@@ -840,7 +842,7 @@ See the [detailed architecture document](docs/development/architecture-detailed.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 13 KiB |
|
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from freecad_mcp_bridge.bridge_utils import GuiWaiter
|
||||
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init loaded\n")
|
||||
|
||||
# Global reference to timer to prevent garbage collection
|
||||
_auto_start_timer = None
|
||||
# Global reference to GuiWaiter and auto-start timer to prevent garbage collection
|
||||
# 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:
|
||||
@@ -43,26 +52,24 @@ def _auto_start_bridge() -> None:
|
||||
)
|
||||
|
||||
# Import and start the bridge directly
|
||||
import commands
|
||||
from freecad_mcp_bridge.server import FreecadMCPPlugin
|
||||
from preferences import get_socket_port, get_xmlrpc_port
|
||||
|
||||
xmlrpc_port = get_xmlrpc_port()
|
||||
socket_port = get_socket_port()
|
||||
|
||||
commands._mcp_plugin = FreecadMCPPlugin(
|
||||
plugin = FreecadMCPPlugin(
|
||||
host="localhost",
|
||||
port=socket_port,
|
||||
xmlrpc_port=xmlrpc_port,
|
||||
enable_xmlrpc=True,
|
||||
)
|
||||
commands._mcp_plugin.start()
|
||||
plugin.start()
|
||||
|
||||
# Track running configuration for restart detection
|
||||
commands._running_config = {
|
||||
"xmlrpc_port": xmlrpc_port,
|
||||
"socket_port": socket_port,
|
||||
}
|
||||
# Register plugin with commands module for restart detection
|
||||
from freecad_mcp_bridge.bridge_utils import register_mcp_plugin
|
||||
|
||||
register_mcp_plugin(plugin, xmlrpc_port, socket_port)
|
||||
|
||||
FreeCAD.Console.PrintMessage("\n")
|
||||
FreeCAD.Console.PrintMessage("=" * 50 + "\n")
|
||||
@@ -81,9 +88,15 @@ def _auto_start_bridge() -> None:
|
||||
# Schedule auto-start after FreeCAD finishes loading
|
||||
# Strategy:
|
||||
# - If FreeCAD.GuiUp is True: Qt event loop is running, use timer for deferred start
|
||||
# - If FreeCAD.GuiUp is False but Qt is available: Start bridge directly
|
||||
# (GUI mode starting up - InitGui.py will handle status bar later)
|
||||
# - If 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:
|
||||
from preferences import get_auto_start
|
||||
|
||||
@@ -110,8 +123,18 @@ try:
|
||||
_auto_start_bridge()
|
||||
elif QtCore is not None:
|
||||
# GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode)
|
||||
# Start bridge directly - InitGui.py will handle status bar update
|
||||
_auto_start_bridge()
|
||||
# Use GuiWaiter to wait for GuiUp to become True before starting
|
||||
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:
|
||||
# True headless mode - no Qt, no GUI
|
||||
_auto_start_bridge()
|
||||
@@ -36,11 +36,21 @@ except Exception as e:
|
||||
)
|
||||
|
||||
|
||||
class FreecadRobustMCPWorkbench(FreeCADGui.Workbench):
|
||||
"""FreeCAD Robust MCP Workbench.
|
||||
class FreecadRobustMCPBridgeWorkbench(FreeCADGui.Workbench):
|
||||
"""Robust MCP Bridge workbench for FreeCAD.
|
||||
|
||||
Provides toolbar and menu commands to start, stop, and monitor
|
||||
the MCP bridge server for AI assistant integration.
|
||||
Provides toolbar and menu commands to start, stop, and monitor the MCP
|
||||
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"
|
||||
@@ -136,7 +146,7 @@ class FreecadRobustMCPWorkbench(FreeCADGui.Workbench):
|
||||
|
||||
|
||||
# Register the workbench
|
||||
FreeCADGui.addWorkbench(FreecadRobustMCPWorkbench())
|
||||
FreeCADGui.addWorkbench(FreecadRobustMCPBridgeWorkbench())
|
||||
|
||||
# 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)
|
||||
@@ -133,7 +133,7 @@ class StartMCPBridgeCommand:
|
||||
_running_config = None
|
||||
FreeCAD.Console.PrintError(f"Failed to import MCP Bridge module: {e}\n")
|
||||
FreeCAD.Console.PrintError(
|
||||
"Ensure the FreecadRobustMCP addon is properly installed.\n"
|
||||
"Ensure the FreecadRobustMCPBridge addon is properly installed.\n"
|
||||
)
|
||||
try:
|
||||
from preferences import get_status_bar_enabled
|
||||
@@ -333,7 +333,7 @@ class MCPBridgePreferencesCommand:
|
||||
def GetResources(self) -> dict[str, str]:
|
||||
"""Return the command resources (icon, menu text, tooltip)."""
|
||||
return {
|
||||
"Pixmap": get_icon_path("icons/mcp_preferences.svg"),
|
||||
"Pixmap": get_icon_path("icons/preferences-robust_mcp_bridge.svg"),
|
||||
"MenuText": "MCP Bridge Preferences...",
|
||||
"ToolTip": "Configure MCP Bridge settings (ports, auto-start, etc.)",
|
||||
}
|
||||
@@ -344,6 +344,11 @@ class MCPBridgePreferencesCommand:
|
||||
|
||||
def Activated(self) -> None:
|
||||
"""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 FreeCADGui
|
||||
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
|
||||
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
|
||||
|
||||
__version__ = "0.0.0" # Updated by release workflow
|
||||
__version__ = "0.6.0" # Updated by release workflow
|
||||
__all__ = ["FreecadMCPPlugin", "__version__"]
|
||||
@@ -1,28 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Blocking FreeCAD MCP Bridge Server.
|
||||
r"""Blocking FreeCAD Robust MCP Bridge Server.
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
|
||||
|
||||
This script starts the MCP bridge server and blocks with run_forever().
|
||||
It works with both FreeCAD GUI and FreeCADCmd (headless) modes.
|
||||
It works with both freecad (GUI) and freecadcmd (headless) modes.
|
||||
|
||||
Use this script when you need FreeCAD to keep running (CI, background servers).
|
||||
For interactive GUI sessions, use startup_bridge.py instead (non-blocking).
|
||||
|
||||
Usage:
|
||||
# Headless mode (no GUI features):
|
||||
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
|
||||
freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/\
|
||||
freecad_mcp_bridge/blocking_bridge.py
|
||||
|
||||
# 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:
|
||||
/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.
|
||||
For full functionality, run with FreeCAD GUI executable.
|
||||
Note: In headless mode (freecadcmd), GUI features like screenshots are not available.
|
||||
For full functionality, run with the freecad GUI executable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,23 +40,23 @@ try:
|
||||
|
||||
print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}")
|
||||
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("Usage:")
|
||||
print(
|
||||
" FreeCADCmd /path/to/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py"
|
||||
" freecadcmd /path/to/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py"
|
||||
)
|
||||
print("")
|
||||
print("On macOS (if workbench installed):")
|
||||
print(" /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd \\")
|
||||
print(
|
||||
" ~/Library/Application\\ Support/FreeCAD/Mod/FreecadRobustMCP/"
|
||||
" ~/Library/Application\\ Support/FreeCAD/Mod/FreecadRobustMCPBridge/"
|
||||
"freecad_mcp_bridge/blocking_bridge.py"
|
||||
)
|
||||
print("")
|
||||
print("On Linux (if workbench installed):")
|
||||
print(
|
||||
" freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/"
|
||||
" freecadcmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/"
|
||||
"freecad_mcp_bridge/blocking_bridge.py"
|
||||
)
|
||||
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
|
||||
MCP bridge requests. It must be executed within FreeCAD's Python environment.
|
||||
@@ -236,11 +236,11 @@ class FreecadMCPPlugin:
|
||||
|
||||
self._running = True
|
||||
|
||||
# Print instance ID to stdout for test automation to capture
|
||||
# This is printed before logging to ensure it's easily parseable
|
||||
# Print instance ID to stderr for test automation to capture.
|
||||
# Stdout may be reserved for JSON-RPC when running in stdio mode.
|
||||
print(
|
||||
f"FREECAD_MCP_BRIDGE_INSTANCE_ID={self._instance_id}",
|
||||
file=sys.stdout,
|
||||
file=sys.stderr,
|
||||
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
|
||||
|
||||
# Addon directory name - single source of truth for renames
|
||||
_ADDON_DIRNAME = "FreecadRobustMCPBridge"
|
||||
|
||||
# Cache for addon path to avoid repeated filesystem lookups
|
||||
_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
|
||||
try:
|
||||
mod_path = os.path.join( # noqa: PTH118
|
||||
FreeCAD.getUserAppDataDir(), "Mod", "FreecadRobustMCP"
|
||||
FreeCAD.getUserAppDataDir(), "Mod", _ADDON_DIRNAME
|
||||
)
|
||||
if os.path.exists(mod_path): # noqa: PTH110
|
||||
_addon_path_cache = mod_path
|
||||
@@ -62,7 +65,7 @@ def get_addon_path() -> str:
|
||||
for item in os.listdir(base_path): # noqa: PTH208
|
||||
if item.startswith("v1-"):
|
||||
versioned_mod = os.path.join( # noqa: PTH118
|
||||
base_path, item, "Mod", "FreecadRobustMCP"
|
||||
base_path, item, "Mod", _ADDON_DIRNAME
|
||||
)
|
||||
if os.path.exists(versioned_mod): # noqa: PTH110
|
||||
_addon_path_cache = versioned_mod
|
||||
@@ -103,14 +106,14 @@ def get_icons_dir() -> 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:
|
||||
The absolute path to the workbench icon, or empty string if not found.
|
||||
"""
|
||||
addon_path = get_addon_path()
|
||||
if addon_path:
|
||||
icon_path = os.path.join(addon_path, "FreecadRobustMCP.svg") # noqa: PTH118
|
||||
icon_path = os.path.join(addon_path, f"{_ADDON_DIRNAME}.svg") # noqa: PTH118
|
||||
if os.path.exists(icon_path): # noqa: PTH110
|
||||
return icon_path
|
||||
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,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# FreeCAD MCP Server Architecture
|
||||
# FreeCAD Robust MCP Server Architecture
|
||||
|
||||
## Executive Summary
|
||||
|
||||
@@ -10,11 +10,11 @@ This document describes the architecture for a Model Context Protocol (MCP) serv
|
||||
|
||||
<!--TOC-->
|
||||
|
||||
- [FreeCAD MCP Server Architecture](#freecad-mcp-server-architecture)
|
||||
- [FreeCAD Robust MCP Server Architecture](#freecad-robust-mcp-server-architecture)
|
||||
- [Executive Summary](#executive-summary)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [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)
|
||||
- [Key Learnings Applied](#key-learnings-applied)
|
||||
- [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.
|
||||
|
||||
### Existing FreeCAD MCP Servers
|
||||
### Existing FreeCAD Robust MCP Servers
|
||||
|
||||
| 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 │ │
|
||||
│ │ - execute_py │ │ - documents │ │ - modeling │ │
|
||||
@@ -293,7 +293,7 @@ freecad_mcp/
|
||||
The main entry point using FastMCP from the official MCP Python SDK.
|
||||
|
||||
```python
|
||||
"""FreeCAD MCP Server - Main entry point."""
|
||||
"""FreeCAD Robust MCP Server - Main entry point."""
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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).
|
||||
|
||||
@@ -8,7 +8,7 @@ For the full architecture document with design decisions and rationale, see [Det
|
||||
|
||||
## Overview
|
||||
|
||||
The FreeCAD MCP Server follows a **Bridge with Adapter** pattern:
|
||||
The FreeCAD Robust MCP Server follows a **Bridge with Adapter** pattern:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
@@ -125,10 +125,10 @@ class FreecadBridge(ABC):
|
||||
The workbench addon runs inside FreeCAD:
|
||||
|
||||
```text
|
||||
addon/FreecadRobustMCP/
|
||||
addon/FreecadRobustMCPBridge/
|
||||
├── Init.py # Module initialization
|
||||
├── InitGui.py # GUI initialization (workbench)
|
||||
├── FreecadRobustMCP.svg # Workbench icon
|
||||
├── FreecadRobustMCPBridge.svg # Workbench icon
|
||||
└── freecad_mcp_bridge/ # Bridge plugin
|
||||
├── __init__.py
|
||||
├── server.py # XML-RPC/JSON-RPC server
|
||||
|
||||
@@ -88,7 +88,7 @@ freecad-robust-mcp-and-more/
|
||||
│ ├── prompts/ # MCP prompt templates
|
||||
│ └── server.py # Main server entry point
|
||||
├── addon/ # FreeCAD workbench addon
|
||||
│ └── FreecadRobustMCP/ # Workbench files
|
||||
│ └── FreecadRobustMCPBridge/ # Workbench files
|
||||
├── macros/ # Standalone FreeCAD macros
|
||||
├── tests/ # Test suite
|
||||
│ ├── unit/ # Unit tests
|
||||
|
||||
@@ -338,7 +338,7 @@ Variables are defined in `docs/variables.yaml`:
|
||||
|
||||
```yaml
|
||||
# docs/variables.yaml
|
||||
project_name: FreeCAD Robust MCP Server
|
||||
project_name: FreeCAD Robust MCP Suite
|
||||
package_name: freecad-robust-mcp
|
||||
xmlrpc_port: 9875
|
||||
socket_port: 9876
|
||||
@@ -355,7 +355,7 @@ Install with: `pip install {{@ package_name @}}`
|
||||
|
||||
| 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 |
|
||||
| `docker_image` | spkane/freecad-robust-mcp | Docker image |
|
||||
| `xmlrpc_port` | 9875 | XML-RPC server port |
|
||||
|
||||
@@ -244,7 +244,7 @@ just release::tag-workbench 1.0.0
|
||||
|
||||
**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>`)
|
||||
|
||||
**What happens automatically:**
|
||||
|
||||
@@ -151,7 +151,7 @@ just freecad::run-gui
|
||||
just freecad::run-headless
|
||||
|
||||
# Or run directly with FreeCADCmd
|
||||
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
|
||||
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@ The Robust MCP Bridge Workbench runs inside FreeCAD and provides the connection
|
||||
|
||||
1. Open FreeCAD
|
||||
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. Restart FreeCAD
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Before starting, ensure you have:
|
||||
|
||||
```bash
|
||||
# If installed via Addon Manager (Linux)
|
||||
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py
|
||||
FreeCADCmd ~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py
|
||||
|
||||
# If working from source
|
||||
just freecad::run-headless
|
||||
|
||||
@@ -115,7 +115,7 @@ Embedded mode is tested in the CI pipeline with unit tests that mock FreeCAD. Ho
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Need FreeCAD MCP?] --> B{Platform?}
|
||||
A[Need FreeCAD Robust MCP?] --> B{Platform?}
|
||||
B -->|macOS/Windows| C[Use xmlrpc or socket]
|
||||
B -->|Linux| D{Need GUI features?}
|
||||
D -->|Yes| C
|
||||
|
||||
@@ -58,7 +58,7 @@ See [MultiExport documentation](https://github.com/spkane/freecad-robust-mcp-and
|
||||
|
||||
### 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
|
||||
|
||||
@@ -155,13 +155,13 @@ create_macro_from_template(
|
||||
|
||||
**Available templates:**
|
||||
|
||||
| Template | Description |
|
||||
| ----------- | --------------------------- |
|
||||
| `basic` | Minimal macro with imports |
|
||||
| `part` | Part workbench operations |
|
||||
| `sketch` | Sketcher operations |
|
||||
| `gui` | GUI/dialog template |
|
||||
| `selection` | Selection handling template |
|
||||
| Template | Description |
|
||||
| ----------- | --------------------------------- |
|
||||
| `basic` | Minimal macro with imports |
|
||||
| `part` | Part workbench operations |
|
||||
| `sketch` | Sketcher operations |
|
||||
| `gui` | GUI/dialog template |
|
||||
| `selection` | Selection handling template |
|
||||
|
||||
**Example prompt:**
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ The workbench provides:
|
||||
|
||||
1. Open FreeCAD
|
||||
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. 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:
|
||||
|
||||
- **Linux:** `~/.local/share/FreeCAD/Mod/FreecadRobustMCP/`
|
||||
- **macOS:** `~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCP/`
|
||||
- **Windows:** `%APPDATA%\FreeCAD\Mod\FreecadRobustMCP\`
|
||||
- **Linux:** `~/.local/share/FreeCAD/Mod/FreecadRobustMCPBridge/`
|
||||
- **macOS:** `~/Library/Application Support/FreeCAD/Mod/FreecadRobustMCPBridge/`
|
||||
- **Windows:** `%APPDATA%\FreeCAD\Mod\FreecadRobustMCPBridge\`
|
||||
|
||||
---
|
||||
|
||||
@@ -75,14 +75,14 @@ The workbench includes a blocking bridge script for running in server mode (keep
|
||||
**Linux:**
|
||||
|
||||
```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:**
|
||||
|
||||
```bash
|
||||
/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):**
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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!
|
||||
```
|
||||
@@ -79,13 +80,17 @@ This project includes standalone FreeCAD macros:
|
||||
| [Tools Reference](MCP_TOOLS_REFERENCE.md) | Complete API reference for all 82+ MCP tools |
|
||||
| [API Reference](api/server.md) | Python API documentation |
|
||||
| [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
|
||||
|
||||
- [GitHub Repository](https://github.com/spkane/freecad-robust-mcp-and-more)
|
||||
- [PyPI Package](https://pypi.org/project/freecad-robust-mcp/)
|
||||
- [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp)
|
||||
- [Issue Tracker](https://github.com/spkane/freecad-robust-mcp-and-more/issues)
|
||||
- [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/) - Python package for pip installation
|
||||
- [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp) - Pre-built Docker images
|
||||
|
||||
---
|
||||
|
||||
!!! tip "Share This Documentation"
|
||||
Direct link: **[https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/)**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Use in docs with {{@ variable_name @}} (custom delimiters to avoid Python dict conflicts)
|
||||
|
||||
# Project info
|
||||
project_name: FreeCAD Robust MCP Server
|
||||
project_name: FreeCAD Robust MCP Suite
|
||||
package_name: freecad-robust-mcp
|
||||
docker_image: spkane/freecad-robust-mcp
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ clean:
|
||||
|
||||
# Open Python REPL with project modules available
|
||||
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)
|
||||
tree:
|
||||
|
||||
@@ -186,12 +186,12 @@ test:
|
||||
echo ""
|
||||
|
||||
# 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
|
||||
echo "✓ FreeCAD MCP bridge is already running on port 9875"
|
||||
echo "✓ FreeCAD Robust MCP Bridge is already running on port 9875"
|
||||
STARTED_FREECAD=false
|
||||
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...)"
|
||||
|
||||
# Start FreeCAD headless in background, capturing output
|
||||
@@ -205,7 +205,7 @@ test:
|
||||
while ! check_xmlrpc; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
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 "FreeCAD log output:"
|
||||
tail -30 "$FREECAD_LOG"
|
||||
@@ -219,7 +219,7 @@ test:
|
||||
done
|
||||
rm -f "$FREECAD_LOG"
|
||||
FREECAD_LOG="" # Clear so cleanup doesn't try to delete again
|
||||
echo "✓ FreeCAD MCP bridge started (took ${RETRY_COUNT}s)"
|
||||
echo "✓ FreeCAD Robust MCP Bridge started (took ${RETRY_COUNT}s)"
|
||||
STARTED_FREECAD=true
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -29,3 +29,47 @@ open:
|
||||
else
|
||||
echo "Documentation built at: {{project_root}}/site/index.html"
|
||||
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}}"
|
||||
|
||||
@@ -14,7 +14,7 @@ run-headless:
|
||||
set -euo pipefail
|
||||
PROJECT_DIR="{{project_root}}"
|
||||
# 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
|
||||
FREECAD_CMD=""
|
||||
@@ -73,7 +73,7 @@ run-headless-custom freecad_cmd:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROJECT_DIR="{{project_root}}"
|
||||
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py"
|
||||
SCRIPT_PATH="${PROJECT_DIR}/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py"
|
||||
|
||||
if [[ ! -x "{{freecad_cmd}}" ]]; then
|
||||
echo "ERROR: FreeCADCmd not found or not executable: {{freecad_cmd}}"
|
||||
@@ -95,7 +95,7 @@ run-gui:
|
||||
PROJECT_DIR="{{project_root}}"
|
||||
|
||||
# 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
|
||||
echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
|
||||
@@ -172,7 +172,7 @@ run-gui-custom freecad_path:
|
||||
PROJECT_DIR="{{project_root}}"
|
||||
|
||||
# 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
|
||||
echo "ERROR: Startup script not found: $STARTUP_SCRIPT"
|
||||
|
||||
@@ -133,7 +133,7 @@ mcp-bridge-workbench:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PROJECT_DIR="{{project_root}}"
|
||||
ADDON_NAME="FreecadRobustMCP"
|
||||
ADDON_NAME="FreecadRobustMCPBridge"
|
||||
|
||||
# Set FreeCAD directories
|
||||
eval "$(just install::_freecad-dirs)"
|
||||
@@ -239,7 +239,7 @@ mcp-bridge-workbench:
|
||||
u = ET.SubElement(standalone, 'url', type='readme')
|
||||
u.text = readme_url.text
|
||||
|
||||
icon_text = wb_icon.text if wb_icon is not None else 'FreecadRobustMCP.svg'
|
||||
icon_text = wb_icon.text if wb_icon is not None else 'FreecadRobustMCPBridge.svg'
|
||||
ET.SubElement(standalone, 'icon').text = icon_text
|
||||
fcmin_text = wb_freecadmin.text if wb_freecadmin is not None else '0.21'
|
||||
ET.SubElement(standalone, 'freecadmin').text = fcmin_text
|
||||
@@ -247,7 +247,7 @@ mcp-bridge-workbench:
|
||||
# Add content/workbench section
|
||||
content = ET.SubElement(standalone, 'content')
|
||||
wb_el = ET.SubElement(content, 'workbench')
|
||||
cls_text = wb_classname.text if wb_classname is not None else 'FreecadRobustMCPWorkbench'
|
||||
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, 'subdirectory').text = './'
|
||||
|
||||
@@ -306,7 +306,7 @@ mcp-bridge-workbench:
|
||||
uninstall-mcp-bridge-workbench:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ADDON_NAME="FreecadRobustMCP"
|
||||
ADDON_NAME="FreecadRobustMCPBridge"
|
||||
|
||||
# Set FreeCAD directories
|
||||
eval "$(just install::_freecad-dirs)"
|
||||
@@ -506,19 +506,19 @@ status:
|
||||
extract_package_version() {
|
||||
local package_file="$1"
|
||||
PACKAGE_FILE="$package_file" python3 -c '
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
try:
|
||||
package_file = os.environ.get("PACKAGE_FILE", "")
|
||||
tree = ET.parse(package_file)
|
||||
root = tree.getroot()
|
||||
ns = {"pkg": "https://wiki.freecad.org/Package_Metadata"}
|
||||
# Try with namespace first, then without
|
||||
ver = root.find("pkg:version", ns) or root.find("version")
|
||||
print(ver.text if ver is not None else "unknown")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
' 2>/dev/null || echo "unknown"
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
try:
|
||||
package_file = os.environ.get("PACKAGE_FILE", "")
|
||||
tree = ET.parse(package_file)
|
||||
root = tree.getroot()
|
||||
ns = {"pkg": "https://wiki.freecad.org/Package_Metadata"}
|
||||
# Try with namespace first, then without
|
||||
ver = root.find("pkg:version", ns) or root.find("version")
|
||||
print(ver.text if ver is not None else "unknown")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
' 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
# Helper function to extract __Version__ from macro files (handles single/double quotes)
|
||||
@@ -526,29 +526,29 @@ except Exception:
|
||||
extract_macro_version() {
|
||||
local file="$1"
|
||||
MACRO_FILE="$file" python3 -c '
|
||||
import os
|
||||
import re
|
||||
try:
|
||||
macro_file = os.environ.get("MACRO_FILE", "")
|
||||
content = open(macro_file).read()
|
||||
match = re.search(r"__Version__\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", content)
|
||||
print(match.group(1) if match else "unknown")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
' 2>/dev/null || echo "unknown"
|
||||
import os
|
||||
import re
|
||||
try:
|
||||
macro_file = os.environ.get("MACRO_FILE", "")
|
||||
content = open(macro_file).read()
|
||||
match = re.search(r"__Version__\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", content)
|
||||
print(match.group(1) if match else "unknown")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
' 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
# Check workbench
|
||||
if [[ -d "$MOD_DIR/FreecadRobustMCP" ]]; then
|
||||
if [[ -d "$MOD_DIR/FreecadRobustMCPBridge" ]]; then
|
||||
WB_VERSION="unknown"
|
||||
if [[ -f "$MOD_DIR/FreecadRobustMCP/package.xml" ]]; then
|
||||
WB_VERSION=$(extract_package_version "$MOD_DIR/FreecadRobustMCP/package.xml")
|
||||
if [[ -f "$MOD_DIR/FreecadRobustMCPBridge/package.xml" ]]; then
|
||||
WB_VERSION=$(extract_package_version "$MOD_DIR/FreecadRobustMCPBridge/package.xml")
|
||||
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 " Version: $WB_VERSION"
|
||||
echo " Updated: $WB_MOD_TIME"
|
||||
echo " Path: $MOD_DIR/FreecadRobustMCP"
|
||||
echo " Path: $MOD_DIR/FreecadRobustMCPBridge"
|
||||
else
|
||||
echo "✗ Robust MCP Bridge Workbench: NOT INSTALLED"
|
||||
echo " Install: just install::mcp-bridge-workbench"
|
||||
|
||||
@@ -50,7 +50,7 @@ bump-workbench version:
|
||||
echo ""
|
||||
|
||||
# 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
|
||||
sed 's/^__version__ = "[^"]*"/__version__ = "'"$VERSION"'"/' "$INIT_FILE" > "$INIT_FILE.tmp" && mv "$INIT_FILE.tmp" "$INIT_FILE"
|
||||
echo "Updated $INIT_FILE:"
|
||||
@@ -60,6 +60,18 @@ bump-workbench version:
|
||||
exit 1
|
||||
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
|
||||
PACKAGE_XML="{{project_root}}/package.xml"
|
||||
if [ -f "$PACKAGE_XML" ]; then
|
||||
@@ -252,7 +264,7 @@ tag-workbench version:
|
||||
echo "Verifying version in source files..."
|
||||
|
||||
# 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)
|
||||
if [ "$INIT_VERSION" != "$VERSION" ]; then
|
||||
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."
|
||||
exit 1
|
||||
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
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ $PACKAGE_XML (workbench): $PKG_VERSION"
|
||||
|
||||
echo ""
|
||||
echo "Version verification passed!"
|
||||
echo ""
|
||||
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."
|
||||
exit 1
|
||||
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
|
||||
PACKAGE_XML="{{project_root}}/package.xml"
|
||||
@@ -389,7 +435,7 @@ list-tags:
|
||||
echo "=== Robust MCP Server Releases ==="
|
||||
git tag -l 'robust-mcp-server-v*' --sort=-v:refname | head -10
|
||||
echo ""
|
||||
echo "=== MCP Workbench Releases ==="
|
||||
echo "=== Robust MCP Bridge Workbench Releases ==="
|
||||
git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -10
|
||||
echo ""
|
||||
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)
|
||||
EXPORT_TAG=$(git tag -l 'macro-multi-export-v*' --sort=-v:refname | head -n1)
|
||||
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 Export: ${EXPORT_TAG:-none}"
|
||||
|
||||
@@ -424,7 +470,7 @@ changes-since component:
|
||||
;;
|
||||
workbench)
|
||||
PREFIX="robust-mcp-workbench-v"
|
||||
PATHS="addon/FreecadRobustMCP"
|
||||
PATHS="addon/FreecadRobustMCPBridge"
|
||||
;;
|
||||
macro-magnets|magnets)
|
||||
PREFIX="macro-cut-object-for-magnets-v"
|
||||
@@ -531,7 +577,7 @@ status:
|
||||
echo ""
|
||||
|
||||
# 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)
|
||||
if [ "$WORKBENCH_CHANGES" -gt 0 ]; then
|
||||
echo "Robust MCP Bridge Workbench: $WORKBENCH_CHANGES unreleased commit(s)"
|
||||
@@ -584,7 +630,7 @@ draft-notes component:
|
||||
;;
|
||||
workbench)
|
||||
PREFIX="robust-mcp-workbench-v"
|
||||
PATHS="addon/FreecadRobustMCP"
|
||||
PATHS="addon/FreecadRobustMCPBridge"
|
||||
COMPONENT_NAME="Robust MCP Bridge Workbench"
|
||||
;;
|
||||
macro-magnets|magnets)
|
||||
@@ -735,25 +781,30 @@ dry-run-tag component version:
|
||||
# FreeCAD Wiki Update Helpers
|
||||
# =============================================================================
|
||||
|
||||
# Helper to update FreeCAD wiki for a macro (copies content to clipboard and opens edit page)
|
||||
wiki-update macro:
|
||||
# Helper to update FreeCAD wiki for a component (copies content to clipboard and opens edit page)
|
||||
wiki-update component:
|
||||
#!/usr/bin/env bash
|
||||
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)
|
||||
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
|
||||
WIKI_PAGE="Macro_Cut_Object_for_Magnets"
|
||||
MACRO_NAME="Cut Object for Magnets"
|
||||
COMPONENT_NAME="Cut Object for Magnets"
|
||||
;;
|
||||
macro-export|export|multi)
|
||||
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
|
||||
WIKI_PAGE="Macro_Multi_Export"
|
||||
MACRO_NAME="Multi Export"
|
||||
COMPONENT_NAME="Multi Export"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown macro: {{macro}}"
|
||||
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)"
|
||||
echo "Unknown component: {{component}}"
|
||||
echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -764,7 +815,7 @@ wiki-update macro:
|
||||
echo "FreeCAD Wiki Update Helper"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Macro: $MACRO_NAME"
|
||||
echo "Component: $COMPONENT_NAME"
|
||||
echo "Wiki Page: https://wiki.freecad.org/${WIKI_PAGE}"
|
||||
echo ""
|
||||
|
||||
@@ -859,29 +910,33 @@ wiki-update macro:
|
||||
echo "The wiki content is in your clipboard - ready to paste!"
|
||||
fi
|
||||
|
||||
# Show the wiki source content for a macro (for review)
|
||||
wiki-show macro:
|
||||
# Show the wiki source content for a component (for review)
|
||||
wiki-show component:
|
||||
#!/usr/bin/env bash
|
||||
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)
|
||||
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)
|
||||
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
|
||||
MACRO_NAME="Multi Export"
|
||||
COMPONENT_NAME="Multi Export"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown macro: {{macro}}"
|
||||
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)"
|
||||
echo "Unknown component: {{component}}"
|
||||
echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "=========================================="
|
||||
echo "Wiki Source: $MACRO_NAME"
|
||||
echo "Wiki Source: $COMPONENT_NAME"
|
||||
echo "=========================================="
|
||||
echo "File: $WIKI_SOURCE"
|
||||
echo ""
|
||||
@@ -898,31 +953,36 @@ wiki-show macro:
|
||||
cat "$WIKI_SOURCE"
|
||||
|
||||
# Diff the local wiki source against the current wiki page (requires curl)
|
||||
wiki-diff macro:
|
||||
wiki-diff component:
|
||||
#!/usr/bin/env bash
|
||||
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)
|
||||
WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt"
|
||||
WIKI_PAGE="Macro_Cut_Object_for_Magnets"
|
||||
MACRO_NAME="Cut Object for Magnets"
|
||||
COMPONENT_NAME="Cut Object for Magnets"
|
||||
;;
|
||||
macro-export|export|multi)
|
||||
WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt"
|
||||
WIKI_PAGE="Macro_Multi_Export"
|
||||
MACRO_NAME="Multi Export"
|
||||
COMPONENT_NAME="Multi Export"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown macro: {{macro}}"
|
||||
echo "Valid options: macro-magnets (or magnets, cut), macro-export (or export, multi)"
|
||||
echo "Unknown component: {{component}}"
|
||||
echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
WIKI_RAW_URL="https://wiki.freecad.org/index.php?title=${WIKI_PAGE}&action=raw"
|
||||
|
||||
echo "Fetching current wiki content for $MACRO_NAME..."
|
||||
echo "Fetching current wiki content for $COMPONENT_NAME..."
|
||||
echo ""
|
||||
|
||||
# Create temp file for wiki content
|
||||
@@ -945,7 +1005,7 @@ wiki-diff macro:
|
||||
echo "=========================================="
|
||||
head -20 "$WIKI_SOURCE"
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -959,5 +1019,5 @@ wiki-diff macro:
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
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
|
||||
|
||||
@@ -20,7 +20,7 @@ cov:
|
||||
fast:
|
||||
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:
|
||||
uv run pytest {{project_root}}/tests/integration -v
|
||||
|
||||
@@ -68,51 +68,19 @@ integration-freecad-auto:
|
||||
#!/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
|
||||
|
||||
# Helper function to kill process on a port (with fallback for systems without lsof)
|
||||
# Usage: kill_port PORT [SIGNAL]
|
||||
# Examples: kill_port 9875 (sends SIGTERM)
|
||||
# kill_port 9875 -9 (sends SIGKILL)
|
||||
kill_port() {
|
||||
local port=$1
|
||||
local signal=${2:--TERM} # Default to SIGTERM if no signal specified
|
||||
local pids=""
|
||||
|
||||
if command -v lsof &>/dev/null; then
|
||||
# Collect PIDs first, then kill only if non-empty
|
||||
pids=$(lsof -ti:"$port" 2>/dev/null || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
echo "$pids" | xargs kill "$signal" 2>/dev/null || true
|
||||
fi
|
||||
elif command -v fuser &>/dev/null; then
|
||||
# fuser -k sends SIGKILL by default; use --signal for others
|
||||
# Check if port is in use first
|
||||
if fuser "$port/tcp" 2>/dev/null; then
|
||||
if [ "$signal" = "-9" ] || [ "$signal" = "-KILL" ]; then
|
||||
fuser -k "$port/tcp" 2>/dev/null || true
|
||||
else
|
||||
fuser -k --signal "${signal#-}" "$port/tcp" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Cleanup function to ensure FreeCAD is stopped
|
||||
# We kill by port since the subshell approach makes PID tracking unreliable
|
||||
cleanup() {
|
||||
if [ "$STARTED_FREECAD" = true ]; then
|
||||
echo ""
|
||||
echo "Stopping FreeCAD..."
|
||||
# Kill processes on our ports - these are the ones we started
|
||||
kill_port 9875
|
||||
kill_port 9876
|
||||
# Wait briefly for graceful shutdown
|
||||
sleep 1
|
||||
# Force kill if still running
|
||||
kill_port 9875 -9
|
||||
kill_port 9876 -9
|
||||
graceful_kill_bridge_ports
|
||||
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
|
||||
# Try to ping - if it responds, there's a healthy bridge already running
|
||||
if uv run python -c "import socket; socket.setdefaulttimeout(2); import xmlrpc.client; print(xmlrpc.client.ServerProxy('http://localhost:9875').ping())" 2>/dev/null | grep -q "pong"; then
|
||||
echo "ERROR: A FreeCAD MCP bridge is already running on port 9875."
|
||||
echo "ERROR: A FreeCAD Robust MCP Bridge is already running on port 9875."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
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" &
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
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"
|
||||
if [ -f "$FREECAD_LOG" ]; then
|
||||
echo "--- Last 20 lines of log ---"
|
||||
@@ -176,7 +144,7 @@ integration-freecad-auto:
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "FreeCAD MCP bridge is ready!"
|
||||
echo "FreeCAD Robust MCP Bridge is ready!"
|
||||
echo ""
|
||||
|
||||
# Run integration tests
|
||||
@@ -206,40 +174,341 @@ just-all:
|
||||
just-release:
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
# 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:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Source shared bridge helper functions
|
||||
. "{{project_root}}/scripts/bridge-helpers.sh"
|
||||
|
||||
echo "Killing any processes on MCP bridge ports (9875, 9876)..."
|
||||
|
||||
# Helper function to kill process on a port (with fallback for systems without lsof)
|
||||
# Collects PIDs first to avoid calling kill with no arguments
|
||||
kill_port() {
|
||||
local port=$1
|
||||
local pids=""
|
||||
|
||||
if command -v lsof &>/dev/null; then
|
||||
# Collect PIDs first, then kill only if non-empty
|
||||
pids=$(lsof -ti:"$port" 2>/dev/null || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||
fi
|
||||
elif command -v fuser &>/dev/null; then
|
||||
# Check if port is in use first
|
||||
if fuser "$port/tcp" 2>/dev/null; then
|
||||
fuser -k -9 "$port/tcp" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo "Warning: Neither lsof nor fuser available, cannot kill port $port"
|
||||
fi
|
||||
}
|
||||
|
||||
kill_port 9875
|
||||
kill_port 9876
|
||||
|
||||
force_kill_bridge_ports
|
||||
echo "Done."
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# FreeCAD MCP Server - Development Workflow Commands
|
||||
# FreeCAD Robust MCP Suite - Development Workflow Commands
|
||||
# https://just.systems/
|
||||
#
|
||||
# Commands are organized into modules. Run `just` to see top-level commands,
|
||||
|
||||
@@ -21,8 +21,8 @@ Usage:
|
||||
__Name__ = "Cut Object for Magnets"
|
||||
__Comment__ = "Cut an object along a plane and add aligned magnet holes with surface collision detection"
|
||||
__Author__ = "Sean P. Kane"
|
||||
__Version__ = "0.5.0-beta"
|
||||
__Date__ = "2026-01-05"
|
||||
__Version__ = "0.6.0"
|
||||
__Date__ = "2026-01-12"
|
||||
__License__ = "MIT"
|
||||
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
|
||||
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||

|
||||
|
||||
**Version:** 0.5.0-beta
|
||||
**Version:** 0.6.0
|
||||
**FreeCAD Version:** 0.19 or later
|
||||
**License:** MIT
|
||||
|
||||
> **Documentation:** [https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/)
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|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.
|
||||
|Author=Sean P. Kane
|
||||
|Version=0.5.0-beta
|
||||
|Date=2026-01-05
|
||||
|Version=0.6.0
|
||||
|Date=2026-01-12
|
||||
|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]
|
||||
|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-->
|
||||
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 GitHub Repository (freecad-robust-mcp-and-more)]
|
||||
|
||||
==Script== <!--T:23-->
|
||||
|
||||
@@ -158,6 +157,8 @@ ToolBar Icon [[Image:Macro_Cut_Object_For_Magnets.png]]
|
||||
==Links== <!--T:25-->
|
||||
|
||||
<!--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
|
||||
* [[PartDesign_Hole|PartDesign Hole]] - Parametric hole feature documentation
|
||||
* [[PartDesign_Body|PartDesign Body]] - Body container documentation
|
||||
|
||||
@@ -22,8 +22,8 @@ Usage:
|
||||
__Name__ = "Multi Export"
|
||||
__Comment__ = "Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously"
|
||||
__Author__ = "Sean P. Kane"
|
||||
__Version__ = "0.5.0-beta"
|
||||
__Date__ = "2026-01-05"
|
||||
__Version__ = "0.6.0"
|
||||
__Date__ = "2026-01-12"
|
||||
__License__ = "MIT"
|
||||
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
|
||||
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||

|
||||
|
||||
**Version:** 0.5.0-beta
|
||||
**Version:** 0.6.0
|
||||
**FreeCAD Version:** 0.19 or later
|
||||
**License:** MIT
|
||||
|
||||
> **Documentation:** [https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/)
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|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.
|
||||
|Author=Sean P. Kane
|
||||
|Version=0.5.0-beta
|
||||
|Date=2026-01-05
|
||||
|Version=0.6.0
|
||||
|Date=2026-01-12
|
||||
|FCVersion=0.21+
|
||||
|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]]
|
||||
@@ -76,7 +76,6 @@ This macro provides a convenient way to export selected FreeCAD objects to multi
|
||||
<!--T:13-->
|
||||
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 GitHub Repository (freecad-robust-mcp-and-more)]
|
||||
|
||||
==Script== <!--T:14-->
|
||||
|
||||
@@ -92,6 +91,8 @@ ToolBar Icon [[Image:Macro_Multi_Export.png]]
|
||||
==Links== <!--T:16-->
|
||||
|
||||
<!--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
|
||||
* [[Import_Export|Import Export]] - Overview of FreeCAD import/export formats
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
site_name: FreeCAD Robust MCP Server
|
||||
site_description: Robust MCP Server for FreeCAD integration with AI assistants
|
||||
site_name: FreeCAD Robust MCP Suite
|
||||
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
|
||||
repo_url: https://github.com/spkane/freecad-robust-mcp-and-more
|
||||
repo_name: spkane/freecad-robust-mcp-and-more
|
||||
@@ -106,6 +106,10 @@ plugins:
|
||||
j2_block_end_string: "@%}"
|
||||
|
||||
extra:
|
||||
# Version selector for mike-managed versioned docs
|
||||
version:
|
||||
provider: mike
|
||||
default: latest
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/spkane/freecad-robust-mcp-and-more
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
|
||||
|
||||
<name>FreeCAD MCP and More</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>
|
||||
<icon>addon/FreecadRobustMCP/FreecadRobustMCP.svg</icon>
|
||||
<name>FreeCAD Robust MCP Suite</name>
|
||||
<description>A collection of FreeCAD macros plus a robust MCP (Model Context Protocol) bridge workbench for AI assistant integration.</description>
|
||||
<icon>addon/FreecadRobustMCPBridge/FreecadRobustMCPBridge.svg</icon>
|
||||
|
||||
<!-- Note: Each component has its own version below. This top-level date is for reference only. -->
|
||||
<date>2026-01-07</date>
|
||||
@@ -37,19 +37,19 @@
|
||||
|
||||
<workbench>
|
||||
<name>Robust MCP Bridge</name>
|
||||
<version>0.5.0-beta</version>
|
||||
<date>2026-01-07</date>
|
||||
<version>0.6.0</version>
|
||||
<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>
|
||||
<classname>FreecadRobustMCPWorkbench</classname>
|
||||
<subdirectory>./addon/FreecadRobustMCP/</subdirectory>
|
||||
<icon>FreecadRobustMCP.svg</icon>
|
||||
<classname>FreecadRobustMCPBridgeWorkbench</classname>
|
||||
<subdirectory>./addon/FreecadRobustMCPBridge/</subdirectory>
|
||||
<icon>FreecadRobustMCPBridge.svg</icon>
|
||||
<freecadmin>0.21</freecadmin>
|
||||
</workbench>
|
||||
|
||||
<macro>
|
||||
<name>Multi Export</name>
|
||||
<version>0.5.0-beta</version>
|
||||
<date>2026-01-07</date>
|
||||
<version>0.6.0</version>
|
||||
<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>
|
||||
<subdirectory>./macros/Multi_Export/</subdirectory>
|
||||
<file>MultiExport.FCMacro</file>
|
||||
@@ -58,8 +58,8 @@
|
||||
|
||||
<macro>
|
||||
<name>Cut Object for Magnets</name>
|
||||
<version>0.5.0-beta</version>
|
||||
<date>2026-01-07</date>
|
||||
<version>0.6.0</version>
|
||||
<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>
|
||||
<subdirectory>./macros/Cut_Object_for_Magnets/</subdirectory>
|
||||
<file>CutObjectForMagnets.FCMacro</file>
|
||||
|
||||
@@ -86,6 +86,7 @@ dev = [
|
||||
"mkdocs-redirects>=1.2.0",
|
||||
"mkdocs-glightbox>=0.4.0",
|
||||
"mkdocs-macros-plugin>=1.0.0",
|
||||
"mike>=2.1.0",
|
||||
"md-toc>=9.0.0",
|
||||
"codespell>=2.3.0",
|
||||
# Publishing
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
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):
|
||||
- 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}
|
||||
|
||||
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,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
|
||||
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()
|
||||
async def troubleshooting() -> str:
|
||||
"""General troubleshooting guide for FreeCAD MCP.
|
||||
"""General troubleshooting guide for FreeCAD Robust MCP.
|
||||
|
||||
Returns:
|
||||
Troubleshooting guidance.
|
||||
"""
|
||||
return """# FreeCAD MCP Troubleshooting Guide
|
||||
return """# FreeCAD Robust MCP Troubleshooting Guide
|
||||
|
||||
## Connection Issues
|
||||
|
||||
|
||||
@@ -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
|
||||
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": [
|
||||
{
|
||||
"name": "freecad-help",
|
||||
"description": "Get help on FreeCAD MCP capabilities",
|
||||
"description": "Get help on FreeCAD Robust MCP capabilities",
|
||||
},
|
||||
{
|
||||
"name": "create-parametric-part",
|
||||
|
||||
@@ -318,7 +318,7 @@ Examples:
|
||||
FREECAD_SOCKET_HOST=192.168.1.100 freecad-mcp
|
||||
|
||||
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:
|
||||
- FreeCAD GUI: Install Robust MCP Bridge workbench, enable auto-start
|
||||
- Headless: just freecad::run-headless
|
||||
@@ -411,9 +411,9 @@ def main() -> None:
|
||||
# Set up logging
|
||||
logging.getLogger().setLevel(config.log_level)
|
||||
|
||||
# Print instance ID to stdout for test automation to capture
|
||||
# This is printed before logging to ensure it's easily parseable
|
||||
print(f"FREECAD_MCP_INSTANCE_ID={INSTANCE_ID}", file=sys.stdout, flush=True)
|
||||
# Print instance ID to stderr for test automation to capture
|
||||
# Must use stderr because stdout is reserved for JSON-RPC in stdio mode
|
||||
print(f"FREECAD_MCP_INSTANCE_ID={INSTANCE_ID}", file=sys.stderr, flush=True)
|
||||
|
||||
logger.info("Starting FreeCAD Robust MCP Server")
|
||||
logger.info("Instance ID: %s", INSTANCE_ID)
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Test suite for FreeCAD MCP Server."""
|
||||
"""Test suite for FreeCAD Robust MCP Suite."""
|
||||
|
||||
@@ -223,10 +223,10 @@ ls -la /tmp/screenshot.png /tmp/gui_test.FCStd 2>/dev/null || echo "Files not cr
|
||||
|
||||
echo ""
|
||||
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..."
|
||||
|
||||
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=$!
|
||||
echo "FreeCAD PID: $FREECAD_PID"
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Pytest configuration for integration tests.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
to the expected instance using the `bridge_instance_id` fixture or by
|
||||
calling proxy.get_instance_id().
|
||||
@@ -28,7 +28,7 @@ _connection_checked: bool = False
|
||||
|
||||
|
||||
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:
|
||||
Tuple of (is_available, error_message, instance_id)
|
||||
@@ -65,17 +65,17 @@ _result_ = {"gui_up": bool(FreeCAD.GuiUp)}
|
||||
_gui_available = False
|
||||
else:
|
||||
_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
|
||||
_gui_available = None
|
||||
except ConnectionRefusedError:
|
||||
_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
|
||||
_gui_available = None
|
||||
except Exception as e:
|
||||
_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
|
||||
_gui_available = None
|
||||
|
||||
@@ -182,7 +182,7 @@ def pytest_collection_modifyitems(
|
||||
_connection_checked = True
|
||||
pytest.fail(
|
||||
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"Error: {error}\n\n"
|
||||
f"To run integration tests, start FreeCAD with the MCP bridge:\n"
|
||||
@@ -209,7 +209,7 @@ def pytest_terminal_summary(
|
||||
return
|
||||
|
||||
# Build the summary message
|
||||
terminalreporter.write_sep("=", "FreeCAD MCP Bridge Status")
|
||||
terminalreporter.write_sep("=", "FreeCAD Robust MCP Bridge Status")
|
||||
|
||||
if _bridge_available:
|
||||
mode = "GUI" if _gui_available else "Headless"
|
||||
@@ -227,21 +227,21 @@ def pytest_terminal_summary(
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
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.
|
||||
The connection check has already been performed during collection.
|
||||
"""
|
||||
is_available, error, _ = _check_bridge_connection()
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
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
|
||||
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
|
||||
- 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
|
||||
|
||||
Note: PartDesign::Hole has a CADKernelError bug in some FreeCAD headless
|
||||
|
||||
@@ -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
|
||||
in GUI mode (with full graphical interface). They test GUI-specific features
|
||||
|
||||
@@ -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
|
||||
object creation, manipulation, and export functionality. Most tests work in
|
||||
|
||||
@@ -6,7 +6,7 @@ These tests verify the MultiExporter class functionality including:
|
||||
- Exporting to multiple formats simultaneously
|
||||
- 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
|
||||
|
||||
To run these tests:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
||||
class TestDocumentationSyntax:
|
||||
"""Syntax validation tests for documentation commands."""
|
||||
|
||||
# Basic documentation commands
|
||||
DOC_COMMANDS: ClassVar[list[str]] = [
|
||||
"documentation::build",
|
||||
"documentation::build-strict",
|
||||
@@ -23,6 +24,19 @@ class TestDocumentationSyntax:
|
||||
"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.parametrize("command", DOC_COMMANDS)
|
||||
def test_documentation_command_syntax(self, just: JustRunner, command: str) -> None:
|
||||
@@ -30,6 +44,28 @@ class TestDocumentationSyntax:
|
||||
result = just.dry_run(command)
|
||||
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:
|
||||
"""Runtime tests for documentation commands."""
|
||||
@@ -45,3 +81,10 @@ class TestDocumentationRuntime:
|
||||
"""Documentation build-strict should run successfully."""
|
||||
result = just.run("documentation::build-strict", timeout=120)
|
||||
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.
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestMCPSyntax:
|
||||
class TestMCPRuntime:
|
||||
"""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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -170,7 +170,8 @@ class TestReleaseBumpCommands:
|
||||
"""Backup files before bump tests and restore after."""
|
||||
# Files that bump commands modify
|
||||
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 / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro",
|
||||
PROJECT_ROOT
|
||||
@@ -184,13 +185,13 @@ class TestReleaseBumpCommands:
|
||||
backups: dict[Path, str] = {}
|
||||
for file_path in files_to_backup:
|
||||
if file_path.exists():
|
||||
backups[file_path] = file_path.read_text()
|
||||
backups[file_path] = file_path.read_text(encoding="utf-8")
|
||||
|
||||
yield
|
||||
|
||||
# Restore all files
|
||||
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_release
|
||||
@@ -204,7 +205,7 @@ class TestReleaseBumpCommands:
|
||||
|
||||
# Verify version was updated
|
||||
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()
|
||||
assert "99.99.99-test" in content
|
||||
|
||||
@@ -28,6 +28,13 @@ class TestTestingSyntax:
|
||||
"testing::all",
|
||||
"testing::watch",
|
||||
"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",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Unit tests for FreeCAD MCP Server."""
|
||||
"""Unit tests for FreeCAD Robust MCP Suite."""
|
||||
|
||||
@@ -10,54 +10,56 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# 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:
|
||||
"""Tests for addon file structure."""
|
||||
|
||||
def test_addon_directory_exists(self):
|
||||
def test_addon_directory_exists(self) -> None:
|
||||
"""The addon directory should exist."""
|
||||
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}"
|
||||
|
||||
def test_init_py_exists(self):
|
||||
def test_init_py_exists(self) -> None:
|
||||
"""Init.py should exist in the addon directory."""
|
||||
init_file = ADDON_DIR / "Init.py"
|
||||
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_file = ADDON_DIR / "InitGui.py"
|
||||
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."""
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCPBridge.svg"
|
||||
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."""
|
||||
bridge_dir = ADDON_DIR / "freecad_mcp_bridge"
|
||||
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}"
|
||||
|
||||
def test_bridge_init_exists(self):
|
||||
def test_bridge_init_exists(self) -> None:
|
||||
"""The bridge module __init__.py should exist."""
|
||||
init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py"
|
||||
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."""
|
||||
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
|
||||
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."""
|
||||
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
|
||||
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."""
|
||||
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
|
||||
assert utils_file.exists(), f"bridge_utils.py not found: {utils_file}"
|
||||
@@ -66,39 +68,39 @@ class TestAddonFileStructure:
|
||||
class TestAddonPythonSyntax:
|
||||
"""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_file = ADDON_DIR / "Init.py"
|
||||
code = init_file.read_text()
|
||||
# This will raise SyntaxError if invalid
|
||||
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_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
# This will raise SyntaxError if invalid
|
||||
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."""
|
||||
init_file = ADDON_DIR / "freecad_mcp_bridge" / "__init__.py"
|
||||
code = init_file.read_text()
|
||||
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."""
|
||||
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
|
||||
code = server_file.read_text()
|
||||
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_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
|
||||
code = blocking_file.read_text()
|
||||
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."""
|
||||
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
|
||||
code = utils_file.read_text()
|
||||
@@ -108,59 +110,59 @@ class TestAddonPythonSyntax:
|
||||
class TestAddonMetadata:
|
||||
"""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_file = ADDON_DIR / "Init.py"
|
||||
code = init_file.read_text()
|
||||
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_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
assert "FreecadRobustMCPWorkbench" in code
|
||||
assert "FreecadRobustMCPBridgeWorkbench" 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_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
assert "StartMCPBridgeCommand" 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_file = ADDON_DIR / "InitGui.py"
|
||||
code = initgui_file.read_text()
|
||||
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."""
|
||||
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
|
||||
code = server_file.read_text()
|
||||
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_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
|
||||
code = blocking_file.read_text()
|
||||
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_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
|
||||
code = blocking_file.read_text()
|
||||
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."""
|
||||
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
|
||||
code = utils_file.read_text()
|
||||
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."""
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCPBridge.svg"
|
||||
content = icon_file.read_text()
|
||||
assert content.startswith("<?xml") or content.startswith("<svg")
|
||||
assert "<svg" in content
|
||||
@@ -170,9 +172,9 @@ class TestAddonMetadata:
|
||||
class TestAddonIconSize:
|
||||
"""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)."""
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
|
||||
icon_file = ADDON_DIR / "FreecadRobustMCPBridge.svg"
|
||||
size_bytes = icon_file.stat().st_size
|
||||
size_kb = size_bytes / 1024
|
||||
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."""
|
||||
|
||||
@pytest.fixture
|
||||
def package_xml(self):
|
||||
def package_xml(self) -> str:
|
||||
"""Load package.xml content."""
|
||||
package_file = ADDON_DIR.parent.parent / "package.xml"
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
assert "<icon>FreecadRobustMCP.svg</icon>" in package_xml
|
||||
assert "<icon>FreecadRobustMCPBridge.svg</icon>" in package_xml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for MCP resources module."""
|
||||
"""Tests for FreeCAD Robust MCP resources."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
@@ -17,7 +17,7 @@ from freecad_mcp.bridge.base import (
|
||||
|
||||
|
||||
class TestFreecadResources:
|
||||
"""Tests for FreeCAD MCP resources."""
|
||||
"""Tests for FreeCAD Robust MCP resources."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mcp(self) -> MagicMock:
|
||||
@@ -437,3 +437,43 @@ class TestFreecadResources:
|
||||
|
||||
# Should have prompts section
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -246,9 +246,17 @@ class TestMain:
|
||||
|
||||
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]
|
||||
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):
|
||||
"""Main should start HTTP transport when configured."""
|
||||
@@ -295,3 +303,203 @@ class TestMain:
|
||||
|
||||
# Should call run without transport arguments (stdio is default)
|
||||
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()
|
||||
|
||||
@@ -594,6 +594,7 @@ dev = [
|
||||
{ name = "commitizen" },
|
||||
{ name = "detect-secrets" },
|
||||
{ name = "md-toc" },
|
||||
{ name = "mike" },
|
||||
{ name = "mkdocs" },
|
||||
{ name = "mkdocs-git-revision-date-localized-plugin" },
|
||||
{ name = "mkdocs-glightbox" },
|
||||
@@ -623,6 +624,7 @@ requires-dist = [
|
||||
{ name = "detect-secrets", marker = "extra == 'dev'", specifier = ">=1.5.0" },
|
||||
{ name = "mcp", specifier = ">=1.25.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-git-revision-date-localized-plugin", marker = "extra == 'dev'", specifier = ">=1.2.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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "iniconfig"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "mkdocs"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyproject-hooks"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "virtualenv"
|
||||
version = "20.36.0"
|
||||
|
||||